diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 9c5d00eae0..820299cf2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.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-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: cce649976c9f4f596d5306b9fe8c3fd49a0e1adc -2026-07-16-explicit-turn-cancellation.zh.md: 6f8b83fdb42af03c97dc2e8a9345a01acc6019fc +2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 +2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index cce649976c..ca56c77a09 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. -The explicit event signatures keep their positional form and place `signal` inside `PreStepContext` or immediately before a waterfall's final `next`. Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. +The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. @@ -44,7 +44,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and **Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning. -**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. +**Expose public turn or step context wrappers.** Existing seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority. **Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 6f8b83fdb4..bf410e5c72 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留位置参数形式,并把 `signal` 放入 `PreStepContext`,或放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 @@ -44,7 +44,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 **现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 -**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 +**公开轮次或步骤上下文包装类型。** 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 **在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 37eccb0c95..9c8308be74 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: aeefbe22a397e3d7ffb9f6427a3c70c8c8e8b940 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 056d50d45cef891e0e635d8bb4f2e73064ccdb87 +2026-07-25-web-client-session-scope-and-provide-channel.md: 3c51f06fca23a495f0fbc0cc4f1c289edea07b3b +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 06fc1005785d9d11b52839f91c3bb4b99cad7d63 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index aeefbe22a3..3c51f06fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 056d50d45c..06fc100578 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 -`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index a52995c855..f22f2340ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 977df6508e1a1cd54cf1ddb469a6bfb835f60071 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: f70065c8b356b2ed5ca6ab317fbdeb5177f058fa +2026-07-25-web-input-machine-and-slash-pipeline.md: 39ef214a94fcd019f535fb60136d5dcc09b54e60 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 9b0ca0cadbc5e0212048b165f0d60d567a5639ad diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 977df6508e..39ef214a94 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -69,9 +69,9 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": ### hub / facade: the resident shell and the strict-session input body - The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation. -- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. +- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears. - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. -- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. +- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted. - Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists. - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current. - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee. @@ -93,9 +93,10 @@ skill/@subagent references skip the placeholder + occurrence identity chain — ### The slot system -`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The child slots are all declared by ui-conversation's conversation registration: +`conversation` is itself session-maybe; its session content and the composer input slots are strict session, while the Hero Workspace picker stays root. The root registration renders the header outlet above its resident scrollport and the body outlet inside it, before the resident composer seat. The child slots are all declared by ui-conversation's conversation registration: -- `conversation.session` (single) — the strict-session header, view ring, and chat store; rebuilt when the session id switches. +- `conversation.session.header` (single) — strict-session breadcrumb, view tabs, and header actions above the resident scrollport. +- `conversation.session` (single) — the strict-session view ring and draft mirror inside the resident scrollport. Header and body share the same session-scoped chat store; each is rebuilt when the session id switches. - `conversation.composer.bar` (single) — the slot for the InputBar itself: the InputBar is a true slot entry (self-registered into its own slot) and the content of the composer chain's fallback; it is not a chain entry — the chain's single election would unmount it on a takeover, breaking textarea DOM survival. - `conversation.input.overlay` — the floating-overlay anchor inside the input card; registrants' inject resolves each one's own per-session controller by the slot sessionId. - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. @@ -128,7 +129,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ ## Consequences -- One resident conversation shell carries no-session/blank/active: no session → blank guarantees only the outer frame's React identity, allowing the disabled textarea to be replaced by the strict InputBar; the same blank session → engaging/active keeps the InputBar and the textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. +- One resident conversation shell carries no-session/blank/active: no session → blank preserves ConversationRoot, Hero, the root-scoped Workspace picker, scrollport, composer seat, InputBar, and textarea; only the strict header and body outlets gain content. The same blank session → engaging/active also keeps the InputBar and textarea. EmptyState and the controlled intent chain (`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`) are deleted along with their last consumer. - The input surface's zero knowledge of commands plus optional dependencies: pure input works without the command packages; `@` references and skill references get free reuse of the same menu/pick pipeline. The cost is that space/enter adjudication is a per-source polling protocol whose answer semantics (sync/async, the meaning of undefined) are a frozen contract. - Transactionalized submission (attempt seq + the drift guard) makes the three defect classes — stale-result backwash, session switching, concurrent replay — structurally impossible, pinned by the matrix tests. - Known gaps: chip fidelity across refresh (paste matching is reusable for it) has no workstream yet; the subagent reference's model representation awaits its business workstream. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index f70065c8b3..9b0ca0cadb 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -69,9 +69,9 @@ occurrence 表与 chip 三投影: ### hub / facade:常驻外壳与严格 session 输入体 - hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。 -- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。 +- 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;Session 出现后,彼此独立的严格 session header 和 body outlet 只填入这些固定区域。 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。 -- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 +- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的 Session 在任何 open state 下都保持 Hero,未经证实的 Session 则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。 - 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。 @@ -93,9 +93,10 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ### slot 体系 -`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。子 slot 均由 ui-conversation 的 conversation 注册声明: +`conversation` 本身是 session-maybe;其会话内容与 composer 输入 slot 严格限定为 session,Hero Workspace picker 保持 root。root 注册把 header outlet 渲染在常驻 scrollport 上方,把 body outlet 渲染在其内部、常驻 composer seat 之前。子 slot 均由 ui-conversation 的 conversation 注册声明: -- `conversation.session`(single)——严格 session 的 header、view ring 与 chat store;session id 切换时重建。 +- `conversation.session.header`(single)——常驻 scrollport 上方严格 session 的 breadcrumb、view tab 与 header action。 +- `conversation.session`(single)——常驻 scrollport 内严格 session 的 view ring 与 draft mirror。header 和 body 共享同一个 session scope chat store;session id 切换时各自重建。 - `conversation.composer.bar`(single)——InputBar 本体的 slot:InputBar 是真 slot entry(自有 slot 自注册),composer chain fallback 的内容;不做 chain entry——chain 单选举会在 takeover 时卸载它,破坏 textarea DOM 存活。 - `conversation.input.overlay`——输入卡内浮层锚点;注册者 inject 按 slot sessionId 解析各自 per-session controller。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 @@ -128,7 +129,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 ## 后果 -- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 只保证大框架 React identity,允许 disabled textarea 替换为严格 InputBar;同一 blank session → engaging/active 保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 +- 一个常驻 conversation 外壳承接 no-session/blank/active:无 session → blank 保持 ConversationRoot、Hero、root scope Workspace picker、scrollport、composer seat、InputBar 与 textarea;只有严格 session header 和 body outlet 开始承载内容。同一 blank session → engaging/active 也保持 InputBar 与 textarea。EmptyState 与受控 intent 链(`sessions.updateIntent`/`updatePendingPrompt`/`workspaces.sendSession`)随最后消费者一并删除。 - 输入面对命令零知识 + 可选依赖:无命令包时纯输入可用;`@` 引用与 skill 引用免费复用同一菜单/pick 管线。代价是空格/回车裁决是逐 source 轮询协议,其应答语义(同步/异步、undefined 含义)为冻结契约。 - 提交事务化(attempt seq + 漂移守卫)使晚到结果回灌、会话切换、concurrent 重放三类缺陷结构性不可能,由矩阵测试钉住。 - 已知欠账:chip 跨刷新保真(可复用粘贴匹配)未立项;subagent 引用的模型表示待业务立项。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index 70d44e1767..9994bcb3b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.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-07-27-dispose-ladder-to-consumer.md -2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 -2026-07-27-dispose-ladder-to-consumer.zh.md: 7fff744e64109549a65d4f5bb17ff2d6ddfc6888 +2026-07-27-dispose-ladder-to-consumer.md: e9af88e8e7ef962213a74e96a249241cbe8d5994 +2026-07-27-dispose-ladder-to-consumer.zh.md: 89f8e107c56d42787c59bc6f8fa8fc7b3ef73208 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md index 97b551ff50..e9af88e8e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) ## Decision -The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. ## Alternatives considered @@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c ## Consequences -Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index 7fff744e64..89f8e107c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的完全停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 @@ -20,4 +20,4 @@ Status: implemented ## 后果 -换来的是:seam 少了一个方法和一个类型;实现只需提供四个动词,无需提供拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml new file mode 100644 index 0000000000..9300571e28 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.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-pi-ai-declared-provider-catalog.md +2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 +2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md new file mode 100644 index 0000000000..d75b6bdb91 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -0,0 +1,67 @@ +# Agent Note: pi-ai routes are declared providers, not catalog lookups + +Status: implemented + +English | [中文](2026-08-03-pi-ai-declared-provider-catalog.zh.md) + +## Problem + +`dsh-llm-pi-ai` treated the pi-ai package's generated catalog as the boundary of what could be configured. A route key had to name an installed provider (`resolveProfiles` rejected anything else), model listing returned `getBuiltinModels(provider)` verbatim, and request-time model resolution looked the id up in that same catalog and overrode only `baseURL`. Three consequences followed, and all three were dead ends rather than gaps: an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog could not be configured at all; a model the catalog had not caught up with failed with `UNKNOWN_MODEL` even against a correct endpoint; and a model's context window and output cap were whatever the pinned pi-ai release said, so a deployment could neither correct a stale value nor supply one for a model pi-ai had never described. Upgrading the package was the only way to move any of it. + +The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/compat`, an entry point whose own module documentation declares it a temporary compatibility surface — its catalog reads are `@deprecated`, and it is deleted when pi-ai finishes its `ModelManager` migration. The three configuration limits and the deprecated dependency have the same fix, because pi-ai's supported runtime (`createModels()` / `createProvider()`) is built around a provider being *declared* rather than looked up. + +## Decision + +A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: + +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. +- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. +- `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. +- A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. + +### Snapshots, not a shared collection + +`Models.streamSimple()` resolves its provider lazily, when the returned stream is first consumed — which is after the adapter has awaited the route's credential. A single collection mutated in place would therefore let a request that started under one configuration finish under another, or fail on a provider that no longer exists, even though `llm.prepareCall()` already froze that step's config and captured its adapter registration. A configuration change builds a *new* collection and leaves the one in use alone, so the seam's per-step freeze holds all the way down: switching models mid-reply takes effect on the next step, never inside the one in flight. + +### The directory replaces atomically + +The configurable-provider directory follows the profiles, so it changes whenever a declared route appears or leaves. Withdrawing the old registration and making a new one cannot express that: a candidate set the registry refuses — a profile keyed `deepseek-official`, which `llm-deepseek` already declares — would leave this plugin's whole directory withdrawn and the Models page empty, silently, because the settings-change callback contains the failure. `registerConfigurableProviders` therefore returns a handle carrying `replace(entries)` with the same validate-the-candidate-set-first atomicity `registerAdapter` has, and the plugin uses it. A refused swap costs a diagnostic; the previous entries keep serving. + +Resolution fails loud and names the route and model at fault: a model the catalog does not describe falls back to the route's own `defaultContextWindow`/`defaultMaxTokens`, so a listing that discloses nothing but ids still yields a serviceable route; a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list. Because the built `Provider` is part of the resolution result, a protocol or model error keeps the last good route set serving, exactly as a bad settings snapshot already did. + +The configurable-provider directory is now the installed catalog **joined with** every route the current profiles declare, re-registered when that set changes. Without the join a hand-declared route would have no settings address and no configuration surface could show or edit it. + +### A capability whose only level does nothing is reported unavailable + +pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected. + +`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. + +### Credentials stay outside pi-ai + +pi-ai's `Models` carries its own credential concept — a `CredentialStore` keyed by provider id, with `envApiKeyAuth` resolving `credential.key ?? env(VAR)`. Adopting it would have created a second credential source of truth beside `ctx.credentials` and, worse, reintroduced the ambient fallback the harness deliberately forbids: a named-but-missing `apiKeyEnv` must fail with `MISSING_CREDENTIAL` rather than authenticate with whatever unrelated key the environment holds. + +`ModelsImpl.applyAuth` honours `options.apiKey` as the request's key, but only through a provider that declares an api-key method: `resolveProviderAuth` short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery, returning nothing — and so failing the request with `Provider is not configured` — when the provider has no api-key method at all. The harness therefore resolves the route's key through its own seam, as before, and passes the result as the request's `apiKey`; the collection is constructed with no credential store. + +A route's auth follows from that. A catalog route keeps the installed provider's own `auth`, which preserves provider-native ambient discovery for a profile naming no credential, and keeps it through an `api` override too: which environment a provider reads is a property of the provider, not of the wire format its models speak. The exception is a catalog provider with no api-key method — `openai-codex` authenticates through OAuth alone — where a profile that names a credential also gets the harness method beside the provider's own, because otherwise its configured key would be refused before any request went out. A keyless profile on such a route adds nothing and keeps the honest refusal: this adapter holds no OAuth store to resolve through. A hand-declared route gets a harness-owned `ApiKeyAuth` that reports configured-but-keyless rather than unconfigured, leaving the requirement to the protocol — which is where it lives: pi-ai's OpenAI-compatible implementation still demands a key or an `Authorization` header, and says so itself. + +## Alternatives considered + +- **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. +- **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. +- **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. + +- **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. +- **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. +- **A runtime dynamic catalog** — `fetchModels` plus `ModelsStore`, refreshed in the background. Rejected for this change: it makes the model list external mutable state needing cache, invalidation, and an offline path, and the product need is a one-shot discovery action whose result the user adopts into `settings.yaml`. That action belongs to the configuration surface and is deferred with it; `settings.yaml` stays the single source of truth for what a route serves. + +## Consequences + +Configuring a provider no longer depends on a pi-ai release. A gateway, a self-hosted server, or a model newer than the pinned catalog is a `settings.yaml` edit, and a stale context window can be corrected in place. The deprecated `/compat` import is gone, so pi-ai deleting it is no longer a breaking event. `defaultMaxTokens` now flows from configuration when a deployment states one, without inventing a cap from catalog metadata. + +What it costs: `settings.yaml` grows for a declared route, because it must state its endpoint, protocol, and model ids. `api` applies to a whole route, so a mixed-protocol catalog route cannot host a model of the other protocol — splitting it across two route keys is the workaround. Nothing queries a provider's `/models`, so a model list is only as current as its last edit. Reported error shape shifts in one case: a route whose auth resolves to nothing now surfaces pi-ai's own diagnostic as an error `finish` chunk before any network call, where the previous adapter sent a keyless request and surfaced the provider's 401. + +## Testing + +`tests/catalog.spec.ts` covers the contract end to end against local mock servers: a hand-declared route streaming to its own endpoint with its own credential, its appearance in the configurable-provider directory, per-model overrides defaulting from the installed catalog, a model added to a catalog route, protocol repointing with and without an endpoint override, catalog-only metadata surviving an override, the keyless posture and its `Authorization`-header workaround, an OAuth-only catalog route authenticating with the key its profile names while a keyless one stays unconfigured, a repointed route keeping its catalog auth, and every resolution failure that names a route or model. `tests/catalog.spec.ts` also pins the snapshot and directory contracts: an in-flight request whose route set changes during its credential await still reaches the endpoint it resolved against, the next request picks up the new one, a colliding declared route leaves the directory whole, and a declared route's entry appears and leaves with its profile. `packages/llm/llm/tests/topology.spec.ts` covers `replace` — refusing a candidate another registration owns while keeping the current set, accepting a swap over its own entries, allowing an empty set, and failing after disposal. `tests/sdk-options.spec.ts` re-targets the SDK boundary from the removed `/compat` import to the protocol table's lazy api module, which also pins that a setup failure arrives as a terminal error chunk rather than a throw. The twin's [design-verification role](2026-06-13-twin-llm-adapters.md) is unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md new file mode 100644 index 0000000000..f8dba9900b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -0,0 +1,67 @@ +# Agent Note: pi-ai 路由是被声明的提供方,而不是 catalog 查表 + +Status: implemented + +[English](2026-08-03-pi-ai-declared-provider-catalog.md) | 中文 + +## Problem + +`dsh-llm-pi-ai` 把 pi-ai 包生成的 catalog 当成了可配置范围的边界。路由键必须点名一个已安装提供方(`resolveProfiles` 拒绝其余一切),模型列举原样返回 `getBuiltinModels(provider)`,请求期的模型解析又在同一份 catalog 里查这个 id、且只覆盖 `baseURL`。由此产生三个后果,而且三个都是死路而非缺口:OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,根本无法配置;catalog 尚未跟上的模型即便端点正确也会以 `UNKNOWN_MODEL` 失败;模型的上下文窗口与输出上限完全由锁定的 pi-ai 版本决定,部署既无法更正过期值,也无法为 pi-ai 从未描述过的模型补上。要动其中任何一条,只能升级依赖。 + +适配器还经 `@earendil-works/pi-ai/compat` 的 `streamSimple` 发起流式请求,而该入口自己的模块文档声明它是临时兼容面——其 catalog 读取标了 `@deprecated`,并会在 pi-ai 完成 `ModelManager` 迁移时被删除。这三条配置限制与这个废弃依赖的解法是同一个,因为 pi-ai 受支持的运行时(`createModels()` / `createProvider()`)正是围绕「提供方是被*声明*出来的,而非查出来的」建立的。 + +## Decision + +提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: + +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 +- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 +- `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 +- 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 + +### 快照,而不是共享集合 + +`Models.streamSimple()` 惰性解析 provider——在返回的流首次被消费时,而那已在适配器 await 路由凭据之后。因此就地改动的单一集合,会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider,尽管 `llm.prepareCall()` 早已冻结了该步的 config 并捕获了其适配器注册。配置变化改为构造**新**集合,正在被使用的那个原封不动,于是 seam 的每步冻结得以贯通到底:回复途中切换模型在下一步生效,绝不影响在途的那一步。 + +### 目录原子替换 + +可配置提供方目录跟随 profiles,因此每当一条声明路由出现或离开它都会变化。「撤销旧注册再新建一个」表达不了这件事:注册表拒绝的候选集合——比如一份键为 `deepseek-official` 的 profile,而 `llm-deepseek` 已声明了它——会让本插件的整个目录被撤走、Models 页变空,而且是静默的,因为 settings 变更回调把失败容住了。因此 `registerConfigurableProviders` 改为返回带 `replace(entries)` 的句柄,其「候选集先整体校验」的原子性与 `registerAdapter` 相同,插件改用它。被拒的替换只付出一条诊断;先前的条目继续服务。 + +解析失败得响亮,并点名出问题的路由与模型:catalog 未描述的模型会回落到该路由自己的 `defaultContextWindow`/`defaultMaxTokens`,因此只公布 id 的列表也能得到可服务的路由;catalog 未提供的路由需要 `api`、`baseURL` 和非空的 `models` 列表。由于构造出的 `Provider` 是解析结果的一部分,协议或模型出错时最后可用的路由集合会继续服务——与此前坏的 settings 快照的行为完全一致。 + +可配置提供方目录现在是已安装 catalog **与**当前 profile 声明的每条路由的并集,并在该集合变化时重新登记。没有这个并集,手工声明的路由就没有 settings 地址,任何配置界面都无法展示或编辑它。 + +### 唯一档位什么也做不到的能力,报告为不可用 + +pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。 + +因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 + +### 凭据留在 pi-ai 之外 + +pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `CredentialStore`,配合 `envApiKeyAuth` 解析 `credential.key ?? env(VAR)`。采用它会在 `ctx.credentials` 之外制造第二个凭据事实源,更糟的是会把 harness 明确禁止的环境回落重新引进来:点名了却取不到的 `apiKeyEnv` 必须以 `MISSING_CREDENTIAL` 失败,而不是用环境里恰好持有的某个无关密钥完成认证。 + +`ModelsImpl.applyAuth` 会把 `options.apiKey` 当作该请求的密钥,但这条路必须经由一个声明了 api-key 方法的提供方:`resolveProviderAuth` 在覆盖存在时短路到该方法,否则依次落到凭据存储与环境发现;若提供方压根没有 api-key 方法,它返回空,请求随即以 `Provider is not configured` 失败。因此 harness 一如既往经自身 seam 解析路由密钥,并把结果作为请求的 `apiKey` 传入;该集合构造时不带任何凭据存储。 + +路由的 auth 由此推出。catalog 路由保留已安装提供方自己的 `auth`,从而为不点名凭据的 profile 保住其提供方原生环境发现,且在 `api` 覆盖之下同样保留:提供方读哪个环境是提供方自身的属性,而非其模型所讲协议格式的属性。例外是没有 api-key 方法的 catalog 提供方——`openai-codex` 只走 OAuth——此时点名了凭据的 profile 会在提供方原有 auth 之外再获得 harness 的方法,否则它配置的密钥会在任何请求发出之前被拒。这类路由上不点名凭据的 profile 什么也不加、并保留那句诚实的拒绝:本适配器没有可供解析的 OAuth 存储。手工声明的路由则获得一个 harness 自有的 `ApiKeyAuth`,它报告「已配置但无密钥」而非「未配置」,把该要求留给协议——那才是它真正所在的位置:pi-ai 的 OpenAI 兼容实现仍要求密钥或 `Authorization` 标头,并且会自己说出来。 + +## Alternatives considered + +- **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 +- **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 +- **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 + +- **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 +- **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 +- **运行时动态 catalog**——`fetchModels` 加 `ModelsStore`,后台刷新。本次变更拒绝:它把模型列表变成需要缓存、失效与离线路径的外部可变状态,而产品需求是一次性的发现动作、其结果由用户采纳进 `settings.yaml`。该动作属于配置界面,与之一并暂缓;`settings.yaml` 始终是「路由服务什么」的唯一事实源。 + +## Consequences + +配置一个提供方不再取决于 pi-ai 的发布节奏。网关、自建服务,或比锁定 catalog 更新的模型,都是一次 `settings.yaml` 编辑,过期的上下文窗口也能就地更正。废弃的 `/compat` 导入已经消失,因此 pi-ai 删除它不再是破坏性事件。`defaultMaxTokens` 现在只在部署明确给出时才自配置流出,不会从 catalog 元数据里发明一个上限。 + +代价是:声明式路由会让 `settings.yaml` 变长,因为它必须自报端点、协议与模型 id。`api` 作用于整条路由,因此混合协议的 catalog 路由无法承载另一种协议的模型——把它拆成两个路由键是变通办法。没有任何环节查询提供方的 `/models`,因此模型列表的新鲜度只到最近一次编辑为止。有一种情形下报错形状发生变化:auth 解析不出任何值的路由,现在会在任何网络调用之前把 pi-ai 自己的诊断作为错误 `finish` 分片呈现,而此前的适配器会发出无密钥请求并呈现提供方的 401。 + +## Testing + +`tests/catalog.spec.ts` 针对本地 mock 服务器端到端覆盖该契约:手工声明的路由带着自己的凭据流向自己的端点、它在可配置提供方目录中的出现、每模型覆盖从已安装 catalog 继承默认值、向 catalog 路由添加模型、带与不带端点覆盖的协议改指、catalog 独有元数据在覆盖后存活、无密钥姿态及其 `Authorization` 标头变通、只走 OAuth 的 catalog 路由用 profile 点名的密钥完成认证而无密钥者保持未配置、改指协议的路由保留其 catalog auth,以及每一种点名路由或模型的解析失败。`tests/catalog.spec.ts` 还钉住了快照与目录两项契约:在途请求即便其路由集在 credential await 期间改变,仍抵达它解析时对应的端点;下一个请求取用新配置;冲突的声明路由让目录保持完好;声明路由的条目随其 profile 出现与离开。`packages/llm/llm/tests/topology.spec.ts` 覆盖 `replace`——拒绝他人已拥有的候选同时保住当前集合、接受对自身条目的替换、允许空集合,以及 dispose 之后失败。`tests/sdk-options.spec.ts` 把 SDK 边界从已移除的 `/compat` 导入改指到协议表的 lazy api 模块,同时钉住「setup 失败以终止性错误分片而非抛出的形式抵达」。twin 的[设计验证角色](2026-06-13-twin-llm-adapters.md)不变。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.i18n.yaml new file mode 100644 index 0000000000..4c5c87821f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.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-04-declaring-a-provider-from-the-models-page.md +2026-08-04-declaring-a-provider-from-the-models-page.md: 53996e488c467e00754837b83b7a33994d4813ee +2026-08-04-declaring-a-provider-from-the-models-page.zh.md: fa61c48492eabf51f3d325078ceffa84ac52d12c diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md new file mode 100644 index 0000000000..53996e488c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md @@ -0,0 +1,43 @@ +# Agent Note: Declaring a provider from the Models page + +Status: implemented + +English | [中文](2026-08-04-declaring-a-provider-from-the-models-page.zh.md) + +## Problem + +The two layers below made a pi-ai route [a declaration](2026-08-03-pi-ai-declared-provider-catalog.md) and gave the host a way to [interrogate a draft endpoint](2026-08-04-draft-provider-endpoint-interrogation.md). Neither reached a person who does not edit YAML: the Models page still offered one API-key field per provider and a fold with a base URL, so adding a gateway meant opening `$DSH_HOME/settings.yaml` and knowing the profile shape, and correcting a stale context window meant the same. The capability existed and the surface did not expose it. + +Two things were missing, and they are not the same shape. Editing an existing route's models is a *field* on a card that already exists. Declaring a route is a *create*: the route id is being chosen, so until it is chosen there is no settings address to edit. + +## Decision + +The model list is a component shared by both flows; the create is its own card. + +`ModelListEditor` edits a profile's `models` array — one row per model with id, display name, context window, and output cap — and owns the fetch action. An empty list means "serve this route's built-in catalog", so a row is only ever added deliberately; clearing an optional field drops it rather than storing a value the schema would reject, and a capacity that is not a positive integer is not stored at all. + +Fetching asks about the endpoint **the form currently shows** — a base URL edited but unsaved, a key typed but unstored — so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end; the adapter's own message appears beside rows that stay editable by hand. + +`CustomProviderCard` declares a route pi-ai does not ship. It is a separate card because the route id is chosen here: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. The three facts a hand-declared route cannot default — endpoint, protocol, and at least one model — gate the create button, so a failure names the field while the user is still looking at it. + +The protocol choices come from the namespace's **own schema**, read through the settings descriptor the page already fetches (`providers.*.api` is a union of the adapter's `supportedProtocols()`). No new wire field, no constant in the client, and no way for the offered choices to drift from the accepted ones. + +## Alternatives considered + +**Declare a provider through `ProviderEditor` with extra fields.** One card instead of two, but the editor is addressed by `settingsPath`, and a route being named has no path yet. Recomputing the path per keystroke would remount the card and discard the draft; deferring it would mean the editor's whole write path no longer described what it was editing. + +**Add a wire field for the protocol list.** Explicit, and the obvious first instinct. But the settings schema already crosses the wire and already contains the union, so a second copy could disagree with the first — and the one the adapter enforces is the schema. + +**Fetch against the stored profile instead of the live form.** No key would leave the form for an unsaved provider. But the flow that needs fetching most is the one where nothing is stored yet, and a form whose endpoint was edited would quietly interrogate the old one. + +**Write adopted candidates straight into the list.** Fewer clicks, but a fetch would then overwrite capacities the user had corrected, and a listing that discloses only ids would replace real numbers with nothing. + +## Consequences + +A gateway, a self-hosted server, or a model newer than the installed catalog is now configurable without leaving the browser, and the endpoint itself supplies the model ids where it can. The page grew two components and one shared list editor; the editor card's pi-ai fold grew from two fields to a list. + +What it costs: only pi-ai routes can be hand-declared, because `llm-pi-ai` is the one namespace whose profiles describe a whole provider — a `llm-deepseek` route stays a composition fact. Interrogation reaches only OpenAI-compatible endpoints, so a gateway speaking another protocol reports that it cannot be asked and its models are typed in. And the page now holds a key in component state for the duration of a fetch, which is the same exposure `credentials.set` already has and no longer than the card lives. + +## Testing + +`packages/client/ui-models/tests/provider-form.spec.tsx` drives the rendered page over a scripted wire face: adding, editing, and removing rows; a cleared optional field leaving the profile and a non-integer capacity never entering it; the interrogation carrying the edited endpoint, the unsaved key, and the profile's protocol; the picker's default selection, toggling, cancel, and adopt-keeps-tuned-rows; the empty, refused, and rejected-transport paths; the create writing one profile plus its credential; every gate on the create button; and the read-only posture. `protocolChoices` is covered against a schema that declares the union and one that does not. diff --git a/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md new file mode 100644 index 0000000000..fa61c48492 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 在 Models 页上声明一个提供方 + +Status: implemented + +[English](2026-08-04-declaring-a-provider-from-the-models-page.md) | 中文 + +## Problem + +下面两层已经让 pi-ai 路由变成[一份声明](2026-08-03-pi-ai-declared-provider-catalog.md),并给了 host [询问草稿端点](2026-08-04-draft-provider-endpoint-interrogation.md)的能力。但两者都没有抵达不编辑 YAML 的人:Models 页仍然只为每个提供方提供一个 API 密钥输入框和一个装着 API 地址的折叠区,因此接入一个网关意味着打开 `$DSH_HOME/settings.yaml` 并知道 profile 的形状,更正一个过期的上下文窗口也是如此。能力已经存在,界面却没有暴露它。 + +缺的是两件事,而它们的形状并不相同。编辑既有路由的模型,是一张已经存在的卡片上的一个*字段*;声明一条路由则是一次*创建*:路由 id 正在此处被选定,而在选定之前根本没有可编辑的 settings 地址。 + +## Decision + +模型列表是两条流程共用的组件;创建则是它自己的卡片。 + +`ModelListEditor` 编辑 profile 的 `models` 数组——一行一个模型,含 id、显示名称、上下文窗口与输出上限——并持有获取动作。空列表意味着「使用该路由的内置 catalog」,因此每一行都只会被刻意添加;清空某个可选字段会丢弃它,而不是存入一个 schema 会拒绝的值,不是正整数的容量则根本不会被存下。 + +获取会询问表单**当前显示**的端点——已修改但未保存的 API 地址、已键入但未存储的密钥——因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路;适配器自己的消息会出现在各行旁边,而这些行仍可手工编辑。 + +`CustomProviderCard` 声明 pi-ai 未提供的路由。它之所以是独立卡片,正因为路由 id 是在这里选定的:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的三件事——端点、协议、至少一个模型——会门控创建按钮,因此失败会在用户仍看着该字段时点名它。 + +协议选项来自该 namespace **自己的 schema**,经页面本就会获取的 settings 描述符读出(`providers.*.api` 是适配器 `supportedProtocols()` 的一个 union)。没有新增协议字段,客户端里没有常量,提供的选项也无从与被接受的集合发生漂移。 + +## Alternatives considered + +**在 `ProviderEditor` 上加字段来声明提供方。** 两张卡片变一张,但编辑器由 `settingsPath` 寻址,而正在被命名的路由还没有路径。逐次按键重算路径会让卡片重新挂载并丢掉草稿;推迟计算则意味着编辑器的整条写入路径不再描述它正在编辑的东西。 + +**为协议列表新增一个协议字段。** 显式,也是最直觉的第一反应。但 settings schema 本来就会跨越协议层、本来就含有那个 union,因此第二份副本可能与第一份不一致——而适配器强制执行的是 schema 那一份。 + +**针对已存 profile 而非实时表单发起获取。** 对尚未保存的提供方来说,密钥就不会离开表单。但最需要获取的恰恰是「什么都还没存」的那条流程,而端点已修改的表单会悄悄去询问旧地址。 + +**把采纳的候选直接写进列表。** 点击更少,但一次获取就会覆盖用户已更正的容量,而只公布 id 的列表会把真实数字替换成空。 + +## Consequences + +网关、自建服务,或比已安装 catalog 更新的模型,如今无需离开浏览器就能配置,而模型 id 在端点能提供时由端点自己给出。页面多了两个组件和一个共用的列表编辑器;编辑卡片的 pi-ai 折叠区从两个字段长成了一个列表。 + +代价是:只有 pi-ai 路由可以手工声明,因为 `llm-pi-ai` 是唯一一个其 profile 描述整个提供方的 namespace——`llm-deepseek` 路由仍是组合面的事实。询问只覆盖 OpenAI 兼容端点,因此讲其他协议的网关会报告自己无法被询问,其模型需手工键入。另外,页面在一次获取期间会把密钥保存在组件状态里,这与 `credentials.set` 已有的暴露面相同,且不长于卡片的存活时间。 + +## Testing + +`packages/client/ui-models/tests/provider-form.spec.tsx` 在脚本化的协议面之上驱动渲染后的页面:添加、编辑与移除行;被清空的可选字段离开 profile、非整数容量从不进入;询问携带已修改的端点、未保存的密钥,以及 profile 自身的协议;选择框的默认选中、勾选切换、取消,以及「采纳保留已调优的行」;空列表、被拒、传输被拒三条路径;创建写入一份 profile 加其凭据;创建按钮上的每一道门控;以及只读姿态。`protocolChoices` 针对「声明了该 union」与「没有声明」两种 schema 都有覆盖。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml new file mode 100644 index 0000000000..ae96598b48 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.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-04-draft-provider-endpoint-interrogation.md +2026-08-04-draft-provider-endpoint-interrogation.md: 65545098cd1063c40081481c1ac8f0afdb4fb390 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: cb09042904f4ab1558c0c214d275a934234955ac diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md new file mode 100644 index 0000000000..65545098cd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -0,0 +1,50 @@ +# Agent Note: Interrogating a draft provider endpoint + +Status: implemented + +English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md) + +## Problem + +Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`. + +The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves. + +The awkward part is that the question is about something that does not exist yet. The provider being added has no route, no stored profile, and no stored credential; the endpoint and key are values in a form the user is still typing. Every existing seam operation is keyed by a registered provider route, so none of them can carry this. + +## Decision + +Interrogation is keyed by **settings namespace**, not by provider route: + +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` lets an adapter plugin offer to interrogate endpoints for the namespace it owns, and `ctx.llm.discoverModels(settingsNs, request)` asks. There is no way to enumerate which namespaces registered: a surface that cannot interrogate learns it from the refusal, and a list nothing consumed would be a required wire field doing nothing. The namespace is the right key because a configuration surface already holds it from the configurable-provider directory, and because a provider being added has no route to name. +- `LlmModelDiscoveryRequest` carries the draft — an optional `provider`, an optional `baseURL`, an optional `api`, an optional `apiKey`, and a signal — and needs at least one of `provider` or `baseURL` to have anything to answer about. `provider` exists because a route the adapter already describes is answered from its own registry with no network call at all; only a route it does not describe reaches an endpoint. Nothing in this path writes settings or credentials. The one read is the credential of a route the request names: a configuration surface holds a redacted descriptor rather than the stored secret, so the draft's `apiKey` is present only while the user is typing one, and without that read an already-configured route would be interrogated unauthenticated and answer 401. The typed key wins, being the one under test. +- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. +- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. The method is loopback-only for a second reason besides the key: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which is a probe an anonymous LAN caller must not have. Every refusal 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. + +`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. + +### Why not pi-ai's own refresh machinery + +pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `ModelsStore`, and the layer below already builds pi-ai `Provider` objects. Routing interrogation through them would have meant constructing a throwaway provider and collection per question, with a store whose entire purpose — persisting a catalog across runs — contradicts the decision that `settings.yaml` owns the catalog. It would also have bought nothing: **no built-in pi-ai provider implements `fetchModels`**, so the HTTP call and its response parsing are this package's code either way. A direct fetch says what is actually happening. The route's stored credential is resolved by the plugin's own per-request resolver, and only on the branch that reaches the network, so a catalog route answers without touching credentials and never fails over one the question did not need. + +## Alternatives considered + +**Key interrogation by provider route.** Symmetric with every other seam operation, and it would let the request omit the endpoint. But the case that motivates the feature — adding a provider — has no route, so the operation would only work for providers already configured, which are the ones that need it least. + +**Put the capability on `LlmAdapter`.** Adapters are reached through a route registration, so this has the same problem, plus it would make an adapter instance answer questions about endpoints it does not serve. + +**Have the host read the stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. Accepting the draft keeps what the user sees and what is asked identical — with the credential as the one exception, because it is the one field a surface is never shown and so can never put in the draft. + +**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback. + +**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed. + +## Consequences + +A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber. + +What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately. + +## Testing + +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md new file mode 100644 index 0000000000..cb09042904 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 询问草稿中的提供方端点 + +Status: implemented + +[English](2026-08-04-draft-provider-endpoint-interrogation.md) | 中文 + +## Problem + +当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models` 上公布了这份列表。 + +显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是**只问一次**,其答案由用户采纳进 `settings.yaml`——从而让 `settings.yaml` 始终是唯一决定路由服务什么的东西。 + +麻烦之处在于,被问的对象还不存在。正在新增的提供方没有路由、没有已存 profile、也没有已存凭据;端点与密钥都是用户尚在输入的表单值。而现有的每个 seam 操作都以已注册的提供方路由为键,因此没有一个能承载它。 + +## Decision + +询问以 **settings namespace** 为键,而不是提供方路由: + +- `ctx.llm.registerModelDiscovery(settingsNs, discover)` 让适配器插件为自己拥有的 namespace 提供「询问端点」的能力,`ctx.llm.discoverModels(settingsNs, request)` 发起询问。没有任何办法枚举哪些 namespace 注册过:询问不了的界面会从那句拒绝里知道,而一份无人消费的列表只会变成一个什么都不做的必填协议字段。以 namespace 为键是对的,因为配置界面已经从可配置提供方目录里拿到了它,也因为正在新增的提供方没有路由可点名。 +- `LlmModelDiscoveryRequest` 携带草稿——可选的 `provider`、可选的 `baseURL`、可选的 `api`、可选的 `apiKey`,以及一个 signal——且 `provider` 与 `baseURL` 至少要有一个,才有东西可答。`provider` 之所以存在,是因为适配器已经描述过的路由直接由它自己的注册表作答、完全不联网;只有它未描述的路由才会抵达某个端点。这条路径不写 settings 与 credentials。唯一的读取是请求所点名路由的凭据:配置界面拿到的是脱敏描述符而非已存的机密,因此草稿里的 `apiKey` 只在用户正键入时才存在;没有这次读取,已配置好的路由就会被不带认证地询问,只换回一个 401。键入的密钥优先,因为那正是被测试的那一把。 +- `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 +- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。除密钥之外它被钉在回环还有第二个理由:它让宿主向调用方选定的 URL 发起 GET 并回报结果,这是匿名 LAN 调用者不该拥有的探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 + +`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。 + +### 为什么不用 pi-ai 自己的 refresh 机制 + +pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 `ModelsStore`,而下层本来就在构造 pi-ai `Provider` 对象。把询问接到它们上面,意味着每问一次就要构造一个用完即弃的 provider 与集合,而那个 store 的全部目的——跨运行持久化 catalog——恰恰与「`settings.yaml` 拥有 catalog」的决定相抵触。而且它什么也换不来:**没有任何一个 pi-ai 内置 provider 实现了 `fetchModels`**,因此 HTTP 调用及其响应解析无论如何都是本包的代码。直接 fetch 才如实说出正在发生的事。路由已存的凭据由本插件自己那套逐请求解析器取出,且只在真正要联网的那条分支上进行,因此 catalog 路由作答时既不触碰凭据,也不会因为一把这次询问根本用不上的密钥而失败。 + +## Alternatives considered + +**以提供方路由为键。** 与其他每个 seam 操作对称,也能让请求省去端点。但催生该功能的场景——新增提供方——没有路由,于是这个操作只对已配置好的提供方可用,而它们恰恰最不需要它。 + +**把能力挂在 `LlmAdapter` 上。** 适配器要经由路由注册才能抵达,因此问题相同;而且这会让一个适配器实例去回答它并不服务的端点的问题。 + +**让 host 读已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有 secret 跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。接受草稿让用户看见的与被询问的保持一致——凭据是唯一的例外,因为它是界面从不被展示、因而永远无法放进草稿的那个字段。 + +**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。 + +**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。 + +## Consequences + +接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、除 fiber 外没有生命周期。 + +代价是:协议层多了第三个承载 secret 的载荷,配置面的只写接口从两个方法变成三个。发现能力按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。 + +## Testing + +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml new file mode 100644 index 0000000000..eed6bee5f0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-05-profile-plugin-bundles.md +2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee +2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md new file mode 100644 index 0000000000..11a8ac3d40 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -0,0 +1,33 @@ +# Agent Note: Profile plugin bundles replace the fixed surface overlays + +Status: implemented + +English | [中文](2026-08-05-profile-plugin-bundles.zh.md) + +## Problem + +The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.yml` shipped inside `apps/cli`, three bespoke entry modes (`--config`, `web`, `-p`) each with its own layer stack, and a single global personal overlay (`$DSH_HOME/config.yaml`). There was no way to install an out-of-tree plugin (a TUI, a provider pack) into a shipped surface without editing the repository, and no place where a third-party package could contribute a default composition. + +## Decision + +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. + +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). + +Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). + +Two supporting refactors: the webserver's built-in static dist serving became the single-owner **fallback seat** (`registerFallback`/`applyIndexTaps`), with the SPA server extracted to `@deepseek-ai/dsh-frontend-static` so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the [dsh CLI personal-config decision](../feature/2026-07-20-dsh-cli-personal-config.md) (`loadPersonalPatches`, `$DSH_HOME/config.yaml`) was retargeted to the per-profile and home-level `cordis.patch.yml` layers (`loadOptionalPatches`, `watchUserPatches` taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing. + +## Alternatives considered + +- **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. +- **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` seam is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. + +## Consequences + +- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape. +- `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone. +- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly. +- Backends reject nothing old on disk (pre-release stance): `$DSH_HOME/config.yaml` is simply no longer read. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md new file mode 100644 index 0000000000..0e9ebf657c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -0,0 +1,33 @@ +# Agent Note: profile 插件组合包取代固定的表层 overlay + +Status: implemented + +[English](2026-08-05-profile-plugin-bundles.md) | 中文 + +## Problem + +`dsh` 启动器硬编码了自己的组合:`base.cordis.yml` + `web.cordis.yml` 随 `apps/cli` 一起交付,三种各自定制的入口模式(`--config`、`web`、`-p`)各带一套层栈,外加一个全局的个人 overlay(`$DSH_HOME/config.yaml`)。想把树外插件(一个 TUI、一个提供方扩展包)装进已交付的表层,只能修改仓库;第三方包也没有任何位置可以贡献默认组合。 + +## Decision + +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 + +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 + +解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 + +两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的**回退席位**(`registerFallback`/`applyIndexTaps`),SPA 服务器提取到 `@deepseek-ai/dsh-frontend-static`,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;[dsh CLI 个人配置决策](../feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制(`loadPersonalPatches`、`$DSH_HOME/config.yaml`)改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层(`loadOptionalPatches`、接受文件名的 `watchUserPatches`),取代该笔记的各入口模式与文件位置,同时保留其 Harness home 根目录、patch 语义与大声失败的解析。 + +## Alternatives considered + +- **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 +- **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 +- **在组合包 manifest(元数据清单)中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` seam 是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 + +## Consequences + +- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装;仓库不再需要为每种部署形态各留一行。 +- `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。 +- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会大声失败。 +- 后端不拒绝磁盘上的任何旧格式(发布前姿态):`$DSH_HOME/config.yaml` 只是不再被读取。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.i18n.yaml new file mode 100644 index 0000000000..b6e58aabc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.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-06-agent-event-payload-objects.md +2026-08-06-agent-event-payload-objects.md: 470c8fb3f9282005829846307778d3d1088c3888 +2026-08-06-agent-event-payload-objects.zh.md: ff201a7c3134c0ef809c9a798d65412541f9f1e7 diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md new file mode 100644 index 0000000000..470c8fb3f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.md @@ -0,0 +1,27 @@ +# Agent Note: Agent-scoped events dispatch a single payload object + +Status: implemented + +English | [中文](2026-08-06-agent-event-payload-objects.zh.md) + +## Problem + +Agent-scoped events historically took positional arguments: a leading `agent` subject, event-specific fields, and a trailing `next` for waterfall/serial events. Adding a field or retiring a context type (as with `PreStepContext` and `RequestFailureContext`) rewrote every listener and emitter across packages, and the contract stayed spread across the parameter list instead of one named payload. + +## Decision + +Every agent-scoped event takes exactly one payload object as its first argument. The payload always carries the subject (`agent`), the event's fields, and the cancellation `signal` when the event has one; `next` remains the last argument of waterfall/serial events. The affected events are the twelve `agent/*` events, `agent-loop/config-start-failed` (the only one without a subject), and `goal/changed`. + +`PreStepContext` and `RequestFailureContext` are retired; their fields live directly in the `agent/pre-step` and `agent/request-error` payloads. + +Dispatch is fused: `agentEvents(ctx, agent)` (and the one-shot `emitAgentEvent`) injects the subject so the scope carrier key and the payload's `agent` cannot diverge, and the injected subject wins even over a structurally acceptable payload that happens to carry an `agent` field. `ReactLoopAgent` builds its dispatcher once in the constructor and routes every emit, serial, and waterfall through it, so hot-path dispatches allocate nothing. + +## Alternatives considered + +**Keep positional signatures.** Adding a field or retiring a context type would keep rewriting every listener and emitter, and the contract would stay spread across the parameter list instead of one named payload. + +**Hand-build the subject at each dispatch site.** The loop's intermediate design called `ctx.waterfall(this.carrier, …)` with a manually constructed `{ agent: this, … }` payload; it avoided per-dispatch allocation but duplicated the subject injection and let the scope key and the payload subject diverge. The fused dispatcher is the single injection point for every dispatch mode. + +## Consequences + +Listener signatures name the full payload once, so extending a payload or retiring a context type is a one-shape change across all listeners and emitters. The subject/scope coupling is enforced by the dispatcher for every dispatch mode, and the loop's hot paths stay allocation-free. diff --git a/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md new file mode 100644 index 0000000000..ff201a7c31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-agent-event-payload-objects.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Agent 作用域事件 dispatch 单个 payload 对象 + +Status: implemented + +[English](2026-08-06-agent-event-payload-objects.md) | 中文 + +## 问题 + +Agent 作用域事件历来采用位置参数:开头的 `agent` 主体、事件专属字段,以及末尾用于 waterfall(瀑布式事件)/serial 事件的 `next`。新增字段或退役上下文类型(如 `PreStepContext` 与 `RequestFailureContext`)都会迫使跨包重写每个监听器和 emitter,契约也一直分散在参数列表中,而不是集中在一个具名 payload 中。 + +## 决策 + +每个 agent 作用域事件都将恰好一个 payload 对象作为其第一个参数。payload 始终携带主体(`agent`)、事件的字段,以及事件有取消信号时的取消 `signal`;`next` 仍然是 waterfall/serial 事件的最后一个参数。受影响的事件是十二个 `agent/*` 事件、`agent-loop/config-start-failed`(唯一没有主体的事件)以及 `goal/changed`。 + +`PreStepContext` 与 `RequestFailureContext` 已退役;它们的字段直接存在于 `agent/pre-step` 与 `agent/request-error` 的 payload 中。 + +dispatch 是融合的:`agentEvents(ctx, agent)`(以及一次性 `emitAgentEvent`)注入主体,使作用域载体键与 payload 的 `agent` 不可能分叉;即使某个结构上可接受的 payload 恰好携带 `agent` 字段,注入的主体仍然优先。`ReactLoopAgent` 在构造函数中构建一次 dispatcher,并将每个 emit、serial 和 waterfall 都经由它路由,因此热路径上的 dispatch 不产生任何分配。 + +## 考虑过的替代方案 + +**保留位置签名。** 新增字段或退役上下文类型依旧会重写每个监听器和 emitter,契约也会继续分散在参数列表中,而不是集中在一个具名 payload 中。 + +**在每个 dispatch 位置手工构造主体。** loop 的中间设计调用 `ctx.waterfall(this.carrier, …)`,传入手工构造的 `{ agent: this, … }` payload;它避免了每次 dispatch 的分配,却重复了主体注入,并让作用域键与 payload 主体分叉。融合的 dispatcher 是每种 dispatch 模式的唯一注入点。 + +## 后果 + +监听器签名一次性命名完整 payload,因此扩展 payload 或退役上下文类型,对所有监听器和 emitter 都是一次形状变更。主体/作用域耦合由 dispatcher 在每种 dispatch 模式下强制执行,且 loop 的热路径保持零分配。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml new file mode 100644 index 0000000000..815f5eee75 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.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-06-web-shell-dist-chunk-layout.md +2026-08-06-web-shell-dist-chunk-layout.md: 1c7b4273dc243685317b149e2fd7fddf2a6c18d1 +2026-08-06-web-shell-dist-chunk-layout.zh.md: 6f4b94e0bd7412e480458e34922273b389aa8892 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md new file mode 100644 index 0000000000..1c7b4273dc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md @@ -0,0 +1,50 @@ +# Agent Note: Web shell dist chunk split and directory layout + +Status: implemented + +English | [中文](2026-08-06-web-shell-dist-chunk-layout.zh.md) + +## Problem + +The apps/web shell previously built into a single ~1.2 MB (minified) index chunk, roughly 80% of it vendor bytes — KaTeX, the boot grammars and the shiki engine, react-dom, the markdown pipeline — fused with all the workspace shell code (about one fifth). Any one-line shell change rehashed the whole chunk, forcing returning clients to redownload everything; `dist/assets/` was a flat single-level spread of 100-plus files (the main chunk, 23 lazy-loaded grammar chunks, 59 KaTeX font faces, and sourcemaps intermixed), impossible to navigate. + +## Decision + +`apps/web/vite.config.ts` splits the shell into two initial chunks via `manualChunks` and sorts the output into directories via naming functions; the entire configuration contains zero regexes — an exact-package-name Set, a filename list, an extension list. + +**Membership** (`VENDOR_PACKAGES`, by exact npm package name): + +- `vendor` = the three heavy rendering families: math (katex), highlight (shiki), markdown (the micromark/mdast parse pipeline — the incremental React renderer above it is workspace code and not part of this). The live membership is `VENDOR_PACKAGES`; the list is the packages workspace code **imports directly**: the remaining private transitive dependencies (the oniguruma family, @shikijs/core, character tables, dozens more) are referenced only by listed members, so rollup's chunk coloring pulls them into vendor automatically; dependencies shared with the index side fall back to index, diluting it by a few KB — not a correctness issue. +- **Every vendor member must be react-free (the boundary invariant)**: rollup folds a module shared between the entry and a manual chunk into the manual chunk — one listed package importing react/jsx-runtime would drag the single shared react copy into vendor, away from index. The React side of markdown/math rendering is workspace code and naturally lives in index, so the whole react family stays pinned to index. +- `index` (the default chunk) = the react family (react, react-dom, scheduler, use-sync-external-store), vendored cordis, all workspace code, and the unlisted small pieces (anser, clsx). +- `@shikijs/langs` is special-cased: the boot grammars (`BOOT_GRAMMAR_FILES`: typescript, shellscript, json — the three that highlight.ts statically imports, all self-contained data modules with zero internal imports) go into vendor; the remaining 23 lazy-loaded grammars get no assignment and each keeps its own on-demand chunk. +- `index.html` is wired up automatically by vite: index loads via `')) + for (const path of ['/', '/index.html', '/no/such/route']) { + const got = await request(port, path) + expect(got.status).toBe(200) + expect(got.body).toContain('__T__') + expect(got.body).toContain('shell') + } + untap() + expect((await request(port, '/')).body).not.toContain('__T__') + + // Traversal outside the dist root is 403; non-GET/HEAD is 405. + expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) + expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) + + // HMR safety: disposing the frontend row releases the fallback seat (the + // unclaimed webserver answers 404) and the seat is claimable again. + const frontendEntry = [...loaded.loader.entries()].find(e => e.options.id === 'frontend') + expect(frontendEntry).toBeDefined() + await frontendEntry!.fiber?.dispose() + expect((await request(port, '/no/such/route')).status).toBe(404) + expect(() => server.registerFallback(() => {})).not.toThrow() + }) +}) diff --git a/packages/host/frontend-static/tsconfig.json b/packages/host/frontend-static/tsconfig.json new file mode 100644 index 0000000000..bda9b5bb40 --- /dev/null +++ b/packages/host/frontend-static/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../webserver" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 8b53e55af5..56fd0e7694 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/webserver/README.md -README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4 -README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977 +README.md: b6dccf2f81c9e2f0b9f53264eafe724edb560f07 +README.zh.md: dbfe420013ed67c48e47048341f020864aeef16a diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 196f350d87..b6dccf2f81 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. -The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. -A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. +A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata. @@ -21,5 +21,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No TLS, auth, or origin policy** — binding a non-loopback address exposes the server to that network; deployment hardening (or fronting it with a real reverse proxy) is deliberately out of scope for the dev-facing v1. -- **The starter MIME table is minimal** — extensions beyond the vite-emitted set fall back to `application/octet-stream`; extend the table when an asset class actually ships. - **Socket options are fixed** — config selects the bind host and port, while backlog and other socket settings remain internal until a deployment needs them. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 0ae0470eab..dbfe420013 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 +Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 -该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的 route。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 +该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 -监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 +监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。 @@ -21,5 +21,4 @@ Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配 ## 已知限制与延期工作 - **不提供 TLS、认证或来源策略**:绑定非回环地址会向对应网络公开服务器;面向部署的加固措施(或在前方放置真正的反向代理)有意不纳入面向开发环境的 v1。 -- **初始 MIME 表很精简**:Vite 输出集合以外的扩展名会回退到 `application/octet-stream`;实际发布新的资产类别时再扩展该表。 - **Socket 选项固定不变**:配置只选择绑定宿主与端口;在具体部署产生需求前,backlog 和其他 socket 设置仍保持内部实现。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 6b46b8704d..a536f9e1f5 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,21 +1,19 @@ /** * @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http * server plus the `httpServer` service (HTTP and upgrade route registries, - * index transform taps, and static dist fallback). Knows no harness concepts; - * feature plugins own every registered protocol. Web shape only — Electron - * loads dist over file:// and carries fetch over an IPC bridge. This package - * never prints: the URL line belongs to the shell. + * index transform taps, and the single fallback seat for everything no route + * claims). Knows no harness concepts and serves no files; the composing + * application's frontend plugin owns dist serving through the fallback seam. + * Web shape only — Electron loads dist over file:// and carries fetch over an + * IPC bridge. This package never prints: the URL line belongs to the shell. */ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse, Server } from 'node:http' -import { readFile } from 'node:fs/promises' import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' -import { dirname } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' -import { serveStatic } from './static.ts' declare module 'cordis' { interface Context { @@ -43,28 +41,26 @@ export interface WebUpgradeRoute { handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise } -/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */ +/** Gateway config: the listen address. */ export interface Config { /** Listen host; the two supported values are loopback and all-interfaces. */ host: '127.0.0.1' | '0.0.0.0' /** Listen port; zero requests an OS-assigned port. */ port: number - /** Absolute path of index.html inside the static root (dist location is workspace knowledge of the app). */ - distIndex: string } /** * The web-shape HTTP carrier service. Activation listens immediately (route * registration order carries no request-facing semantics: named routes are - * composed to be disjoint, and the static dist fallback answers anything not - * yet claimed during the boot window). A listen failure throws out of init — - * a FAILED fiber the boot's fail-loud sweep reports. + * composed to be disjoint, and the fallback seat answers anything not yet + * claimed during the boot window — 404 until its owner registers). A listen + * failure throws out of init — a FAILED fiber the boot's fail-loud sweep + * reports. */ export class HttpServerService extends Service { static Config: z = z.object({ host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(), port: z.natural().max(65535).required(), - distIndex: z.string().required(), }) private readonly exact = new Map() @@ -72,15 +68,12 @@ export class HttpServerService extends Service { private readonly upgrades = new Map() private readonly upgradedSockets = new Set() private readonly indexTaps: ((html: string) => string)[] = [] - private readonly distRoot: string - private readonly distIndex: string + private fallback: WebRoute['handler'] | undefined private server!: Server private listenedPort!: number constructor(ctx: Context, private config: Config) { super(ctx, 'httpServer') - this.distIndex = config.distIndex - this.distRoot = dirname(config.distIndex) } /** The listening port (the OS-assigned value when config.port is 0). */ @@ -123,8 +116,24 @@ export class HttpServerService extends Service { } /** - * Register an index.html transform, applied to every index response in - * registration order. + * Claim the fallback seat: the handler answering every request no named + * route matches (the SPA dist server in the shipped Web composition). One + * owner only — a second registration throws, because two fallbacks cannot + * compose. + * @param handler - owns the full response lifecycle of unmatched requests. + * @returns the disposer releasing the seat. + */ + registerFallback(handler: WebRoute['handler']): () => void { + if (this.fallback !== undefined) { + throw new Error('webserver: fallback already registered') + } + this.fallback = handler + return () => { this.fallback = undefined } + } + + /** + * Register an index.html transform, applied by the fallback owner to every + * index response ({@link applyIndexTaps}) in registration order. * @param transform - pure html-to-html function. * @returns the disposer removing the transform. */ @@ -147,14 +156,13 @@ export class HttpServerService extends Service { await route.handler(req, res) return } - // Static fallback keeps the pre-plugin semantics: non-GET/HEAD is 405, - // traversal 403, miss falls back to index.html 200 (SPA routing). - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) + const fallback = this.fallback + if (fallback === undefined) { + res.writeHead(404) res.end() return } - await serveStatic(decodeURIComponent(rawPath), res, this.distRoot, this.distIndex, () => this.renderIndex()) + await fallback(req, res) } // Last-resort guard: handle() rejecting would otherwise be an unhandled // rejection killing the process on one malformed request (bad %-escape, @@ -243,11 +251,16 @@ export class HttpServerService extends Service { return best } - /** Index body: dist index.html through the registered taps in order. */ - private async renderIndex(): Promise { - let html = await readFile(this.distIndex, 'utf8') - for (const transform of this.indexTaps) html = transform(html) - return html + /** + * Run an index.html body through the registered taps in registration order + * — called by the fallback owner on every index response it renders. + * @param html - the raw index.html body. + * @returns the transformed body. + */ + applyIndexTaps(html: string): string { + let out = html + for (const transform of this.indexTaps) out = transform(out) + return out } } diff --git a/packages/host/webserver/src/static.ts b/packages/host/webserver/src/static.ts deleted file mode 100644 index a672f4e5c2..0000000000 --- a/packages/host/webserver/src/static.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Static file serving for the web shell: the starter MIME table and the - * request handler with the semantics locked by the step1 acceptance list — - * traversal outside the dist root is 403, any miss falls back to index.html - * with HTTP 200 (SPA routing), unknown extensions ship as octet-stream. - */ - -import type { ServerResponse } from 'node:http' -import { extname, join, normalize, resolve, sep } from 'node:path' -import { readFile } from 'node:fs/promises' - -const MIME: Record = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.svg': 'image/svg+xml', - '.json': 'application/json', - '.map': 'application/json', -} - -/** - * Serve one GET/HEAD static request from the dist root. - * @param pathname - decoded URL pathname of the request. - * @param res - the node:http response to write. - * @param distRoot - absolute dist root directory (resolved by the caller). - * @param distIndex - absolute path of index.html inside distRoot. - * @param renderIndex - when set, produces the index.html body (boot-manifest - * injection) for `/` and every SPA fallback; undefined serves the file verbatim. - */ -export async function serveStatic( - pathname: string, res: ServerResponse, distRoot: string, distIndex: string, - renderIndex?: () => Promise, -): Promise { - const target = resolve(normalize(join(distRoot, pathname))) - // Traversal rejection: the target must be distRoot itself (`/`) or stay under - // it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/' - // suffix would reject every legitimate subpath as traversal. - if (target !== distRoot && !target.startsWith(distRoot + sep)) { - res.writeHead(403) - res.end() - return - } - const serveIndex = async (): Promise => { - const body = renderIndex === undefined ? await readFile(distIndex) : await renderIndex() - res.writeHead(200, { 'content-type': MIME['.html'] }) - res.end(body) - } - if (target === distRoot || target === distIndex) { - await serveIndex() - return - } - try { - const body = await readFile(target) - res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' }) - res.end(body) - } catch { - // Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing). - await serveIndex() - } -} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 19a252d53a..d91284c87b 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -2,11 +2,10 @@ * REAL-composition coverage: a test-only cordis.yml booted through the * vendored Loader mounts the webserver row, and every assertion observes the * user-visible HTTP surface of the running server (routing precedence, index - * taps, static-fallback semantics, per-request error containment, teardown). + * taps, fallback-seat semantics, per-request error containment, teardown). */ import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { mkdir } from 'node:fs/promises' import { once } from 'node:events' import { connect } from 'node:net' import { tmpdir } from 'node:os' @@ -28,21 +27,15 @@ afterEach(async () => { root = undefined }) -/** Write a dist fixture and a cordis.yml with one webserver row, then boot it through the real Loader. */ +/** Write a cordis.yml with one webserver row, then boot it through the real Loader. */ async function loadComposition(port = 0): Promise { root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-')) - const dist = join(root, 'dist') - await mkdir(dist) - const distIndex = join(dist, 'index.html') - await writeFile(distIndex, 'shell') - await writeFile(join(dist, 'app.js'), 'export {}') const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-host-webserver'", ' config:', " host: '127.0.0.1'", ` port: ${String(port)}`, - ` distIndex: '${distIndex}'`, '', ].join('\n')) @@ -96,7 +89,7 @@ describe('real Loader composition', () => { // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. - it('serves registered routes, index taps, and the static fallback semantics', { timeout: 60_000 }, async () => { + it('serves registered routes, index taps, and the fallback-seat semantics', { timeout: 60_000 }, async () => { const loaded = await loadComposition() const unloaded = [...loaded.loader.entries()] .filter(entry => entry.fiber === undefined && !entry.disabled) @@ -120,21 +113,24 @@ describe('real Loader composition', () => { expect(await request(port, '/api')).toMatchObject({ status: 200, body: 'API' }) expect(await request(port, '/api/anything', { method: 'POST' })).toMatchObject({ status: 200, body: 'API' }) - // Index taps apply in registration order on `/` and on the SPA fallback; - // the disposer removes the transform. + // Fallback seat: 404 while unclaimed; the owner answers everything no + // named route matches; index taps are the owner's to apply; the seat + // admits exactly one owner and the disposer releases it. + expect((await request(port, '/no/such/route')).status).toBe(404) const untap = server.tapIndex(html => html.replace('', '')) - expect((await request(port, '/')).body).toContain('__T__') + expect(server.applyIndexTaps('')).toContain('__T__') + const releaseFallback = server.registerFallback((req, res) => { + // Decode like a real static server would — a malformed %-escape throws + // here, probing the webserver's per-request error containment. + decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname) + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(server.applyIndexTaps('shell')) + }) + expect(() => server.registerFallback(() => {})).toThrow(/fallback already registered/) expect((await request(port, '/no/such/route')).body).toContain('__T__') untap() - expect((await request(port, '/')).body).not.toContain('__T__') - - // Static fallback semantics: real asset served, traversal 403, non-GET/ - // HEAD without a matching route 405. - expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export {}' }) - await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true') - expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' }) - expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403) - expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405) + expect((await request(port, '/no/such/route')).body).not.toContain('__T__') + expect((await request(port, '/no/such/route')).body).toContain('shell') // Per-request error containment: a malformed %-escape answers 400 and the // server keeps serving afterwards (no process-level failure path). @@ -148,9 +144,14 @@ describe('real Loader composition', () => { const disposeOnce = server.register({ kind: 'exact', path: '/once', handler: (_req, res) => { res.writeHead(200); res.end('ONCE') } }) expect(await request(port, '/once')).toMatchObject({ status: 200, body: 'ONCE' }) disposeOnce() - expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback + expect((await request(port, '/once')).body).toContain('shell') // back to the fallback owner expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow() + // Releasing the seat restores the unclaimed 404 and registrability. + releaseFallback() + expect((await request(port, '/no/such/route')).status).toBe(404) + expect(() => server.registerFallback(() => {})).not.toThrow() + // Upgrade routes match exact pathnames, reject duplicate ownership, and // become registrable again after disposal. The accepted socket stays open // so the teardown assertion also covers upgraded-connection ownership. diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 7e616b5cd1..b4e9cffabb 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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-pi-ai/README.md -README.md: 75b2136315aed758f18f7fe82afcd4903f4a7b98 -README.zh.md: ea67250549f1d23d48455fd185283b00183dd538 +README.md: af0e952dd8dbd9767b98229ee6b87262007d6738 +README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 75b2136315..af0e952dd8 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,19 +2,20 @@ English | [中文](README.zh.md) -Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` against that route's configured catalog. A route naming an installed pi-ai provider inherits its endpoint, wire protocol, and model catalog as defaults and overrides them field by field; a route pi-ai does not ship is declared outright, so an OpenAI-compatible gateway, a self-hosted server, or a provider newer than the installed catalog is configuration rather than a code change. -The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. +The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `supportedProtocols()`; profile resolution, catalog materialization, provider construction, replay conversion, and stream conversion remain package-internal. ## Config -Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what delegates authentication to pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. +Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route. ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: + # Catalog route: endpoint, protocol, and models all come from pi-ai. openai: apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 @@ -26,34 +27,77 @@ Configure credentials and deployment-specific transport settings per provider, k initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 + # Catalog route with its catalog narrowed to one model and that model's + # capacity corrected; every unset field still comes from the catalog. anthropic: apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - openrouter: - apiKeyEnv: OPENROUTER_API_KEY - headers: - X-Deployment: production + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 ``` -Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.`), so configuration surfaces can offer the full catalog before any route exists. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. +The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`. + +## Catalog resolution + +A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. Reasoning is not per-model configurable at all: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, and no listing endpoint reports a model's reasoning protocol, so reasoning rides the installed catalog entry or is absent. + +A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. + +Resolution still fails loud, naming the offending route and model, when a route cannot be served at all: a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list of uniquely-identified models. That resolution runs inside the section schema, so an unserviceable profile is refused **where it is written** — `settings.mutate` answers `settings-rejected` naming the route and model — rather than being stored and then quietly disabling every route in the namespace. The settings seam keeps a namespace's last good value for an already-stored section that fails, so this cannot strand a deployment. `api` accepts the protocols in `supportedProtocols()` and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing. + +`baseURL` sets the endpoint of every model on the route, so private proxies such as `https://proxy.example.com:8443` remain supported; a catalog route that omits it keeps each catalog model's own endpoint. Naming `api` on a catalog route repoints the whole route at that protocol, which is how a deployment moves a provider between, say, Responses and Chat Completions. + +`supportedProtocols()` is deliberately narrower than pi-ai's full streaming API set: it holds only the protocols a profile can *completely* describe with a key, an endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and application-default credentials, Azure needs provider environment plus an api-version, and Codex authenticates through OAuth — offering those would hand back a route that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. ## Dynamic configuration (settings + credentials) The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. -Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load. +Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving. -The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. +The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. -The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. +A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. -Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. + +Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. +## Endpoint interrogation + +The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. It is deliberately *not* a catalog refresh: nothing is stored, and the reply is candidates the surface offers for adoption. `settings.yaml` remains the only thing that decides what a route serves. + +A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand. + +A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. + +Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. + +Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out. + ## Provider/model routing and replay -The selected pi-ai catalog descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. +Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use. + +Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives. + +The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. Successful assistant responses store a versioned, lossless-JSON replay state beside their durable provider/model provenance. At request time, `LlmService` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response. @@ -109,7 +153,9 @@ Recorded response content appends to the next request and does not invalidate it - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). -- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. +- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. +- **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. +- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index ea67250549..f8a19999f0 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -2,19 +2,20 @@ [English](README.md) | 中文 -基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`。 +基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并针对该路由已配置的 catalog 解析 `GenerateOptions.model`。点名了已安装 pi-ai 提供方的路由会继承其端点、协议格式与模型 catalog 作为默认值,并逐字段覆盖;pi-ai 未提供的路由则整体声明出来,因此接入 OpenAI 兼容网关、自建服务,或比已安装 catalog 更新的提供方,都属于配置而非改代码。 -包根入口导出 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。 +包(package)根入口导出 Cordis 插件契约、`PiAiAdapter` 与 `supportedProtocols()`;profile 解析、catalog 物化、提供方构造、回放转换和流转换保留在包内部。 ## 配置 -按提供方配置凭据与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会把认证委托给 pi-ai 的提供方原生环境发现;已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: + # Catalog route: endpoint, protocol, and models all come from pi-ai. openai: apiKeyEnv: OPENAI_API_KEY baseURL: https://proxy.example.com:8443 @@ -26,34 +27,77 @@ initialDelayMs: 500 maxDelayMs: 10000 jitterRatio: 0.1 + # Catalog route with its catalog narrowed to one model and that model's + # capacity corrected; every unset field still comes from the catalog. anthropic: apiKeyEnv: ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 - openrouter: - apiKeyEnv: OPENROUTER_API_KEY - headers: - X-Deployment: production + models: + - id: claude-sonnet-4-5 + contextWindow: 200000 + # Hand-declared route: pi-ai ships nothing under this key, so the profile + # supplies the whole provider. + acme-gateway: + displayName: Acme Gateway + apiKeyEnv: ACME_GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.acme.example/v1 + models: + - id: acme-large + name: Acme Large + contextWindow: 65536 + maxTokens: 4096 ``` -每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.`)中声明每个已安装 catalog 提供方,因此配置界面可以在任何路由存在之前就提供完整 catalog。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 +字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 + +## Catalog 解析 + +profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow` 与 `maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。 + +条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。 + +路由完全无法服务时解析仍会失败得响亮,并点名出问题的路由与模型:catalog 未提供的路由需要 `api`、`baseURL`,以及一个由唯一标识的模型组成的非空 `models` 列表。该解析在分节 schema 内部运行,因此无法服务的 profile 会在**写入之处**被拒绝——`settings.mutate` 以 `settings-rejected` 点名路由与模型——而不是先存下来、再悄悄让该 namespace 下每条路由失效。对于已经存下的、在此失败的分节,settings seam 会保留该 namespace 上一份可用值,因此这不会把部署卡死。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要:catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。 + +`baseURL` 设定该路由下每个模型的端点,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy;省略它的 catalog 路由会保留每个 catalog 模型自己的端点。在 catalog 路由上点名 `api` 会把整条路由改指到该协议,这正是部署把某个提供方在 Responses 与 Chat Completions 之间迁移的方式。 + +`supportedProtocols()` 刻意窄于 pi-ai 的完整流式 API 集合:它只保留 profile 能用密钥、端点与标头**完整描述**的那些协议。Bedrock 要用 AWS 凭据与 region 做 SigV4 签名,Vertex 需要 project、location 与应用默认凭据,Azure 需要提供方环境外加 api-version,Codex 走 OAuth——提供它们只会交回一个无法完成认证的路由。catalog 路由仍可经自己的 provider 抵达这些协议;被拒绝的只有显式覆盖。 ## 动态配置(settings + credentials) 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。 -凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。 +凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。 -适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。 +适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 -`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 -受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 + +受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 +## 端点询问 + +插件提供 `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`,用来回答「这个提供方能服务哪些模型?」——针对配置界面正在编辑或起草的路由。它刻意**不是** catalog 刷新:什么都不存储,回复是界面供用户采纳的候选。`settings.yaml` 始终是唯一决定路由服务什么的东西。 + +点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。 + +草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。 + +询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。 + +多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。 + ## 提供方/模型路由与回放 -所选 pi-ai catalog descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 +每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。 + +凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储,harness 也保住了自己失败得响亮的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。 + +所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 成功的 assistant 响应会在自身持久提供方/模型溯源旁存储经版本化的无损 JSON 回放状态。请求时,`LlmService` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。 @@ -109,7 +153,9 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 -- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。 +- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 +- **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 +- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。 - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 030592f74c..365c3901a5 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -1,17 +1,33 @@ /** * Generic pi-ai-backed implementation of the Harness LLM seam. * + * Each resolution produces one **immutable** snapshot — the profiles plus a + * `Models` collection holding the `Provider` each route built — and an + * operation captures a whole snapshot before its first `await`. A + * configuration change builds a *new* collection rather than mutating the one + * in use, because `Models.streamSimple()` is lazy: it resolves the provider + * when the stream is first consumed, which is after the credential await, so a + * mutated collection would let a request that started under one configuration + * finish under another — or fail with a provider that no longer exists. This is + * what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the + * way down: switching models mid-reply takes effect on the next step, never + * inside the one in flight. + * + * Credentials stay outside that collection. The harness resolves a route's key + * through its own seam and passes it as the request's `apiKey` option, which + * pi-ai treats as the highest-priority auth override — so `Models` never holds + * a credential store and the harness keeps its fail-loud reference semantics. + * * @module dsh-llm-pi-ai/adapter */ -import { streamSimple } from '@earendil-works/pi-ai/compat' -import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' -import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' -import { getSupportedThinkingLevels } from '@earendil-works/pi-ai' +import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, Model, + Models, ModelThinkingLevel, + MutableModels, SimpleStreamOptions, ThinkingLevel, } from '@earendil-works/pi-ai' @@ -24,6 +40,7 @@ import { import type { GenerateOptions, LlmModelInfo, + LlmProviderInfo, LlmResolvedModelInfo, ReasoningEffortId as ReasoningEffortIdType, ResolvedRetryPolicy, @@ -34,35 +51,29 @@ import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' +/** One resolution's frozen view: the profiles and the collection built from them. */ +interface PiAiSnapshot { + /** The resolved profiles this collection was built from, used as its identity. */ + profiles: ReadonlyMap + /** Providers for exactly those profiles; never mutated once published. */ + models: Models +} + /** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */ export interface PiAiAdapterOptions { /** Current validated profiles by provider route; called once per operation. */ profiles: () => ReadonlyMap /** * Resolve the credential for one already-resolved profile; called once per - * stream call and frozen for that call. `undefined` defers to pi-ai's - * provider-native ambient discovery, which the plugin allows only for a - * profile naming no credential at all; a named reference that misses throws - * `LlmError` `MISSING_CREDENTIAL` rather than falling back. + * stream call and frozen for that call. `undefined` defers to the route's own + * pi-ai auth, which for an installed catalog route is its provider-native + * ambient discovery; the plugin allows that only for a profile naming no + * credential at all, because a named reference that misses throws `LlmError` + * `MISSING_CREDENTIAL` rather than falling back. */ resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise } -/** - * Resolve a catalog model dynamically and apply only the configured endpoint - * override, preserving the catalog's API/capability/compatibility metadata. - */ -function resolvePiModel( - profile: ResolvedPiAiProviderProfile, - modelId: string, -): Model { - const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined - if (model === undefined) { - throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') - } - return profile.baseURL === undefined ? model : { ...model, baseUrl: profile.baseURL } -} - /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( profile: ResolvedPiAiProviderProfile, @@ -97,6 +108,39 @@ function resolveReasoningLevel( ) } +/** + * Selectable reasoning efforts for one model, or nothing at all. + * + * A model that carries no reasoning metadata — every hand-declared one, and + * every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as + * supporting the single level `off`. Passing that through would offer a control + * that cannot do what it says: `off` is translated to *omitting* the reasoning + * option, which for such a model is byte-for-byte the same request as naming no + * effort — so a provider whose own default is to think would keep thinking with + * `off` selected. Omitting `reasoning` entirely is the seam's way of saying the + * capability is unavailable, which leaves the surface offering only the + * provider's default. + * @param model - the resolved model descriptor. + * @param defaultLevel - the profile's configured effort, already validated. + * @returns the `reasoning` field, or an empty object when none can be offered. + */ +function reasoningInfo( + model: Model, + defaultLevel: ModelThinkingLevel | undefined, +): Pick | Record { + if (!model.reasoning) return {} + const levels = getSupportedThinkingLevels(model) + return { + reasoning: { + efforts: levels.map(level => ({ + id: ReasoningEffortId(level), + name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`, + })), + ...defaultLevel === undefined ? {} : { defaultEffort: ReasoningEffortId(defaultLevel) }, + }, + } +} + /** Merge deployment headers while removing case-insensitive attribution collisions. */ function requestHeaders(headers: Readonly> | undefined): Record { const attribution = attributionHeaders() @@ -108,28 +152,72 @@ function requestHeaders(headers: Readonly> | undefined): } /** - * pi-ai-backed multi-provider adapter. Model descriptors are resolved for each - * request, so models need not be registered during the Cordis lifecycle. + * pi-ai-backed multi-provider adapter. Each operation reads the current + * profiles, so a configuration change reaches the next request without a + * restart; model descriptors come from the collection those profiles built. */ export class PiAiAdapter extends LlmAdapter { + private snapshot: PiAiSnapshot | undefined + constructor(private readonly config: PiAiAdapterOptions) { super() } + /** + * The snapshot for the current profiles. Resolution memoizes its result, so + * an unchanged configuration is recognized by identity; a changed one gets a + * brand-new collection, leaving any snapshot an operation already captured + * untouched for as long as that operation holds it. + */ + private current(): PiAiSnapshot { + const profiles = this.config.profiles() + if (this.snapshot?.profiles === profiles) return this.snapshot + const models: MutableModels = createModels() + for (const profile of profiles.values()) models.setProvider(profile.piProvider) + this.snapshot = { profiles, models } + return this.snapshot + } + + /** The profile for one route within one snapshot, or the not-owned failure. */ + private profileOf(snapshot: PiAiSnapshot, provider: string): ResolvedPiAiProviderProfile { + const profile = snapshot.profiles.get(provider) + if (profile === undefined) { + throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER') + } + return profile + } + + /** The configured descriptor for one exact route/model pair within one snapshot. */ + private modelOf(snapshot: PiAiSnapshot, provider: string, model: string): Model { + this.profileOf(snapshot, provider) + const resolved = snapshot.models.getModel(provider, model) + if (resolved === undefined) { + throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL') + } + return resolved + } + + override providerInfo(provider: string): LlmProviderInfo { + // The configured name, not the route key: `displayName` exists so a + // deployment can label a route, and a label only the configuration surface + // reads would leave every selector showing the raw key. + return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider } + } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { - return this.config.profiles().get(provider)?.retryPolicy + return this.current().profiles.get(provider)?.retryPolicy } override listModels(provider: string): Promise { - const profile = this.config.profiles().get(provider) - if (profile === undefined) { - return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')) - } - return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({ - provider, - id: model.id, - name: model.name, - }))) + return Promise.resolve().then(() => { + const snapshot = this.current() + this.profileOf(snapshot, provider) + return snapshot.models.getModels(provider).map(model => ({ + provider, + id: model.id, + name: model.name, + })) + }) } override resolveModel( @@ -137,31 +225,21 @@ export class PiAiAdapter extends LlmAdapter { model: string, _signal?: AbortSignal, ): Promise { - const profile = this.config.profiles().get(provider) - if (profile === undefined) { - return Promise.reject(new LlmError( - `pi-ai adapter does not own provider "${provider}"`, - 'NO_ADAPTER', - )) - } return Promise.resolve().then(() => { - const resolvedModel = resolvePiModel(profile, model) - const levels = getSupportedThinkingLevels(resolvedModel) + const snapshot = this.current() + const profile = this.profileOf(snapshot, provider) + const resolvedModel = this.modelOf(snapshot, provider, model) const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) + // Only a cap the deployment configured is a request default; the + // catalog's `maxTokens` sizes the model and stops there. + const configuredMaxTokens = profile.configuredMaxTokens.get(model) return { provider, id: model, name: resolvedModel.name, context: { contextWindow: resolvedModel.contextWindow }, - reasoning: { - efforts: levels.map(level => ({ - id: ReasoningEffortId(level), - name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`, - })), - ...defaultLevel === undefined - ? {} - : { defaultEffort: ReasoningEffortId(defaultLevel) }, - }, + ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, + ...reasoningInfo(resolvedModel, defaultLevel), } }) } @@ -170,14 +248,14 @@ export class PiAiAdapter extends LlmAdapter { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') } - // One resolution per stream call: the profile snapshot and the credential - // freeze here and hold for this whole request, so an in-flight stream - // never observes a configuration change and the next call re-resolves. - const profile = this.config.profiles().get(options.provider) - if (profile === undefined) { - throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') - } - const model = resolvePiModel(profile, options.model) + // One capture per stream call, taken before any await: the profile, the + // model descriptor, and the collection all come from the same immutable + // snapshot, and the credential freezes with them. A configuration change + // mid-request builds a separate snapshot, so this request finishes under + // the one it started with and the next call picks up the new one. + const snapshot = this.current() + const profile = this.profileOf(snapshot, options.provider) + const model = this.modelOf(snapshot, options.provider, options.model) const reasoning = resolveReasoningLevel( model, options.reasoningEffort ?? profile.reasoning, @@ -192,7 +270,7 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const events = streamSimple(model, toPiContext(options), { + const events = snapshot.models.streamSimple(model, toPiContext(options), { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts new file mode 100644 index 0000000000..173b84dd7d --- /dev/null +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -0,0 +1,223 @@ +/** + * Materialization of one provider route's model catalog. The installed pi-ai + * catalog supplies defaults keyed by model id, and a profile's own model + * entries override them field by field, so a route naming a catalog provider + * stays configuration-free while a route pi-ai has never heard of is fully + * describable from `settings.yaml`. + * + * Every pi-ai `Model` field the harness cannot default is required here rather + * than at request time: an unserviceable route fails while its configuration is + * being resolved, which is the earliest point that can name the offending key. + * + * @module dsh-llm-pi-ai/catalog + */ + +import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' +import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' +import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai' + +/** + * Pricing for a model the installed catalog does not describe. The harness + * never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer + * reports spend — so this is the absence of a fact, not a configurable rate. + */ +const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } + +/** + * Input modalities for a model the installed catalog does not describe. The + * request converter keeps only text blocks, so text is the adapter's actual + * capability rather than a deployment choice. + */ +const TEXT_ONLY: Model['input'] = ['text'] + +let providerIndex: Map | undefined + +/** + * Installed catalog providers by id, constructed once. Each entry owns the API + * implementations for its own models, which is why a catalog route reuses this + * provider instead of being rebuilt from parts. + * @returns the catalog provider index. + */ +function catalogProviders(): Map { + providerIndex ??= new Map(builtinProviders().map(provider => [provider.id, provider])) + return providerIndex +} + +/** + * The installed catalog provider for one route, when pi-ai ships one. + * @param provider - provider route key. + * @returns the catalog provider, or `undefined` for a route pi-ai does not ship. + */ +export function catalogProvider(provider: string): Provider | undefined { + return catalogProviders().get(provider) +} + +/** + * Every provider route the installed pi-ai catalog ships. + * @returns the catalog provider ids. + */ +export function catalogProviderIds(): readonly string[] { + return getBuiltinProviders() +} + +/** + * The installed catalog models for one route, indexed by model id. + * @param provider - provider route key. + * @returns catalog models by id; empty for a route pi-ai does not ship. + */ +export function catalogModels(provider: string): Map> { + if (!catalogProviders().has(provider)) return new Map() + const models = getBuiltinModels(provider as BuiltinProvider) as Model[] + return new Map(models.map(model => [model.id, model])) +} + +/** One configured model entry: an id plus the catalog fields it overrides. */ +export interface PiAiModelProfile { + /** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */ + id: string + /** Display name for selectors; defaults to the catalog name, then the id. */ + name?: string + /** Maximum combined request and response context in tokens. */ + contextWindow?: number + /** + * Maximum output tokens. Configuring one also makes it this model's + * per-request default; a value inherited from the installed catalog, or the + * route's fallback, is the model's capability and never becomes a request + * default on its own. + */ + maxTokens?: number +} + +/** The route-level facts model materialization reads. */ +export interface RouteCatalogRequest { + /** Provider route key, stamped onto every materialized model. */ + provider: string + /** Wire protocol override; absent defers to each catalog model's own API. */ + api?: string + /** Endpoint override; absent defers to the catalog model, then the catalog provider. */ + baseURL?: string + /** Configured catalog; absent means the whole installed catalog for this route. */ + models?: readonly PiAiModelProfile[] + /** Context capacity for a model neither the entry nor the catalog sizes. */ + defaultContextWindow: number + /** Output capability for a model neither the entry nor the catalog sizes. */ + defaultMaxTokens: number +} + +/** Report a route the deployment cannot serve, naming the settings key at fault. */ +function invalid(provider: string, detail: string): never { + throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`) +} + +/** + * The one wire protocol a catalog route's shipped models agree on. This is what + * lets a deployment add a model the installed catalog has not caught up with — + * a provider's newest release — without restating the protocol its siblings + * already use. A route whose shipped models disagree (an OpenAI-style catalog + * spanning Responses and Chat Completions) has no such answer, so a model it + * does not describe must name its protocol at the route. + */ +function sharedCatalogApi(defaults: ReadonlyMap>): string | undefined { + const apis = new Set() + for (const model of defaults.values()) apis.add(model.api) + return apis.size === 1 ? [...apis][0] : undefined +} + +/** One route's materialized catalog, plus the request caps its profile chose. */ +export interface RouteCatalog { + /** The materialized models in configuration order. */ + models: readonly Model[] + /** + * Per-request output caps this profile explicitly configured, by model id. + * + * Separate from `Model.maxTokens` because the two answer different + * questions: pi-ai requires `maxTokens` as the model's output *capability*, + * while the harness seam's `defaultMaxTokens` is a cap the deployment chose + * to send on requests that name none. Materializing a catalog capability as + * a request default would start capping every request at a number nobody + * picked, so only an explicit configuration lands here. + */ + configuredMaxTokens: ReadonlyMap +} + +/** + * Materialize one route's catalog by merging the installed catalog defaults + * under the configured entries. A route with no configured `models` serves the + * installed catalog unchanged, which is what keeps an existing + * `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched. + * @param request - the route-level catalog facts. + * @returns the materialized models and the explicitly configured request caps. + */ +export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + const { provider } = request + const defaults = catalogModels(provider) + const providerBaseUrl = catalogProvider(provider)?.baseUrl + // An absent `models` key and an empty one are the same request: the config + // schema materializes `[]` for the absent case, and an empty catalog could + // serve no request anyway, so both mean "serve the installed catalog". + const configured = request.models ?? [] + const entries: readonly PiAiModelProfile[] = configured.length > 0 + ? configured + : [...defaults.values()].map(model => ({ id: model.id })) + if (entries.length === 0) { + invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models' + + ' must be listed in configuration') + } + const routeApi = sharedCatalogApi(defaults) + const seen = new Set() + const configuredMaxTokens = new Map() + const models = entries.map((entry) => { + if (entry.id.length === 0) invalid(provider, 'has a model with an empty id') + if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`) + seen.add(entry.id) + const base = defaults.get(entry.id) + const api = request.api ?? base?.api ?? routeApi + if (api === undefined) { + invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the` + + ' route\'s api to the wire protocol its endpoint speaks') + } + const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl + if (baseUrl === undefined) { + invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`) + } + // Capacities fall back to the route's own defaults, so a model listing that + // discloses nothing but ids still yields a serviceable route. The fallback + // is a guess by construction, which is why it is a configurable route field + // rather than a constant buried here. + const contextWindow = entry.contextWindow ?? base?.contextWindow ?? request.defaultContextWindow + if (!Number.isInteger(contextWindow) || contextWindow <= 0) { + invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`) + } + const maxTokens = entry.maxTokens ?? base?.maxTokens ?? request.defaultMaxTokens + if (!Number.isInteger(maxTokens) || maxTokens <= 0) { + invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`) + } + // Only a value the profile named is a deployment choice; the catalog's is + // the model's capability and stays out of request defaults. + if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens) + return { + // The installed entry lays the floor, and the fields below override it. + // Enumerating instead would silently drop every `Model` field this + // package does not model — reasoning-level spellings, compatibility + // quirks, model headers, and whatever a pi-ai upgrade adds next. That is + // not hypothetical: `headers` reached this file only after an nvidia + // route lost it, and a rebuild keeps re-earning that bug on every + // upgrade. + ...base, + id: entry.id, + name: entry.name ?? base?.name ?? entry.id, + api, + provider, + baseUrl, + // Reasoning rides the installed entry or is absent: a bare boolean would + // make pi-ai advertise effort levels with no `thinkingLevelMap` to spell + // them, and no listing endpoint reports a model's reasoning protocol. + reasoning: base?.reasoning ?? false, + input: base?.input ?? TEXT_ONLY, + cost: base?.cost ?? NO_COST, + contextWindow, + maxTokens, + } + }) + return { models, configuredMaxTokens } +} diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index c635b1f13e..7473dbb7ae 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -3,29 +3,73 @@ * Profiles are a dict keyed by provider route, so the composition base and a * user-settings layer merge per provider and the route set is structural. * + * A route key is not required to name an installed pi-ai provider. When it does, + * that provider's endpoint, protocol, display name, and model catalog are the + * profile's defaults and the profile overrides them field by field; when it does + * not, the profile is the whole provider declaration. Resolution therefore ends + * in a built pi-ai `Provider` per route: everything a request needs is decided + * once, while the configuration key that made a route unserviceable can still be + * named in the failure. + * * @module dsh-llm-pi-ai/config */ -import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' -import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' +import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { resolveRouteModels } from './catalog.ts' +import type { PiAiModelProfile } from './catalog.ts' +import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 +/** Context capacity assumed for a model neither configuration nor the catalog sizes. */ +export const DEFAULT_CONTEXT_WINDOW = 262_144 + +/** Output capability assumed for a model neither configuration nor the catalog sizes. */ +export const DEFAULT_MAX_TOKENS = 32_768 + +export type { PiAiModelProfile } from './catalog.ts' + /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */ apiKey?: string /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */ apiKeyEnv?: string - /** Override the selected catalog model's endpoint without changing its protocol metadata. */ + /** Name shown by configuration surfaces; defaults to the route key. */ + displayName?: string + /** + * Wire protocol every model on this route speaks. Omission keeps each + * installed catalog model's own protocol, which is why a catalog route needs + * no protocol at all; a route the catalog does not ship must name one. + */ + api?: string + /** Endpoint for this route's models; defaults to the installed catalog's endpoint. */ baseURL?: string + /** + * This route's model catalog. Omission serves the installed catalog for the + * route unchanged; an explicit list replaces it, each entry defaulting its + * unset fields from the installed model of the same id. + */ + models?: PiAiModelProfile[] + /** + * Context capacity for a model this route lists that neither the entry nor + * the installed catalog sizes (default 262,144). A guess by construction, so + * a deployment whose gateway serves smaller models corrects it here. + */ + defaultContextWindow?: number + /** + * Output capability for a model this route lists that neither the entry nor + * the installed catalog sizes (default 32,768). This sizes the model; it + * never becomes a per-request cap on its own. + */ + defaultMaxTokens?: number /** Provider request headers; Harness attribution wins reserved names. */ headers?: Record /** Provider-neutral pi-ai reasoning level. */ @@ -47,15 +91,31 @@ export interface PiAiProviderProfile { } /** Validated profile with its route stamped and every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends Omit { - /** pi-ai provider catalog name and Harness route key (the configuration dict key). */ +export interface ResolvedPiAiProviderProfile + extends Omit { + /** Harness route key and the `Models` collection key (the configuration dict key). */ provider: string + /** Resolved display name for selectors and configuration surfaces. */ + displayName: string /** Validated credential reference, when one is configured. */ apiKeyEnv?: CredentialRef /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy + /** + * The pi-ai provider this route registers, built from the resolved models. + * Construction happens here so an unserviceable protocol or an underspecified + * model fails with the rest of resolution, leaving the last good route set + * serving requests. + */ + piProvider: Provider + /** + * Per-request output caps this profile explicitly configured, by model id. + * The seam materializes one only into a request that names no cap of its + * own, so a catalog capability must not appear here. + */ + configuredMaxTokens: ReadonlyMap } /** Plugin configuration: the provider routes this instance owns. */ @@ -75,10 +135,22 @@ const thinkingBudgets = z.object({ high: z.number(), }) +const modelProfile: z = z.object({ + id: z.string().required(), + name: z.string(), + contextWindow: z.number().step(1).min(1), + maxTokens: z.number().step(1).min(1), +}) + const profile = z.object({ apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref'), + displayName: z.string(), + api: z.union(supportedProtocols()), baseURL: z.string(), + models: z.array(modelProfile), + defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), + defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), headers: z.dict(z.string()), reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), thinkingBudgets, @@ -96,10 +168,44 @@ export const Config: z = z.object({ }) /** - * Validate profiles against the installed pi-ai catalog and return a detached - * route-keyed map suitable for per-request reads. This is the one explicit - * resolve step, so an omitted dict resolves to the empty (dormant) route set - * here rather than through a hidden fallback. + * Reject a section this adapter could not serve. Registered as the settings + * namespace's validator, so an unserviceable profile is refused where it is + * *written* — `settings.mutate` answers `settings-rejected` with the offending + * route and model named — instead of being stored and then quietly disabling + * every route in the namespace. It stays a validator rather than a schema + * transform because the schema is also the shape a configuration surface + * renders and the value an absent section resolves to; wrapping it would break + * both. + * @param config - the resolved section to check. + * @throws Error naming the route and model that cannot be served. + */ +export function assertServiceable(config: Config): void { + resolveProfiles(config.providers) +} + +/** Reject a pre-release profile shape, naming the replacement. */ +function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void { + const legacy = source as PiAiProviderProfile & { + provider?: unknown + maxRetries?: unknown + maxRetryDelayMs?: unknown + } + if ('provider' in legacy) { + throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`) + } + if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { + throw new Error( + `llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed;` + + ' compose agent recovery with dsh-llm-retry', + ) + } +} + +/** + * Validate profiles and return a detached route-keyed map suitable for + * per-request reads. This is the one explicit resolve step, so an omitted dict + * resolves to the empty (dormant) route set here rather than through a hidden + * fallback, and each route's models and pi-ai provider are materialized once. * @param providers - configured provider profiles keyed by route. * @returns validated profiles in configuration order. */ @@ -110,28 +216,19 @@ export function resolveProfiles( throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles') } const entries = Object.entries(providers ?? {}) - const supported = new Set(getBuiltinProviders()) const resolved = new Map() for (const [provider, source] of entries) { - const legacy = source as PiAiProviderProfile & { - provider?: unknown - maxRetries?: unknown - maxRetryDelayMs?: unknown - } - if ('provider' in legacy) { - throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key') - } - if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) { - throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry') - } + rejectRemovedFields(provider, source) if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty') - if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`) if (source.apiKey !== undefined && source.apiKey.trim().length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`) } if (source.baseURL !== undefined && source.baseURL.length === 0) { throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`) } + if (source.displayName !== undefined && source.displayName.length === 0) { + throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`) + } const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 @@ -140,15 +237,37 @@ export function resolveProfiles( `llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } - const { apiKeyEnv, retryPolicy, ...rest } = source + // The route key, not the installed provider's own name: the directory has + // always shown route keys, and a catalog route must not silently rename + // itself on every configuration surface just because it gained a profile. + const displayName = source.displayName ?? provider + const catalog = resolveRouteModels({ + provider, + ...source.api === undefined ? {} : { api: source.api }, + ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, + ...source.models === undefined ? {} : { models: source.models }, + defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, + defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, + }) + const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { ...rest, provider, + displayName, ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) }, streamIdleTimeoutMs, retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`), ...rest.headers === undefined ? {} : { headers: { ...rest.headers } }, ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } }, + configuredMaxTokens: catalog.configuredMaxTokens, + piProvider: buildProvider({ + provider, + displayName, + ...source.api === undefined ? {} : { api: source.api }, + ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, + models: catalog.models, + namesCredential: source.apiKey !== undefined || apiKeyEnv !== undefined, + }), }) } return resolved diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts new file mode 100644 index 0000000000..bff2c9a7ca --- /dev/null +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -0,0 +1,262 @@ +/** + * Answering "which models can this provider serve?" for the configuration + * surface's "fetch available models" action. + * + * A route the installed pi-ai catalog ships is answered **from that catalog**, + * with no network call at all: pi-ai's registry is the authoritative list for + * its own providers, and it carries the capacities a listing endpoint would + * not disclose. Only a route the catalog does not describe — a gateway, a + * self-hosted server — is interrogated over the wire. + * + * Neither path is a catalog refresh. Nothing here is stored: the request + * carries a draft the user is still editing, and the reply is candidate + * metadata the surface offers for adoption. `settings.yaml` remains the only + * thing that decides what a route serves. + * + * Only OpenAI-compatible protocols are interrogated. Their listing is the one + * shape a gateway, a self-hosted server, and the official endpoints all agree + * on, which is the case this action exists for; every other protocol reports + * that it cannot be interrogated so the surface falls back to hand-entry + * rather than guessing a response shape. + * + * @module dsh-llm-pi-ai/discovery + */ + +import { LlmError } from '@deepseek-ai/dsh-llm' +import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm' +import { attributionHeaders } from '@deepseek-ai/dsh-llm' +import { catalogModels } from './catalog.ts' + +/** + * Protocols whose model listing this module can read: the two that speak + * OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its + * OpenAI lineage — it authenticates with an `api-key` header and requires an + * `api-version` query — and Codex authenticates through OAuth; guessing at + * either would report an authentication failure as a provider with no models. + * pi-ai's remaining protocols are absent for the same reason. + */ +const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ + 'openai-completions', + 'openai-responses', +]) + +/** + * Endpoint replies larger than this are refused. The endpoint is whatever URL + * the user typed, so the ceiling holds on the bytes actually read rather than + * on the length the server claims — the same two-stage shape `dsh-web-fetch` + * uses for its own caller-supplied URLs, except that a truncated model listing + * is not parseable, so overflow rejects instead of truncating. + */ +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + +/** One entry of an OpenAI-compatible `GET /models` reply. */ +interface ListingEntry { + id?: unknown + /** Common gateway extensions; absent from the official listings. */ + name?: unknown + display_name?: unknown + context_window?: unknown + context_length?: unknown + max_tokens?: unknown + max_output_tokens?: unknown +} + +/** A positive integer field of a listing entry, or `undefined` when absent or unusable. */ +function capacity(...candidates: readonly unknown[]): number | undefined { + for (const candidate of candidates) { + if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0) return candidate + } + return undefined +} + +/** A non-empty string field of a listing entry, or `undefined`. */ +function label(...candidates: readonly unknown[]): string | undefined { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.length > 0) return candidate + } + return undefined +} + +/** + * Join the endpoint base with the listing path. The base is treated as a + * prefix rather than a URL to resolve against, so a deployment path such as + * `https://gateway.example/openai/v1` keeps its segments instead of losing + * them to `URL` resolution. + */ +function listingUrl(baseURL: string): string { + return `${baseURL.replace(/\/+$/, '')}/models` +} + +/** + * Read a reply body, refusing one that outgrows the ceiling. A declared length + * is checked first so an honest server is turned away without transferring + * anything; the accumulated total is what actually enforces the bound, because + * a server that under-declares (or streams) tells us nothing up front. + */ +async function readBounded(response: Response, url: string): Promise { + const oversized = (): LlmError => + new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, 'DISCOVERY_FAILED') + const declared = Number(response.headers.get('content-length') ?? Number.NaN) + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + await response.body?.cancel() + throw oversized() + } + /* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */ + if (response.body === null) return '' + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > MAX_RESPONSE_BYTES) throw oversized() + chunks.push(value) + } + } finally { + /* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */ + await reader.cancel().catch(() => { + // Cancel after a drained read, or after this function walked away from + // an oversized one, is cleanup; the reply is already decided either way. + }) + } + const body = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(body) +} + +/** + * Read one OpenAI-compatible listing reply. Entries without a usable id are + * skipped rather than failing the whole interrogation: a single malformed row + * should not deny the user the rest of a working endpoint's catalog. + */ +function readListing(body: unknown): LlmDiscoveredModel[] { + const data = (body as { data?: unknown } | null)?.data + if (!Array.isArray(data)) { + throw new LlmError( + 'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand', + 'DISCOVERY_FAILED', + ) + } + const models: LlmDiscoveredModel[] = [] + for (const raw of data) { + const entry = raw as ListingEntry | null + const id = label(entry?.id) + if (id === undefined) continue + const name = label(entry?.name, entry?.display_name) + const contextWindow = capacity(entry?.context_window, entry?.context_length) + const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens) + models.push({ + id, + ...name === undefined ? {} : { name }, + ...contextWindow === undefined ? {} : { contextWindow }, + ...maxTokens === undefined ? {} : { maxTokens }, + }) + } + return models +} + +/** + * Interrogate one draft provider endpoint for the models it advertises. + * @param request - the endpoint, protocol, and one-shot credential to use. + * @param storedApiKey - the credential the named route already stored, asked + * for only when the draft carries none and only on the path that reaches the + * network. A configuration surface never holds a stored secret — it edits a + * redacted descriptor — so without this an already-configured route would be + * interrogated unauthenticated and answer 401. + * @returns the advertised models in endpoint order. + * @throws LlmError when the protocol has no readable listing, the endpoint + * refuses or fails the request, or the reply is not a model listing. + */ +export async function discoverModels( + request: LlmModelDiscoveryRequest, + storedApiKey?: () => Promise, +): Promise { + // A catalog route already has its answer, and a better one: the installed + // entries carry context windows and output caps no listing endpoint reports. + if (request.provider !== undefined) { + const installed = catalogModels(request.provider) + if (installed.size > 0) { + return [...installed.values()].map(model => ({ + id: model.id, + name: model.name, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + })) + } + } + if (request.baseURL === undefined || request.baseURL.length === 0) { + throw new LlmError( + `pi-ai ships no catalog for provider "${request.provider ?? ''}", so its models can only come from its` + + " endpoint; set a baseURL, or enter this provider's models by hand", + 'DISCOVERY_FAILED', + ) + } + // A draft that has not chosen a protocol yet is asked as OpenAI Chat + // Completions: it is the shape a gateway is overwhelmingly likely to speak, + // and the alternative — refusing until the field is filled — would withhold + // the action from the case it exists for. The cost is a misdirected message + // when the endpoint speaks something else (an Anthropic gateway answers 401, + // which reads as a credential problem), and hand-entry remains the way out. + const api = request.api ?? 'openai-completions' + if (!LISTABLE_PROTOCOLS.has(api)) { + throw new LlmError( + `pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`, + 'DISCOVERY_UNSUPPORTED', + ) + } + const url = listingUrl(request.baseURL) + // A key typed into the form wins: it is the one the user is testing, and it + // may be the replacement for exactly the stored key that is failing. The + // stored one is only asked for here, past the catalog short-circuit and the + // protocol check, so a route answered from the registry costs no credential + // lookup — and no diagnostic about a credential it never needed. + const apiKey = request.apiKey ?? await storedApiKey?.() + let response: Response + try { + response = await fetch(url, { + method: 'GET', + headers: { + accept: 'application/json', + ...apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` }, + ...attributionHeaders(), + }, + ...request.signal === undefined ? {} : { signal: request.signal }, + }) + } catch (error: unknown) { + if (request.signal?.aborted) { + throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error }) + } + throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error }) + } + if (!response.ok) { + throw new LlmError( + `${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`, + 'DISCOVERY_FAILED', + ) + } + let text: string + try { + text = await readBounded(response, url) + } catch (error: unknown) { + // Cancellation during the body read rejects with the abort reason, which + // may be any value; the caller gets the same coded failure it would have + // for a cancellation before the request went out. + if (request.signal?.aborted) { + throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error }) + } + throw error + } + let body: unknown + try { + body = JSON.parse(text) + } catch (error: unknown) { + throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error }) + } + return readListing(body) +} diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 91cb32a181..0d058e94ac 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -1,10 +1,11 @@ /** * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of - * provider routes; requests select a profile by provider and resolve the - * model dynamically from pi-ai's installed catalog. Profile facts resolve per - * request over the optional `llm-pi-ai` user-settings section and the - * optional credential seam, so a changed key, endpoint, or knob reaches the - * next request without a restart; a changed *route set* (or a route's + * provider routes; a route naming an installed pi-ai provider inherits that + * provider's endpoint, protocol, and model catalog as defaults, and a route + * pi-ai does not ship is declared outright. Profile facts resolve per request + * over the optional `llm-pi-ai` user-settings section and the optional + * credential seam, so a changed key, endpoint, model, or knob reaches the next + * request without a restart; a changed *route set* (or a route's * registration-captured retry policy) re-registers the same adapter instance * in place. * @@ -13,34 +14,49 @@ * name: '@deepseek-ai/dsh-llm-pi-ai' * config: * providers: + * # Catalog route: everything but the credential comes from pi-ai. * openai: * apiKeyEnv: OPENAI_API_KEY * retryPolicy: * mode: normal * maxRetries: 2 + * # Catalog route with the catalog narrowed and one capacity corrected. * anthropic: * apiKeyEnv: ANTHROPIC_API_KEY - * openrouter: - * apiKeyEnv: OPENROUTER_API_KEY - * baseURL: https://proxy.example.com/v1 + * models: + * - id: claude-sonnet-4-5 + * contextWindow: 200000 + * # Hand-declared route: pi-ai ships nothing under this key. + * acme-gateway: + * displayName: Acme Gateway + * apiKeyEnv: ACME_GATEWAY_API_KEY + * api: openai-completions + * baseURL: https://gateway.acme.example/v1 + * models: + * - id: acme-large + * name: Acme Large + * contextWindow: 65536 + * maxTokens: 4096 * ``` * * @module @deepseek-ai/dsh-llm-pi-ai */ import type { Context } from 'cordis' -import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import { LlmError } from '@deepseek-ai/dsh-llm' -import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm' +import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' -import { Config, resolveProfiles } from './config.ts' +import { catalogProviderIds } from './catalog.ts' +import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' +import { discoverModels } from './discovery.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] @@ -54,33 +70,60 @@ const NS = settingsNamespace('llm-pi-ai') */ function registrationFacts(profiles: ReadonlyMap): unknown { return [...profiles.entries()] - .map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy })) + // `displayName` rides along because the registry hands it to every selector + // through `providerInfo()`: a rename that did not re-register would leave + // the old label showing until some unrelated fact happened to change. + .map(([provider, profile]) => ({ + provider, + displayName: profile.displayName, + retryPolicy: profile.retryPolicy, + })) .sort((left, right) => left.provider.localeCompare(right.provider)) } +/** + * The configurable-provider directory: every installed catalog route, plus + * every route the current profiles declare. A hand-declared route has no + * catalog entry, so without this union it would have no settings address and + * configuration surfaces could neither show nor edit it. + * @param profiles - the currently resolved provider profiles. + * @returns the directory entries in catalog order, declared routes last. + */ +function directoryEntries( + profiles: ReadonlyMap, +): LlmConfigurableProvider[] { + const entries = new Map() + const declare = (provider: string, displayName: string): void => { + entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] }) + } + for (const provider of catalogProviderIds()) declare(provider, provider) + for (const [provider, profile] of profiles) declare(provider, profile.displayName) + return [...entries.values()] +} + /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { let current: () => Config = () => config let lastRaw: Config | undefined - let lastGood: ReadonlyMap | undefined + let memoized: ReadonlyMap | undefined + /** + * The resolved profiles for the current configuration, memoized by the raw + * snapshot's identity — which is also what makes the adapter's own snapshot + * stable across operations that observe no change. + * + * No fallback for an unserviceable snapshot lives here: the section schema + * resolves the whole profile set, so a write that could not be served is + * refused where it is written, and the settings seam keeps a namespace's + * last good value for a stored section that fails. Anything reaching this + * point has already resolved once. + */ const profiles = (): ReadonlyMap => { const raw = current() - if (raw === lastRaw && lastGood !== undefined) return lastGood - try { - const next = resolveProfiles(raw.providers) - lastRaw = raw - lastGood = next - return next - } catch (error) { - // Static composition resolves before anything registers, so this branch - // only sees a live settings snapshot failing catalog or bound checks: - // keep serving the last good profiles and say so once per bad snapshot. - if (lastGood === undefined) throw error - lastRaw = raw - ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section') - ctx.logger.error(error) - return lastGood - } + if (raw === lastRaw && memoized !== undefined) return memoized + const next = resolveProfiles(raw.providers) + lastRaw = raw + memoized = next + return next } profiles() @@ -114,13 +157,46 @@ export function apply(ctx: Context, config: Config): void { const adapter = new PiAiAdapter({ profiles, resolveApiKey }) // The full installed catalog is configurable from the moment the plugin // mounts — dormant or not — so configuration surfaces can offer every - // pi-ai provider before any route exists. - ctx.llm.registerConfigurableProviders(getBuiltinProviders().map(provider => ({ - provider, - displayName: provider, - settingsNs: NS, - settingsPath: ['providers', provider], - }))) + // pi-ai provider before any route exists. Hand-declared routes join it as + // profiles appear, and leave with them. + let directory: DirectoryRegistrationHandle | undefined + let directoryFacts: unknown + const ensureDirectory = (): void => { + const entries = directoryEntries(profiles()) + if (deepEqualJson(entries, directoryFacts)) return + // Atomic replace, never dispose-then-register: a route another adapter + // family already declares (a profile keyed `deepseek-official`) would + // otherwise leave this plugin's whole directory withdrawn and the Models + // page empty. The candidate set is validated first, so a collision keeps + // the previous entries serving and only costs a diagnostic. + if (directory === undefined) { + directory = ctx.llm.registerConfigurableProviders(entries) + } else { + directory.replace(entries) + } + directoryFacts = entries + } + ensureDirectory() + /** + * The credential a named route already resolves, for an interrogation whose + * draft carries none. A route being declared for the first time names no + * profile yet, and a profile that names no credential defers to pi-ai's own + * discovery, so both answer `undefined` and the endpoint is asked + * unauthenticated — the same posture a request to that route would take. + */ + const storedApiKey = async (provider: string | undefined): Promise => { + if (provider === undefined) return undefined + const profile = profiles().get(provider) + if (profile === undefined) return undefined + return resolveApiKey(provider, profile) + } + // Interrogating an endpoint is a configuration-time action over a draft, so + // it is offered for the whole namespace rather than per route: the provider + // a surface is adding does not exist yet. The draft is the whole request + // except the credential: a configuration surface edits a redacted descriptor + // and never holds a stored secret, so an already-configured route supplies + // its own here rather than being interrogated unauthenticated. + ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider))) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. A bare // mount (zero routes) is the dormant posture: nothing registers until a @@ -153,9 +229,37 @@ export function apply(ctx: Context, config: Config): void { ensureRegistrationFacts() installSettingsSection(ctx, NS, Config, config, { + // Refuse an unserviceable section where it is written: without this a + // schema-valid profile the adapter cannot serve would be stored and then + // silently disable every route in this namespace. + validate: assertServiceable, setSource: (source) => { current = source }, - onChange: ensureRegistrationFacts, + onChange: () => { + // Named here rather than left to the settings watcher: `assertServiceable` + // cannot see the llm registry, so a profile claiming a route another + // adapter family owns is stored successfully and only fails at this swap. + // Without its own diagnostic that refusal reaches the operator as a + // generic "settings: watcher failed", naming neither the route nor why it + // is not serving. The previous routes keep serving either way. + try { + ensureRegistrationFacts() + } catch (error) { + ctx.logger.error('llm-pi-ai: keeping the previously registered routes after a refused update') + ctx.logger.error(error) + } + // The directory follows the profiles the registry accepted, so a route + // that failed to register is not advertised as configurable. A refused + // directory swap is contained here for the same reason the registry's + // is: the previous entries keep serving, and `directoryFacts` stays put + // so returning to a working configuration re-applies. + try { + ensureDirectory() + } catch (error) { + ctx.logger.error('llm-pi-ai: keeping the previous configurable-provider directory after a refused update') + ctx.logger.error(error) + } + }, }) } diff --git a/packages/llm/llm-pi-ai/src/provider.ts b/packages/llm/llm-pi-ai/src/provider.ts new file mode 100644 index 0000000000..893199d8fa --- /dev/null +++ b/packages/llm/llm-pi-ai/src/provider.ts @@ -0,0 +1,191 @@ +/** + * Construction of the pi-ai `Provider` that one configured route registers into + * the adapter's `Models` collection. + * + * Two constructions, one decision: a route the installed catalog ships, whose + * profile does not override the wire protocol, **reuses that catalog provider** + * with its models replaced — the catalog provider owns API implementations this + * package cannot reconstruct (Bedrock loads its Smithy module through a + * separate entry point), so rebuilding it from parts would silently narrow + * which providers work. Every other route — one pi-ai has never heard of, or a + * catalog route pointed at a different protocol — is built by `createProvider` + * over the protocol table below. + * + * Credentials never reach this module's storage: the harness resolves a route's + * key through `ctx.credentials` before the request enters pi-ai and hands it + * over as a stream option, which `Models` presents to `resolve()` as the + * credential key. + * + * @module dsh-llm-pi-ai/provider + */ + +import { createProvider } from '@earendil-works/pi-ai' +import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai' +import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy' +import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy' +import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy' +import { catalogProvider } from './catalog.ts' + +/** + * Wire protocols a configured route may name, mapped to pi-ai's lazily loaded + * implementations. Each entry is the factory that pi-ai's matching provider + * factory uses, so a hand-declared route reaches exactly the implementation a + * catalog route would. + * + * The table is deliberately narrow: the protocols a hand-declared route + * actually reaches for today, each completely describable with a key, an + * endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a + * region, Vertex needs a project, a location, and application-default + * credentials, Azure needs provider environment plus an api-version, and Codex + * authenticates through OAuth — none of which this configuration shape can + * express, so offering them would hand back a provider that cannot + * authenticate. The remainder are absent for want of a consumer rather than a + * blocker: each is one line here once a deployment needs it. Catalog routes + * still reach every protocol through their own provider; only an explicit + * override is refused. + */ +const PROTOCOLS: Readonly ProviderStreams>> = { + 'openai-completions': openAICompletionsApi, + 'openai-responses': openAIResponsesApi, + 'anthropic-messages': anthropicMessagesApi, +} + +/** + * Every wire protocol a configured route may name, most-reached first. The + * order is the table's and therefore stable; a configuration surface offering + * a choice presents the first as its default, which is why the protocol a + * hand-declared gateway most often speaks — and the one endpoint interrogation + * can read — leads. + * @returns the supported protocol identifiers. + */ +export function supportedProtocols(): readonly string[] { + return Object.keys(PROTOCOLS) +} + +/** + * Api-key auth for a route the harness authenticates itself. `Models` calls + * this after the adapter has already resolved the route's credential, so a + * missing key here is not this layer's failure: a named-but-unresolvable + * reference has already failed the request with `MISSING_CREDENTIAL`, and a + * route naming no credential at all is deliberately unauthenticated. Reporting + * it as configured hands the decision to the protocol, which is where the + * requirement actually lives — pi-ai's OpenAI-compatible implementation, for + * one, still insists on a key or an `Authorization` header of its own. + * @param name - display name used as the resolution's status label. + * @returns the api-key auth for a harness-authenticated route. + */ +function harnessApiKeyAuth(name: string): ApiKeyAuth { + return { + name, + resolve: ({ credential }) => Promise.resolve({ + auth: credential?.key === undefined ? {} : { apiKey: credential.key }, + source: name, + }), + } +} + +/** The resolved route facts provider construction reads. */ +export interface ProviderSpec { + /** Provider route key; also the `Models` collection key and each model's `provider`. */ + provider: string + /** Display name for selectors and status labels. */ + displayName: string + /** Wire protocol override; absent means each model keeps its catalog protocol. */ + api?: string + /** Endpoint override already applied to {@link models}; kept for provider-level display. */ + baseURL?: string + /** The route's materialized models, in configuration order. */ + models: readonly Model[] + /** + * Whether the profile names a credential — a literal key or a reference. + * Only that decides whether {@link routeAuth} adds the harness's own api-key + * method to a catalog provider that offers none; the key itself still arrives + * per request, never at construction. + */ + namesCredential: boolean +} + +/** + * The auth one route resolves its credential through. + * + * A catalog route keeps the installed provider's own auth, which is what + * preserves provider-native ambient discovery for a profile naming no + * credential. That holds even when the profile repoints the protocol: which + * environment a provider reads is a property of the provider, not of the wire + * format its models speak. + * + * The single addition covers a catalog provider that offers no api-key method + * at all. pi-ai resolves a request's `apiKey` override only when the provider + * declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before + * honouring the override), so an OAuth-only provider — `openai-codex` is the + * one the installed catalog ships — would refuse a profile's explicit key with + * `Provider is not configured` before any request went out. Adding the harness + * method beside the provider's own restores that route. A keyless profile adds + * nothing and still reports the honest refusal, because this adapter resolves + * credentials through its own seam and holds no OAuth store to fall back on. + * @param spec - the resolved route facts. + * @param catalog - the installed catalog provider, when pi-ai ships one. + * @returns the auth to construct this route's provider with. + */ +function routeAuth(spec: ProviderSpec, catalog: Provider | undefined): Provider['auth'] { + if (catalog === undefined) return { apiKey: harnessApiKeyAuth(spec.displayName) } + if (catalog.auth.apiKey !== undefined || !spec.namesCredential) return catalog.auth + return { ...catalog.auth, apiKey: harnessApiKeyAuth(spec.displayName) } +} + +/** + * Reuse an installed catalog provider with this route's models and identity. + * Model dispatch stays with the catalog provider, so its API implementations, + * compatibility quirks, and ambient credential discovery are preserved exactly. + * Catalog-owned dynamic refresh is dropped: this route's catalog is the + * settings document, and a background refresh would contradict it. + */ +function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider { + // Provider-level `baseUrl` is display metadata: pi-ai routes every request + // through `Model.baseUrl`, which model resolution has already overridden. + const baseUrl = spec.baseURL ?? base.baseUrl + return { + id: spec.provider, + name: spec.displayName, + ...baseUrl === undefined ? {} : { baseUrl }, + auth: routeAuth(spec, base), + getModels: () => spec.models, + // Delegated rather than copied: the catalog provider stays the receiver, so + // an implementation holding state on itself keeps working. + stream: (model, context, options) => base.stream(model, context, options), + streamSimple: (model, context, options) => base.streamSimple(model, context, options), + } +} + +/** + * Build the pi-ai provider for one resolved route. + * @param spec - the resolved route facts. + * @returns the provider to register in the adapter's `Models` collection. + * @throws Error when the route names a wire protocol this build cannot serve. + */ +export function buildProvider(spec: ProviderSpec): Provider { + const catalog = catalogProvider(spec.provider) + // A catalog route keeping its catalog protocol reuses the catalog provider; + // an explicit protocol means the deployment is repointing the route at a + // different wire format, which only the protocol table can serve. + if (catalog !== undefined && spec.api === undefined) return reuseCatalogProvider(catalog, spec) + + // Every model on this path carries the route's protocol: model resolution + // requires one for a route the catalog cannot default, and an explicit one + // replaces each catalog model's own. So the route has a single API. + const factory = spec.api === undefined ? undefined : PROTOCOLS[spec.api] + if (factory === undefined) { + throw new Error( + `llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve;` + + ` supported protocols are ${supportedProtocols().join(', ')}`, + ) + } + return createProvider({ + id: spec.provider, + name: spec.displayName, + ...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL }, + auth: routeAuth(spec, catalog), + models: spec.models, + api: factory(), + }) +} diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 6f6e6c7ba6..a9c4335a92 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -129,6 +129,23 @@ describe('PiAiAdapter provider routing', () => { expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('names a route by its displayName, and by its own key once the profiles drop it', () => { + const adapter = adapterOf({ 'acme-gateway': { + apiKey: 'k', + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + models: [{ id: 'acme-large' }], + } }) + expect(adapter.providerInfo('acme-gateway')).toEqual({ id: 'acme-gateway', name: 'Acme Gateway' }) + + // The registry and the profiles can disagree for a moment: a refused + // registration swap leaves the previous routes serving while resolution + // has already moved on, so a selector may ask about a route the current + // profiles no longer describe. It gets the key rather than nothing. + expect(adapter.providerInfo('departed')).toEqual({ id: 'departed', name: 'departed' }) + }) + it('reports unsupported stop sequences rather than silently ignoring them', async () => { const server = await mockServer([]) const ctx = await harness(server.url) @@ -339,12 +356,11 @@ describe('provider profile lifecycle', () => { ReasoningEffortId('xhigh'), ReasoningEffortId('max'), ]) - await expect(ctx.llm.resolveModelInfo('openai', 'gpt-4.1')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], - }, - }) + // A catalog model without reasoning is the same case as a hand-declared + // one: pi-ai reports the single level `off`, which translates to omitting + // the reasoning option — exactly what naming no effort already does. The + // capability is reported unavailable rather than offering that control. + expect((await ctx.llm.resolveModelInfo('openai', 'gpt-4.1')).reasoning).toBeUndefined() }) it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => { @@ -406,12 +422,14 @@ describe('provider profile lifecycle', () => { expect(server.requests).toHaveLength(0) }) - it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => { + it('validates empty, underspecified, legacy-shaped, and explicitly blank profiles', () => { // Empty and omitted dicts are the dormant zero-route posture, not errors. expect(resolveProfiles({}).size).toBe(0) expect(resolveProfiles(undefined).size).toBe(0) expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/) - expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/) + // A route the installed catalog does not ship is allowed, but it has no + // defaults to fall back on: it must describe its own models. + expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/resolves no models/) // The pre-release array shape and its per-profile provider field fail // loud with migration directions instead of half-working. expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts new file mode 100644 index 0000000000..49805b65b3 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -0,0 +1,576 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import SettingsLocal from '@deepseek-ai/dsh-settings-local' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' +import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' +import { createModels } from '@earendil-works/pi-ai' +import type { Api, Model, Provider } from '@earendil-works/pi-ai' +import { resolveProfiles } from '../src/config.ts' +import { buildProvider, supportedProtocols } from '../src/provider.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +const homes: string[] = [] + +afterEach(async () => { + await closeMockServers() + await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +/** A throwaway $DSH_HOME with an empty settings document. */ +async function home(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-catalog-')) + homes.push(dir) + await writeFile(join(dir, 'settings.yaml'), '') + return dir +} + +/** The dormant composition plus a real settings service, as the product mounts it. */ +async function bootWithSettings(dir: string, config: LlmPiAi.Config): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false }) + await ctx.plugin(LlmPiAi, config) + return ctx +} + +/** A complete hand-declared route: nothing about it exists in pi-ai's catalog. */ +function gateway(baseURL: string, overrides: Record = {}): LlmPiAi.Config { + return { + providers: { + 'acme-gateway': { + apiKey: 'gw-key', + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL, + models: [{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }], + ...overrides, + }, + }, + } +} + +async function harness(config: LlmPiAi.Config): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, config) + return ctx +} + +describe('hand-declared providers', () => { + it('serves a route pi-ai has never heard of from its own declaration', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(gateway(`${server.url}/v1`)) + + const result = await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-large', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }) + + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(server.paths).toEqual(['/v1/chat/completions']) + expect(server.headers[0]?.authorization).toBe('Bearer gw-key') + }) + + it('lists and resolves the declared models rather than a catalog', async () => { + const server = await mockServer([]) + const ctx = await harness(gateway(`${server.url}/v1`)) + + expect(await ctx.llm.listModels('acme-gateway')).toEqual([ + { provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large' }, + ]) + const info = await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large') + expect(info).toMatchObject({ + provider: 'acme-gateway', + id: 'acme-large', + name: 'Acme Large', + context: { contextWindow: 65_536 }, + defaultMaxTokens: 4096, + }) + }) + + it('offers no reasoning control it could not honour', async () => { + const server = await mockServer([]) + const ctx = await harness(gateway(`${server.url}/v1`)) + + // pi-ai reports a model with no reasoning metadata as supporting the single + // level `off`, but `off` is translated to *omitting* the reasoning option — + // byte-for-byte the same request as naming no effort — so a provider whose + // own default is to think would keep thinking with `off` selected. The + // capability is reported unavailable instead of offering that control. + expect((await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).reasoning).toBeUndefined() + + // A catalog route is unaffected: its models carry the metadata that makes + // `off` actually disable thinking. + const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } }) + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id)) + .toContain('off') + }) + + it('joins the configurable-provider directory so a settings surface can reach it', async () => { + const server = await mockServer([]) + const ctx = await harness(gateway(`${server.url}/v1`)) + + expect(ctx.llm.listConfigurableProviders()).toContainEqual({ + provider: 'acme-gateway', + displayName: 'Acme Gateway', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'acme-gateway'], + }) + }) + + it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => { + const resolved = resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + // A listing endpoint that discloses nothing but ids still yields a + // serviceable route. + models: [{ id: 'bare' }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }], + }, + 'tuned-gateway': { + api: 'openai-completions', + baseURL: 'https://tuned.test', + defaultContextWindow: 4096, + defaultMaxTokens: 256, + models: [{ id: 'bare' }], + }, + }) + const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] => + resolved.get(route)?.piProvider.getModels() ?? [] + + expect(modelsOf('acme-gateway')).toMatchObject([ + { id: 'bare', contextWindow: 262_144, maxTokens: 32_768 }, + { id: 'sized', contextWindow: 8192, maxTokens: 512 }, + ]) + // The fallback is a guess, so a deployment whose gateway serves smaller + // models corrects it once for the whole route. + expect(modelsOf('tuned-gateway')).toMatchObject([{ id: 'bare', contextWindow: 4096, maxTokens: 256 }]) + // Only an explicitly configured cap is a request default; a fallback is + // the model's capability and stops there. + expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('bare')).toBeUndefined() + expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512) + }) + + it('rejects a model the route cannot identify', () => { + const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) => + () => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } }) + + expect(declare({ id: '' })).toThrow(/empty id/) + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'dup', contextWindow: 1, maxTokens: 1 }, { id: 'dup', contextWindow: 2, maxTokens: 2 }], + }, + })).toThrow(/more than once/) + }) + + it('rejects a declaration that names no wire protocol or endpoint', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] }, + })).toThrow(/needs an api/) + expect(() => resolveProfiles({ + 'acme-gateway': { api: 'openai-completions', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] }, + })).toThrow(/needs a baseURL/) + }) + + it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])( + 'refuses %s, whose authentication a profile cannot express', + (api) => { + // These need SigV4 credentials and a region, a project plus ADC, provider + // environment and an api-version, or OAuth — none of which a key, an + // endpoint, and headers can carry, so a route naming one would be built + // unable to authenticate. + expect(supportedProtocols()).not.toContain(api) + expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [], namesCredential: true })) + .toThrow(/cannot serve; supported protocols are/) + }, + ) + + it('rejects a protocol this build cannot serve, and a route that names none', () => { + const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [], namesCredential: true } + expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' })) + .toThrow(/cannot serve; supported protocols are/) + expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/) + }) + + it('leaves an unauthenticated route to its protocol rather than inventing a credential', async () => { + const server = await mockServer([{ events: textEvents }]) + // Naming no credential is the deliberately unauthenticated posture — a + // named reference that resolved to nothing would have failed with + // MISSING_CREDENTIAL long before this point. The route resolves as + // configured and the protocol decides: pi-ai's OpenAI-compatible + // implementation wants a key or an Authorization header of its own, and + // says so instead of the harness guessing a placeholder. + const ctx = await harness({ + providers: { + 'local-llm': { + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }], + }, + }, + }) + + const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { message: 'No API key for provider: local-llm' }, + }) + expect(server.requests).toHaveLength(0) + }) + + it('authenticates an unauthenticated route through a configured header', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness({ + providers: { + 'local-llm': { + api: 'openai-completions', + baseURL: `${server.url}/v1`, + headers: { Authorization: 'Bearer local' }, + models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }], + }, + }, + }) + + const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] }) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(server.headers[0]?.authorization).toBe('Bearer local') + }) + + it('rejects a capacity that is not a positive integer', () => { + const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) => + () => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } }) + + expect(declare({ id: 'm', contextWindow: 0, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/) + expect(declare({ id: 'm', contextWindow: 1.5, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/) + expect(declare({ id: 'm', contextWindow: 1, maxTokens: 0 })).toThrow(/maxTokens must be a positive integer/) + expect(declare({ id: 'm', contextWindow: 1, maxTokens: 1.5 })).toThrow(/maxTokens must be a positive integer/) + }) + + it('names the route key when no displayName is configured', () => { + const resolved = resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }], + }, + }) + expect(resolved.get('acme-gateway')?.displayName).toBe('acme-gateway') + expect(() => resolveProfiles({ 'acme-gateway': { displayName: '' } })).toThrow(/empty displayName/) + }) +}) + +describe('catalog routes with per-model configuration', () => { + it('serves the installed catalog untouched when the profile lists no models', async () => { + const server = await mockServer([]) + const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } }) + + const listed = await ctx.llm.listModels('deepseek') + expect(listed.map(model => model.id).sort()) + .toEqual(getBuiltinModels('deepseek').map(model => model.id).sort()) + }) + + it('overrides one catalog model field and defaults the rest from the catalog', async () => { + const server = await mockServer([]) + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + const ctx = await harness({ + providers: { + deepseek: { + apiKey: 'k', + baseURL: server.url, + models: [{ id: catalogModel.id, contextWindow: 4096 }], + }, + }, + }) + + const info = await ctx.llm.resolveModelInfo('deepseek', catalogModel.id) + // The configured field wins and the name still comes from the catalog. The + // catalog's own output cap is the model's capability, not a cap anyone + // chose, so it must not arrive as the request default. + expect(info.context).toEqual({ contextWindow: 4096 }) + expect(info.name).toBe(catalogModel.name) + expect(info.defaultMaxTokens).toBeUndefined() + // An explicit list replaces the catalog rather than adding to it. + expect((await ctx.llm.listModels('deepseek')).map(model => model.id)).toEqual([catalogModel.id]) + }) + + it('materializes a request default only from a configured output cap', async () => { + const server = await mockServer([]) + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + const ctx = await harness({ + providers: { + deepseek: { + apiKey: 'k', + baseURL: server.url, + models: [{ id: catalogModel.id, maxTokens: 4096 }], + }, + }, + }) + + // Configuring the cap is the deployment choosing one, so it becomes the + // default the seam materializes into requests that name none. + expect((await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)).defaultMaxTokens).toBe(4096) + }) + + it('adds a model the installed catalog does not describe to a catalog route', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness({ + providers: { + deepseek: { + apiKey: 'k', + baseURL: `${server.url}/v1`, + models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }], + }, + }, + }) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-preview', messages: [] }) + expect(result.finish).toEqual({ kind: 'stop' }) + // The catalog route keeps its catalog protocol, so the new model reaches + // the same endpoint shape the shipped models use. + expect(server.paths).toEqual(['/v1/chat/completions']) + }) + + it('fails an unconfigured model id before any provider request', async () => { + const server = await mockServer([]) + const ctx = await harness({ + providers: { + deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] }, + }, + }) + + const result = await assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] }) + + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } }) + expect(server.requests).toHaveLength(0) + }) + + it('preserves catalog-only model metadata the profile cannot express', () => { + // Some catalog models carry provider-required request headers; overriding a + // capacity must not drop them, because configuration has no way to restate + // them. + const headered = (getBuiltinModels('nvidia') as { id: string; headers?: unknown }[]) + .find(model => model.headers !== undefined) + if (headered === undefined) throw new Error('the installed catalog ships no nvidia model with headers') + + const resolved = resolveProfiles({ + nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] }, + }) + const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? [] + expect(model?.headers).toEqual(headered.headers) + expect(model?.contextWindow).toBe(4096) + }) + + it('delegates both stream methods back to the reused catalog provider', async () => { + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + const built = resolved.get('deepseek')?.piProvider + if (built === undefined) throw new Error('the deepseek route built no provider') + const [model] = built.getModels() + if (model === undefined) throw new Error('the deepseek route resolved no models') + const context = { messages: [{ role: 'user' as const, content: 'hi', timestamp: 0 }] } + + // `stream` is interface-required and unused by the harness adapter, which + // only calls `streamSimple`; both must still reach the catalog provider. + for await (const _event of built.stream(model, context, { apiKey: 'k' })) { /* drain */ } + for await (const _event of built.streamSimple(model, context, { apiKey: 'k' })) { /* drain */ } + + expect(server.paths).toEqual(['/v1/chat/completions', '/v1/chat/completions']) + }) + + it('keeps each model its own endpoint when the catalog route declares none', () => { + // `opencode` ships no provider-level endpoint: the address lives on every + // catalog model, so the route resolves without any configured baseURL. + const resolved = resolveProfiles({ opencode: {} }) + const models = resolved.get('opencode')?.piProvider.getModels() ?? [] + expect(models.length).toBeGreaterThan(0) + expect(models.every(model => model.baseUrl.length > 0)).toBe(true) + expect(resolved.get('opencode')?.piProvider.baseUrl).toBeUndefined() + }) + + it('repoints a catalog route at another wire protocol without restating its endpoint', () => { + const resolved = resolveProfiles({ openai: { api: 'openai-completions' } }) + const models = resolved.get('openai')?.piProvider.getModels() ?? [] + // The protocol changes for the whole route; each model keeps the catalog + // endpoint it already had. + expect(models.every(model => model.api === 'openai-completions')).toBe(true) + expect(models.every(model => model.baseUrl === 'https://api.openai.com/v1')).toBe(true) + }) + + it('repoints a catalog route at another wire protocol', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness({ + providers: { + // openai's catalog models speak the Responses API; naming the protocol + // explicitly moves the whole route onto Chat Completions. + openai: { + apiKey: 'k', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }], + }, + }, + }) + + await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] }) + expect(server.paths).toEqual(['/v1/chat/completions']) + }) + + it('keeps the catalog provider’s own auth when the route repoints its protocol', () => { + // Which environment a provider reads is a property of the provider, not of + // the wire format its models speak: naming an api must not cost a profile + // its provider-native discovery. + const resolved = resolveProfiles({ openai: { api: 'openai-completions' } }) + expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key') + }) + + it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => { + // pi-ai honours a request's `apiKey` override only when the provider + // declares an api-key method. `openai-codex` ships OAuth alone, so without + // the harness method beside it the route refuses its own configured key as + // `Provider is not configured` before any request goes out. + const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } }) + const provider = resolved.get('openai-codex')?.piProvider + expect(provider?.auth.oauth).toBeDefined() + const models = createModels() + models.setProvider(provider as Provider) + const model = provider?.getModels()[0] as Model + const auth = await models.getAuth(model, { apiKey: 'codex-token' }) + expect(auth?.auth.apiKey).toBe('codex-token') + }) + + it('leaves an OAuth-only catalog route unconfigured when its profile names no key', () => { + // Nothing to add: this adapter resolves credentials through its own seam + // and holds no OAuth store, so declaring the provider configured would + // trade a truthful refusal for an endpoint's 401. + const resolved = resolveProfiles({ 'openai-codex': {} }) + expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined() + }) +}) + +describe('resolution snapshots', () => { + it('finishes an in-flight request under the configuration it started with', async () => { + const server = await mockServer([{ events: textEvents }]) + let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + let release: () => void = () => {} + const held = new Promise((resolve) => { release = resolve }) + const adapter = new PiAiAdapter({ + profiles: () => current, + // Credential resolution is the real await inside a stream call, and the + // window a configuration change has to land in. + resolveApiKey: async () => { await held; return 'k' }, + }) + + const chunks: StreamChunk[] = [] + const inFlight = (async () => { + for await (const chunk of adapter.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [], + })) chunks.push(chunk) + })() + + // The route set changes while the request waits, and something else reads + // the adapter meanwhile, which is what would rebuild a shared collection. + current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } }) + await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0) + release() + await inFlight + + // The in-flight request keeps its own snapshot: it reaches the endpoint it + // resolved against instead of failing on a provider that no longer exists. + expect(chunks.at(-1)).toMatchObject({ type: 'finish', reason: { kind: 'stop' } }) + expect(server.paths).toEqual(['/v1/chat/completions']) + }) + + it('serves the next request from the new configuration', async () => { + const first = await mockServer([{ events: textEvents }]) + const second = await mockServer([{ events: textEvents }]) + let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } }) + const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') }) + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ + provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], + })) { /* drain */ } + } + + await drain() + current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } }) + await drain() + + expect(first.paths).toHaveLength(1) + expect(second.paths).toHaveLength(1) + }) +}) + +describe('configurable-provider directory', () => { + it('keeps the previous directory when a route collides with another adapter family', async () => { + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + // Another adapter family owns this route id, exactly as llm-deepseek does. + ctx.llm.registerConfigurableProviders([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + ]) + const before = ctx.llm.listConfigurableProviders().length + expect(before).toBeGreaterThan(30) + + await ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'deepseek-official': { + apiKey: 'k', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }], + }, + }, + }) + + // The refused swap costs a diagnostic, not the directory: every entry the + // page needs is still declared. + expect(ctx.llm.listConfigurableProviders()).toHaveLength(before) + expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'deepseek-official')?.settingsNs) + .toBe('llm-deepseek') + }) + + it('replaces its entries atomically as declared routes come and go', async () => { + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + const catalogOnly = ctx.llm.listConfigurableProviders().length + + await ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + apiKey: 'k', + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }], + }, + }, + }) + expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly + 1) + expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'acme-gateway')?.displayName) + .toBe('Acme Gateway') + + await ctx.settings.replace(settingsNamespace('llm-pi-ai'), {}) + expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts new file mode 100644 index 0000000000..916700fbbf --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -0,0 +1,313 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' +import { discoverModels } from '../src/discovery.ts' + +const servers: Server[] = [] +/** Credential variables a test set, cleared so the next one starts unset. */ +const touchedEnv: string[] = [] + +afterEach(async () => { + for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name) + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) +}) + +interface ListingServer { + url: string + paths: string[] + headers: IncomingMessage['headers'][] +} + +/** + * A stand-in provider that answers one scripted `GET /models`. `chunks` writes + * without a declared length, which is how a real streamed reply arrives. + */ +async function listingServer(behavior: { + status?: number + body?: string + chunks?: string[] + holdOpenMs?: number +}): Promise { + const paths: string[] = [] + const headers: IncomingMessage['headers'][] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + paths.push(request.url ?? '') + headers.push(request.headers) + if (behavior.chunks !== undefined) { + // No declared length: the ceiling has to hold on what is read. + response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' }) + for (const chunk of behavior.chunks) response.write(chunk) + if (behavior.holdOpenMs === undefined) { response.end(); return } + // Left open so a caller's cancellation lands while the body is still + // being read rather than after it completed. + setTimeout(() => { response.end() }, behavior.holdOpenMs) + return + } + const body = behavior.body ?? '{}' + response.writeHead(behavior.status ?? 200, { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(body)), + }) + response.end(body) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}`, paths, headers } +} + +/** A bare dormant mount: discovery is offered whether or not a route exists. */ +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, {}) + return ctx +} + +describe('catalog-route model discovery', () => { + it('answers from the installed registry, with capacities and no network call', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'from-the-endpoint' }] }) }) + const ctx = await harness() + + const models = await ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek', baseURL: server.url }) + + // pi-ai's own registry is the authority for its own providers, and it + // carries what a listing endpoint would not disclose. + expect(models.map(model => model.id).sort()) + .toEqual(getBuiltinModels('deepseek').map(model => model.id).sort()) + expect(models.every(model => (model.contextWindow ?? 0) > 0 && (model.maxTokens ?? 0) > 0)).toBe(true) + expect(server.paths).toEqual([]) + }) + + it('needs no endpoint for a route the catalog describes', async () => { + const ctx = await harness() + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0) + }) + + it('says where a route the catalog does not describe must get its models', async () => { + const ctx = await harness() + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway' })) + .rejects.toThrow(/ships no catalog for provider "acme-gateway".*set a baseURL/s) + // A form that cleared the field says the same thing as one that never had it. + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: '' })) + .rejects.toThrow(/set a baseURL/) + // The seam refuses a request naming neither, so the module's own guard for + // that shape is only reachable by calling it directly. + await expect(discoverModels({})).rejects.toThrow(/set a baseURL/) + }) +}) + +describe('draft-provider model discovery', () => { + it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => { + const server = await listingServer({ + body: JSON.stringify({ + data: [ + { id: 'acme-large', display_name: 'Acme Large', context_length: 65_536, max_output_tokens: 4096 }, + { id: 'acme-small' }, + ], + }), + }) + const ctx = await harness() + + const models = await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/v1`, apiKey: 'probe-key' }) + + expect(models).toEqual([ + { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, + { id: 'acme-small' }, + ]) + expect(server.paths).toEqual(['/v1/models']) + expect(server.headers[0]?.authorization).toBe('Bearer probe-key') + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) + }) + + it('keeps a deployment path instead of resolving it away', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = await harness() + + await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/openai/v1/` }) + + expect(server.paths).toEqual(['/openai/v1/models']) + }) + + it('offers no credential when the draft names none', async () => { + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = await harness() + + await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }) + + expect(server.headers[0]?.authorization).toBeUndefined() + }) + + it('authenticates a configured route the draft cannot supply a key for', async () => { + // What the Models page actually sends after a key is saved: the form holds + // the redacted descriptor, so the draft names the route and the endpoint + // and no credential at all. Interrogating unauthenticated would answer 401 + // and read as a wrong key. + const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) + const ctx = new Context() + await ctx.plugin(LlmService) + process.env['ACME_GATEWAY_KEY'] = 'stored-key' + touchedEnv.push('ACME_GATEWAY_KEY') + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'ACME_GATEWAY_KEY', + api: 'openai-completions', + baseURL: server.url, + models: [{ id: 'acme-large' }], + }, + }, + }) + + await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url }) + // A key typed into the form is the one being tested — possibly the + // replacement for the stored one — so it wins. + await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url, apiKey: 'typed' }) + // A route no profile declares yet is the create case: nothing is stored. + await ctx.llm.discoverModels('llm-pi-ai', { provider: 'not-declared-yet', baseURL: server.url }) + + expect(server.headers.map(headers => headers.authorization)) + .toEqual(['Bearer stored-key', 'Bearer typed', undefined]) + }) + + it('leaves a catalog route\'s credential unresolved, having never reached the network', async () => { + // The catalog answers before any endpoint is asked, so a route whose + // profile names a credential that is not set must still answer rather than + // failing over a key the interrogation never needed. + const ctx = new Context() + await ctx.plugin(LlmService) + Reflect.deleteProperty(process.env, 'ABSENT_FOR_DISCOVERY') + await ctx.plugin(LlmPiAi, { providers: { deepseek: { apiKeyEnv: 'ABSENT_FOR_DISCOVERY' } } }) + + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0) + }) + + it('drops unusable rows rather than failing the whole listing', async () => { + const server = await listingServer({ + body: JSON.stringify({ + data: [ + { id: 'good' }, + { id: '' }, + { name: 'no id at all' }, + null, + { id: 'good' }, + { id: 'zero-capacity', context_length: 0, max_tokens: -1 }, + ], + }), + }) + const ctx = await harness() + + expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })) + .toEqual([{ id: 'good' }, { id: 'zero-capacity' }]) + }) + + it('points at the credential for a rejected one, and only then', async () => { + const ctx = await harness() + + for (const status of [401, 403]) { + const refused = await listingServer({ status, body: '{"error":"nope"}' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: refused.url, apiKey: 'wrong' })) + .rejects.toThrow(new RegExp(`answered ${status}; check the API key`)) + } + + // A server fault is not a credential problem, so it must not send the user + // off to re-check a key that is fine. + const broken = await listingServer({ status: 500, body: '{"error":"boom"}' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url, apiKey: 'fine' })) + .rejects.toThrow(/answered 500$/) + }) + + it('reports a reply that is not a model listing', async () => { + const server = await listingServer({ body: '{"models":[]}' }) + const ctx = await harness() + + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })) + .rejects.toThrow(/no "data" array; enter this provider's models by hand/) + + const broken = await listingServer({ body: 'not json at all' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url })) + .rejects.toThrow(/did not answer with JSON/) + }) + + it('refuses an oversized reply, whether its length is declared or streamed', async () => { + const ctx = await harness() + // Just over the four-megabyte ceiling, as one padded model row. + const oversized = `{"data":[{"id":"m","pad":"${'x'.repeat(4 * 1024 * 1024)}"}]}` + + const declared = await listingServer({ body: oversized }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: declared.url })) + .rejects.toThrow(/answered with more than 4194304 bytes/) + + // A streamed reply declares no length, so the ceiling has to hold on the + // body the harness actually read. + const streamed = await listingServer({ chunks: ['{"data":[{"id":"m","pad":"', 'x'.repeat(4 * 1024 * 1024), '"}]}'] }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: streamed.url })) + .rejects.toThrow(/answered with more than 4194304 bytes/) + }) + + it('reports an unreachable endpoint instead of an empty catalog', async () => { + const ctx = await harness() + // Port 9 is the discard service: nothing accepts a connection there. + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'http://127.0.0.1:9/v1' })) + .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) + }) + + it.each(['anthropic-messages', 'azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])( + 'says it cannot interrogate %s rather than guessing a shape', + async (api) => { + // Azure authenticates with an `api-key` header and an `api-version` + // query despite its OpenAI lineage, and Codex uses OAuth; guessing at + // either would report an auth failure as a provider with no models. + const ctx = await harness() + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'https://gateway.example/v1', api })) + .rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' }) + }, + ) + + it('reports cancellation during the body read as an abort, not a raw reason', async () => { + const ctx = await harness() + const controller = new AbortController() + // Chunked, so the headers arrive and the cancellation lands mid-body. + const slow = await listingServer({ chunks: ['{"data":[', '{"id":"a"}'], holdOpenMs: 400 }) + const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: slow.url, signal: controller.signal }) + setTimeout(() => { controller.abort('test cancellation') }, 40) + + await expect(probe).rejects.toMatchObject({ code: 'ABORTED' }) + }) + + it('honors caller cancellation', async () => { + const ctx = await harness() + const aborted = AbortSignal.abort('test cancellation') + await expect(ctx.llm.discoverModels('llm-pi-ai', { + baseURL: 'http://127.0.0.1:9/v1', + signal: aborted, + })).rejects.toMatchObject({ code: 'ABORTED' }) + }) + + it('is offered for the namespace, and refuses one it does not serve', async () => { + const ctx = await harness() + + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0) + await expect(ctx.llm.discoverModels('llm-deepseek', { baseURL: 'https://api.deepseek.com' })) + .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: '' })) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + }) + + it('withdraws the offer when the plugin unloads', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const fiber = await ctx.plugin(LlmPiAi, {}) + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })).resolves.not.toHaveLength(0) + + await fiber.dispose() + + await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'openai' })) + .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 94c81b8cae..3416dc15dc 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -146,13 +146,16 @@ describe('request-level dynamic profiles', () => { expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) }) - it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => { + it('refuses a settings write this adapter could not serve, leaving its routes alone', async () => { const dir = await home() const ctx = await boot(dir, { providers: { openai: {} } }) - // Schema-valid but catalog-invalid: the resolver rejects it and the - // last good route set keeps serving. - await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } }) + // Shape-valid but unserviceable: a route the catalog does not ship and + // that lists no models of its own. The section schema resolves the whole + // profile set, so this is refused where it is written rather than stored + // and then quietly disabling every route in the namespace. + await expect(ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })) + .rejects.toThrow(/resolves no models/) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) }) diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 3f12ef4460..86c646cba6 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -1,41 +1,74 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' const streamSimple = vi.hoisted(() => vi.fn()) -// The 0.81 SDK moved `streamSimple` to the compat entry; the adapter imports it -// from there, so the mock must target the same specifier. -vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, streamSimple } -}) +// A hand-declared route is built by `createProvider` over the protocol table in +// `src/provider.ts`, so the table's lazy api module is the SDK boundary this +// test can observe. A catalog route dispatches through pi-ai's own provider and +// would not see this mock. +vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({ + openAICompletionsApi: () => ({ stream: streamSimple, streamSimple }), +})) import { PiAiAdapter } from '../src/adapter.ts' import { resolveProfiles } from '../src/config.ts' afterEach(() => { streamSimple.mockReset() }) +/** A hand-declared OpenAI-compatible route with one fully described model. */ +function gatewayAdapter(): PiAiAdapter { + return new PiAiAdapter({ + profiles: () => resolveProfiles({ + 'local-gateway': { + apiKey: 'test-key', + api: 'openai-completions', + baseURL: 'http://127.0.0.1:9/v1', + models: [{ id: 'local-model', contextWindow: 8192, maxTokens: 1024 }], + }, + }), + resolveApiKey: () => Promise.resolve('test-key'), + }) +} + +async function drain(adapter: PiAiAdapter): Promise { + const chunks: StreamChunk[] = [] + for await (const chunk of adapter.stream({ + provider: 'local-gateway', + model: 'local-model', + messages: [], + })) chunks.push(chunk) + return chunks +} + describe('pi-ai SDK retry boundary', () => { it('pins one SDK attempt even when the installed provider currently defaults to zero retries', async () => { - const failure = new Error('mock SDK boundary') - streamSimple.mockReturnValue({ - async * [Symbol.asyncIterator](): AsyncGenerator { - throw failure - }, - }) - const adapter = new PiAiAdapter({ - profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }), - resolveApiKey: () => Promise.resolve('test-key'), - }) - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ - provider: 'openai', - model: 'gpt-4.1', - messages: [], - })) { /* drain */ } - } + streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') }) + + const chunks = await drain(gatewayAdapter()) - await expect(drain()).rejects.toBe(failure) expect(streamSimple).toHaveBeenCalledOnce() - expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0 }) + expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0, apiKey: 'test-key' }) + // pi-ai reports a setup failure as a terminal in-stream error rather than + // throwing, which the converter turns into the harness error finish. + expect(chunks.at(-1)).toMatchObject({ + type: 'finish', + reason: { kind: 'error', failure: { message: 'mock SDK boundary' } }, + }) + }) + + it('dispatches a hand-declared route to the endpoint and model its configuration describes', async () => { + streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') }) + + await drain(gatewayAdapter()) + + expect(streamSimple.mock.calls[0]?.[0]).toMatchObject({ + id: 'local-model', + provider: 'local-gateway', + api: 'openai-completions', + baseUrl: 'http://127.0.0.1:9/v1', + contextWindow: 8192, + maxTokens: 1024, + }) }) }) diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index fd756a39cc..620e367742 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -5,9 +5,9 @@ * @module @deepseek-ai/dsh-llm-retry */ -import type { Context } from 'cordis' +import type { Context, Events } from 'cordis' import z from 'schemastery' -import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent' +import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -172,12 +172,9 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } async function recover( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters[0], next: () => Promise, ): Promise { - const { turn, step, provider, failure, retryPolicy: policy } = context if (policy === undefined) return next() if (policy.mode === 'always') { if (signal.aborted || lifetime.signal.aborted) return @@ -228,16 +225,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const disposeListener = ctx.on('agent/request-error', ( - agent: Agent, - context: RequestFailureContext, - signal: AbortSignal, + payload, next: () => Promise, ) => { // A waterfall may have captured this callback before its registration was // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve(undefined) - return track(recover(agent, context, signal, next)) + return track(recover(payload, next)) }) ctx.effect(() => async () => { diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index d1500fa781..ac0ed687fa 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -506,7 +506,7 @@ describe('provider-routed retry policy', () => { ;({ ctx: context } = await harness(adapter, { other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'other', })) @@ -543,7 +543,7 @@ describe('provider-routed retry policy', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1 }, }), }, (ctx) => { - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: adapter.requests.length === 0 ? 'mock' : 'other', })) @@ -881,7 +881,7 @@ describe('provider-routed retry policy', () => { context = mounted.ctx const downstream = Promise.withResolvers() const entered = Promise.withResolvers() - context.on('agent/request-error', (agent) => { + context.on('agent/request-error', ({ agent }) => { agent.cancel({ kind: 'user' }) entered.resolve(undefined) return downstream.promise @@ -917,7 +917,7 @@ describe('provider-routed retry policy', () => { const captured = Promise.withResolvers() let invokeCaptured: (() => Promise) | undefined const mounted = await harness(adapter, {}, (ctx) => { - ctx.on('agent/request-error', (_agent, _context, _signal, next) => { + ctx.on('agent/request-error', (_payload, next) => { return new Promise((resolve) => { invokeCaptured = async () => { resolve(await next()) } captured.resolve(undefined) @@ -926,7 +926,7 @@ describe('provider-routed retry policy', () => { }) context = mounted.ctx let downstreamCalls = 0 - context.on('agent/request-error', async (_agent, _context, _signal, next) => { + context.on('agent/request-error', async (_payload, next) => { downstreamCalls += 1 return next() }) @@ -980,7 +980,7 @@ describe('provider-routed retry policy', () => { textResponse('must not run'), ]) ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { - ctx.on('agent/request-error', async (agent, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent }, next) => { agent.cancel({ kind: 'user' }) return next() }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index ef759b6993..5e4daa179b 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/README.md -README.md: 5d74ed647f4de3c8ed65554dff736eb8aec9eef9 -README.zh.md: a362ba8b825238325ce70238c5b2f3725f0d8495 +README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd +README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 5d74ed647f..ca34ffdeaa 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -12,8 +12,11 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. -- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber. +- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` Declare provider routes an adapter plugin can activate through configuration — registered or dormant — each naming its owning settings namespace and the path to its profile inside that section. All-or-nothing (`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`), disposed with the calling fiber. The handle also carries `replace(entries)`: the candidate set is validated in full before anything moves, so an entry another registration already declares leaves the current set intact, and an empty array is legal there. A plugin whose declared set follows its configuration must use `replace` rather than disposing and re-registering — the latter strands the directory empty whenever the new set is refused. - `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` List the declared directory in declaration order; configuration surfaces merge it with `listProviders()` to mark each entry live or dormant. +- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` Offer to interrogate provider endpoints for the settings namespace this plugin owns. One offer per namespace (`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`), disposed with the calling fiber. +- `ctx.llm.listModelDiscoveryNamespaces(): string[]` List the namespaces that can interrogate an endpoint, so a surface offers the action only where it works. +- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` Ask one endpoint which models it advertises. - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context, output-default, and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. @@ -23,6 +26,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa `LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy. +Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route should answer from its own knowledge — better metadata, no network call — which is why `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`. + Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. Every topology commit point — adapter routes registering or disposing, directory entries appearing or withdrawing — emits the payload-free `llm/adapters-updated` event after the mutation, so consumers re-read `listProviders()`/`listModels()`/`listConfigurableProviders()` instead of polling. Observer failures are contained (logged, non-vetoing); only `INVARIANT`-coded failures rethrow after the fan-out. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index a362ba8b82..1f95d3cd64 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -12,8 +12,11 @@ - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。 - `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。 -- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。 +- `ctx.llm.registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle` 声明适配器插件可通过配置激活的提供方路由——无论已注册还是休眠——每个条目指明其所属 settings namespace,以及 profile 在该分节内的路径。要么全部成功,要么全部不生效(`INVALID_DIRECTORY`/`DUPLICATE_DIRECTORY`),并随调用 fiber dispose。该句柄还带 `replace(entries)`:候选集合会先被整体校验,因此其中若有条目已被另一个注册声明,当前集合原封不动;此处允许传空数组。声明集合随配置变化的插件必须使用 `replace`,而不是先 dispose 再重新注册——后者会在新集合被拒时让目录整个落空。 - `ctx.llm.listConfigurableProviders(): LlmConfigurableProvider[]` 按声明顺序列出已声明的目录;配置界面将其与 `listProviders()` 合并,为每个条目标注存活或休眠。 +- `ctx.llm.registerModelDiscovery(settingsNs: string, discover): () => void` 为本插件拥有的 settings namespace 提供「询问提供方端点」的能力。每个 namespace 只能有一个(`INVALID_DISCOVERY`/`DUPLICATE_DISCOVERY`),并随调用 fiber dispose。 +- `ctx.llm.listModelDiscoveryNamespaces(): string[]` 列出可以询问端点的 namespace,让界面只在可用之处提供该动作。 +- `ctx.llm.discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise` 询问某个端点它公布了哪些模型。 - `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。 - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份,以及可用上下文、输出默认值和推理(reasoning)元数据;异步适配器可选地支持取消。 @@ -23,6 +26,8 @@ `LlmService` 将最终适配器选择、同步 dispatch、iterator 构造与迭代中的失败规范化为流协议唯一的终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分增量输出后发生失败时,内容块可能仍未闭合;消费方会丢弃这些不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出,因为它们属于插件或消费方失败,而非模型请求结果。已准备调用会暴露随其确切适配器注册一同捕获的不可变重试策略;完全由 middleware 处理的路由没有服务策略。 +询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器应当用自己的知识作答——元数据更好,且无需联网——这正是 `baseURL` 可选、两者必居其一的原因。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。 + 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 每个拓扑提交点——适配器路由注册或 dispose、目录条目出现或撤回——都会在变更之后发出无载荷的 `llm/adapters-updated` 事件,消费方因此重读 `listProviders()`/`listModels()`/`listConfigurableProviders()` 而非轮询。观察者故障会被隔离(记录日志、不否决);只有带 `INVARIANT` 码的故障会在扇出后重新抛出。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 59932f676c..74ca171f64 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -10,8 +10,10 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, LlmConfigurableProvider, + LlmDiscoveredModel, LlmFailure, LlmModelContext, + LlmModelDiscoveryRequest, LlmModelInfo, LlmResolvedModelInfo, LlmProviderInfo, @@ -225,6 +227,27 @@ export interface AdapterRegistrationHandle { replace(providers: string[]): void } +/** + * A live configurable-provider registration, disposable and atomically + * replaceable — the directory counterpart of {@link AdapterRegistrationHandle}. + */ +export interface DirectoryRegistrationHandle { + /** Withdraw every entry this registration currently holds. */ + (): void + /** + * Replace this registration's entries with `entries`. The candidate set is + * validated in full first — an entry another registration already declares, + * a duplicate within the set, or invalid metadata throws and leaves the + * current entries untouched — and the swap is one synchronous section, so no + * reader observes a gap. An empty array is legal here, unlike an empty + * initial registration. + * + * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration + * has been disposed. + */ + replace(entries: readonly LlmConfigurableProvider[]): void +} + /** * The abstract `llm` service: an adapter registry plus a streaming model-call * surface, interceptable via the `llm/stream` waterfall. @@ -232,6 +255,10 @@ export interface AdapterRegistrationHandle { export class LlmService extends Service { private adapters = new Map() private directory = new Map() + private discoveries = new Map< + string, + (request: LlmModelDiscoveryRequest) => Promise + >() constructor(ctx: Context) { super(ctx, 'llm') @@ -370,34 +397,61 @@ export class LlmService extends Service { * entry, or a provider already declared by any registration throws * `LlmError` without registering the rest. Disposed with the fiber. * @param entries - every configurable provider this plugin owns. - * @returns the disposer that withdraws all of them. + * @returns a handle that withdraws all of them, and can atomically replace them. */ - registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void { - const dispose = this.ctx.effect(function* (this: LlmService) { - if (entries.length === 0) { - throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY') - } + registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle { + let held: LlmConfigurableProvider[] = [] + let disposed = false + /** + * Validate a candidate set in full against everything this registration + * does not already hold, then publish it. Nothing is written until the + * whole set passes, so a refused candidate leaves the current entries in + * place — the property that makes `replace` a swap rather than a + * delete-then-add that can strand the directory empty. + */ + const commit = (candidates: readonly LlmConfigurableProvider[]): void => { const detached: LlmConfigurableProvider[] = [] - for (const entry of entries) { + const own = new Set(held.map(entry => entry.provider)) + for (const entry of candidates) { if (entry.provider.length === 0 || entry.displayName.length === 0 || entry.settingsNs.length === 0) { throw new LlmError('configurable providers need a non-empty provider, displayName, and settingsNs', 'INVALID_DIRECTORY') } if (entry.settingsPath.some(segment => segment.length === 0)) { throw new LlmError(`configurable provider "${entry.provider}" has an empty settingsPath segment`, 'INVALID_DIRECTORY') } - if (this.directory.has(entry.provider) || detached.some(seen => seen.provider === entry.provider)) { + if ((this.directory.has(entry.provider) && !own.has(entry.provider)) + || detached.some(seen => seen.provider === entry.provider)) { throw new LlmError(`configurable provider "${entry.provider}" is already declared`, 'DUPLICATE_DIRECTORY') } detached.push({ ...entry, settingsPath: [...entry.settingsPath] }) } + for (const entry of held) this.directory.delete(entry.provider) for (const entry of detached) this.directory.set(entry.provider, entry) + held = detached this.emitAdaptersUpdated() + } + + const dispose = this.ctx.effect(function* (this: LlmService) { + if (entries.length === 0) { + throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY') + } + commit(entries) yield () => { - for (const entry of detached) this.directory.delete(entry.provider) + disposed = true + for (const entry of held) this.directory.delete(entry.provider) + held = [] this.emitAdaptersUpdated() } }.bind(this), 'llm.registerConfigurableProviders()') - return () => void dispose() + + const handle = ((): void => void dispose()) as DirectoryRegistrationHandle + handle.replace = (next: readonly LlmConfigurableProvider[]): void => { + if (disposed) { + throw new LlmError('this configurable-provider registration was disposed', 'REGISTRATION_DISPOSED') + } + commit(next) + } + return handle } /** @@ -408,6 +462,73 @@ export class LlmService extends Service { return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] })) } + /** + * Offer to interrogate provider endpoints on behalf of the settings + * namespace this plugin owns. The namespace is the key because that is what + * a configuration surface already holds from the configurable-provider + * directory, and because a provider being *added* has no route to name yet. + * Disposed with the fiber. + * @param settingsNs - the namespace whose profiles this discovery serves. + * @param discover - interrogates one endpoint; must honor `request.signal`. + * @returns the disposer that withdraws the offer. + */ + registerModelDiscovery( + settingsNs: string, + discover: (request: LlmModelDiscoveryRequest) => Promise, + ): () => void { + const dispose = this.ctx.effect(function* (this: LlmService) { + if (settingsNs.length === 0) { + throw new LlmError('model discovery needs a non-empty settings namespace', 'INVALID_DISCOVERY') + } + if (this.discoveries.has(settingsNs)) { + throw new LlmError(`model discovery for "${settingsNs}" is already registered`, 'DUPLICATE_DISCOVERY') + } + this.discoveries.set(settingsNs, discover) + yield () => { + this.discoveries.delete(settingsNs) + } + }.bind(this), 'llm.registerModelDiscovery()') + return () => void dispose() + } + + /** + * Interrogate one provider endpoint for the models it advertises. The + * request describes a draft, not a stored route, so nothing here reads or + * writes settings or credentials — the caller owns both, and the reply is + * candidate metadata a surface may offer for adoption. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - the endpoint, protocol, and one-shot credential to use. + * @returns the advertised models, deduplicated in endpoint order. + */ + async discoverModels( + settingsNs: string, + request: LlmModelDiscoveryRequest, + ): Promise { + const discover = this.discoveries.get(settingsNs) + if (discover === undefined) { + throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY') + } + // One of the two identifies what to describe: a route the adapter knows, or + // an endpoint to ask. Neither leaves nothing to answer about. + if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) { + throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY') + } + const discovered = await discover(request) + const seen = new Set() + const models: LlmDiscoveredModel[] = [] + for (const model of discovered) { + if (typeof model.id !== 'string' || model.id.length === 0 || seen.has(model.id)) continue + seen.add(model.id) + models.push({ + id: model.id, + ...model.name === undefined ? {} : { name: model.name }, + ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, + ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, + }) + } + return models + } + /** * Resolve the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index f5ad71bb43..4980b74405 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -139,6 +139,49 @@ export interface LlmConfigurableProvider { settingsPath: readonly string[] } +/** + * One interrogation of a provider endpoint that configuration has not stored + * yet. Configuration surfaces send the draft a user is still editing, so the + * request carries the endpoint and credential directly instead of naming a + * route: a provider being added has no route to name. + */ +export interface LlmModelDiscoveryRequest { + /** + * Route the draft is editing, when it edits an existing one. A route whose + * adapter already knows its models answers from that knowledge instead of + * asking the endpoint — the adapter's own registry is the better answer, and + * it costs no network call. + */ + provider?: string + /** + * Endpoint to interrogate. Optional because a route the adapter already + * describes needs none; a route it does not must supply one. + */ + baseURL?: string + /** Wire protocol the endpoint speaks, when the draft names one. */ + api?: string + /** Credential for this interrogation alone; the harness never stores it. */ + apiKey?: string + /** Caller cancellation; implementations must settle promptly after it aborts. */ + signal?: AbortSignal +} + +/** + * One model an endpoint reports about itself. Every field but the id is + * optional because most provider listings disclose an id and nothing else; + * a surface adopting one of these still owes the capacities its adapter needs. + */ +export interface LlmDiscoveredModel { + /** Model id the endpoint accepts. */ + id: string + /** Human-readable name when the endpoint supplies one. */ + name?: string + /** Maximum combined request and response context, when disclosed. */ + contextWindow?: number + /** Maximum output tokens, when disclosed. */ + maxTokens?: number +} + /** One adapter-discovered model; catalog membership is advisory, not request validation. */ export interface LlmModelInfo { /** Provider route that owns this model entry. */ diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index f07b33af7d..8577e14b7c 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -170,6 +170,32 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders()).toEqual([]) }) + it('replaces its entries atomically, keeping the old set when a candidate collides', async () => { + const ctx = await setup() + const handle = ctx.llm.registerConfigurableProviders([entry(), entry({ provider: 'second' })]) + ctx.llm.registerConfigurableProviders([entry({ provider: 'owned-elsewhere' })]) + + // A candidate another registration already declares refuses the whole swap. + expect(() =>{ handle.replace([entry({ provider: 'owned-elsewhere' })]) }).toThrow(/already declared/) + expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort()) + .toEqual(['owned-elsewhere', 'second', entry().provider].sort()) + + // Its own entries are not "already declared" against itself, so a swap that + // keeps one and drops another lands whole. + handle.replace([entry({ displayName: 'Renamed' })]) + expect(ctx.llm.listConfigurableProviders().map(view => view.provider).sort()) + .toEqual(['owned-elsewhere', entry().provider].sort()) + expect(ctx.llm.listConfigurableProviders().find(view => view.provider === entry().provider)?.displayName) + .toBe('Renamed') + + // An empty replace is legal, unlike an empty initial registration. + handle.replace([]) + expect(ctx.llm.listConfigurableProviders().map(view => view.provider)).toEqual(['owned-elsewhere']) + + handle() + expect(() =>{ handle.replace([entry()]) }).toThrow(/was disposed/) + }) + it('rejects duplicates within one registration and across registrations', async () => { const ctx = await setup() expect(() => ctx.llm.registerConfigurableProviders([entry(), entry()])).toThrow(/already declared/) @@ -179,3 +205,64 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders()).toHaveLength(1) }) }) + +describe('model discovery registry', () => { + it('offers one interrogation per settings namespace and disposes with its fiber', async () => { + const ctx = await setup() + const discover = vi.fn(() => Promise.resolve([{ id: 'from-endpoint' }])) + + const dispose = ctx.llm.registerModelDiscovery('llm-example', discover) + await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })) + .resolves.toEqual([{ id: 'from-endpoint' }]) + expect(discover).toHaveBeenCalledWith({ baseURL: 'https://gateway.example/v1' }) + + // Disposal is observed through the offer itself, which is the only thing + // the registration ever produced. + dispose() + await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })) + .rejects.toThrow(/no model discovery is registered/) + }) + + it('rejects an unnamed namespace and a second registration of the same one', async () => { + const ctx = await setup() + const discover = (): Promise => Promise.resolve([]) + + expect(() => ctx.llm.registerModelDiscovery('', discover)).toThrow(/non-empty settings namespace/) + ctx.llm.registerModelDiscovery('llm-example', discover) + expect(() => ctx.llm.registerModelDiscovery('llm-example', discover)).toThrow(/already registered/) + // The refused second registration left the first one serving. + await expect(ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })) + .resolves.toEqual([]) + }) + + it('normalizes what an interrogation returns without inventing capacities', async () => { + const ctx = await setup() + ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([ + { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 }, + { id: '' }, + { id: 'keep' }, + { id: 'bare' }, + ] as never)) + + expect(await ctx.llm.discoverModels('llm-example', { baseURL: 'https://gateway.example/v1' })).toEqual([ + { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 }, + { id: 'bare' }, + ]) + }) + + it('refuses a namespace nothing serves and a draft with no endpoint', async () => { + const ctx = await setup() + ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([])) + + await expect(ctx.llm.discoverModels('llm-absent', { baseURL: 'https://gateway.example/v1' })) + .rejects.toMatchObject({ code: 'NO_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-example', { baseURL: '' })) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-example', { provider: '', baseURL: '' })) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + await expect(ctx.llm.discoverModels('llm-example', {})) + .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' }) + // Naming a route alone is enough: the adapter may know it without an endpoint. + await expect(ctx.llm.discoverModels('llm-example', { provider: 'known-route' })).resolves.toEqual([]) + }) +}) diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 036f80647f..c83879c63a 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -33,10 +33,11 @@ const breakdownSchema = z.object({ * * Envelope figures are last-wins per `request/header`; the message figure * rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy - * projection uses — so it equals `measure().surfaceTokens` at every event - * boundary and compaction shrinks it by its logged shadow price, the way it - * shrinks the next request. The state is a fixed handful of numbers, so the - * persisted checkpoint stays O(1) over the session's life. + * projection uses — so fully metered logs equal `measure().surfaceTokens` at + * every event boundary and compaction shrinks the figure by its logged shadow + * price. A replacement without a claim preserves the previous total. The + * state is a fixed handful of numbers, so the persisted checkpoint stays + * O(1) over the session's life. */ export const contextBreakdownProjectionDefinition: ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = { diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index e4dfacc254..2848025b19 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -3,9 +3,10 @@ * surface `measure()` serves and compaction plans against. The projection * units deliberately do NOT share this fold — their state must stay O(1) * for the persisted checkpoint, so they ride `surface-projection.ts`'s - * shadow-price protocol instead. The two stay in agreement by construction: - * both price through `estimate.ts`, and every logged shadow price is derived - * from THIS fold's nodes by the replace producer. + * shadow-price protocol instead. Fully metered logs stay in agreement by + * construction: both price through `estimate.ts`, and every logged shadow + * price is derived from THIS fold's nodes by the replace producer. A + * projection replacement without a claim deliberately folds with zero delta. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts index dcc8181370..9c42d5248e 100644 --- a/packages/llm/token-meter/src/surface-projection.ts +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -10,7 +10,9 @@ * heuristic price of the exact replaced range, so the fold keeps a running * total plus at most one pending claim and never retains per-node prices. * The counts are exact by construction: producers derive them from the same - * fixed estimator this module prices appends with. + * fixed estimator this module prices appends with. A replacement without an + * armed claim folds with zero delta because bounded state cannot reconstruct + * the replaced range; this preserves replay at the cost of possible drift. * * @module @deepseek-ai/dsh-token-meter/surface-projection */ @@ -47,16 +49,19 @@ export interface SurfaceTokensFold { * Fold one committed event onto a running surface-token total. * * A shadow-price event arms a claim; any other event expires it, and a - * surface `replace` must consume a claim naming its exact range — the + * surface `replace` consumes the claim naming its exact range — the * producers append the metering event and the replacement synchronously * adjacent, so a surviving claim always prices the very next event. + * A replace with no claim folds with zero delta because the bounded state + * cannot reconstruct the replaced range. An armed claim for another range + * still fails because the adjacent events contradict each other. * @param claim - the claim armed by the immediately preceding event, if any. * @param event - the next committed session event. * @returns the signed token delta and the claim state after this event. - * @throws when a replacement arrives without a claim for its exact range — - * every in-repo replace producer meters its replacement, so an unpriced - * replacement is a shadow-price contract violation and must fail loud - * rather than let the total drift. + * @throws when a replacement arrives with an armed claim for a different + * range — the metering event was adjacent, so this is a live producer's + * shadow-price contract violation, not historical data, and must fail + * loud rather than let the total drift. */ export function foldSurfaceProjection( claim: ShadowPriceClaim | undefined, @@ -74,10 +79,15 @@ export function foldSurfaceProjection( const tokens = message === null ? 0 : estimateMessage(message) const op = event.surfaceOp if (op === 'append') return { deltaTokens: tokens, claim: undefined } - if (claim === undefined || claim.start !== op.start || claim.end !== op.end) { + // Sessions recorded before the shadow-price protocol log replacements with + // no adjacent metering event; the bounded state cannot reconstruct the + // replaced range's price, so fold those neutrally — historical replay + // degrades to drift instead of failing. + if (claim === undefined) return { deltaTokens: 0, claim: undefined } + if (claim.start !== op.start || claim.end !== op.end) { throw new Error( `token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price` - + (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`), + + ` (armed claim covers ${claim.start}-${claim.end})`, ) } return { deltaTokens: tokens - claim.tokens, claim: undefined } diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 0d5db509b5..a7fc9debf0 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -155,9 +155,10 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { * `projectedTokens` — the sample plus the surface's signed movement since it * was taken — so occupancy answers for the next request rather than the last * one. The total rides {@link foldSurfaceProjection}, so the state stays O(1) - * and a replacement shrinks it by its logged shadow price. A usage sample is - * stamped BEFORE the same event joins the surface, so an `assistant/message` - * anchors against the surface its own request saw. + * and a replacement shrinks it by its logged shadow price. A replacement + * without a claim preserves the previous total. A usage sample is stamped + * BEFORE the same event joins the surface, so an `assistant/message` anchors + * against the surface its own request saw. */ export const contextPressureProjectionDefinition: ProjectionDefinition<'contextPressure', ContextPressureState> = { diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index b7e4850fd4..20cb2cc819 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -180,7 +180,7 @@ describe('contextBreakdown session projection', () => { expect(agree()).toBeLessThan(grown) }) - it('fails loud on a replacement without an adjacent matching shadow price', () => { + it('folds a replacement without a claim at zero and fails on a mismatched claim', () => { const definition = contextBreakdownProjectionDefinition const replace = (start: number, end: number): SessionEvent => ({ type: 'user/message', @@ -206,15 +206,17 @@ describe('contextBreakdown session projection', () => { let state = definition.init() state = definition.apply(state, append(1)) state = definition.apply(state, append(3)) - // No metering event at all. - expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim for a different range does not price this replacement. + // No metering event: the replacement contributes zero instead of throwing. + expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) + // An adjacent claim for another range contradicts the replacement. const mismatched = definition.apply(state, meter(1, 1, 8)) expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price') - // A claim expires after one intervening event instead of lingering. + // A claim expires after one intervening event, so replacement delta is zero. let expired = definition.apply(state, meter(1, 3, 8)) expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent) - expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price') + expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens) + .toBe(definition.view(state).messageTokens) // The armed claim prices exactly the next event's matching replacement. const armed = definition.apply(state, meter(1, 3, 8)) expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens) diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 07261576eb..0307b96f46 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -419,6 +419,25 @@ describe('contextPressure session projection', () => { expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!) }) + it('folds a replacement without a claim at zero', async () => { + const { ctx, session } = await harness() + const question = appendUser(session, 'a question from an unmetered log') + startStep(session, 1, 1) + usageChunk(session, { inputTokens: 100, outputTokens: 1 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + const before = pressure(ctx, session) + + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary without a preceding claim' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: question }, + sourceEventSeqs: [question], + }) + + expect(pressure(ctx, session)).toEqual(before) + }) + it('clamps a projection that heuristic error drove below zero', async () => { const { ctx, session } = await harness() recordContext(session, 'large', 128_000) diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 75b8ffd36b..aac726d128 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -202,9 +202,7 @@ export class PlanModeService extends Service { // the session. A failed append remains pending for a later boundary, and // policy cannot block the step. ctx.on('agent/pre-step', async ( - agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 6e614a36a0..34678714fa 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -42,7 +42,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() @@ -139,7 +139,7 @@ describe('plan mode through the agent loop', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _context, _signal, next) => { + ctx.on('agent/request-error', async ({ agent: subject }, next) => { if (subject !== agent) return next() ctx.planMode.set(agent, true) return { kind: 'retry' } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 87a295e90c..63abed59ea 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -45,7 +45,7 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { acti // Seeded plan state lands before the creation announcement, matching resume. if (active !== undefined) session.append('plan/mode', { active }) // The loop announces creation after publication. - ctx.emit('agent/created', agent) + ctx.emit('agent/created', { agent }) return agent } @@ -74,8 +74,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type: const signal = new AbortController().signal const decision = await events.waterfall( 'agent/pre-step', - [message], - { turn: 1, step: 1, signal }, + { messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [message] }), ) if (decision.kind === 'enter') { diff --git a/packages/session-persistence/session-checkpoint-policy/src/index.ts b/packages/session-persistence/session-checkpoint-policy/src/index.ts index c26a65e8ad..804ed0dcb1 100644 --- a/packages/session-persistence/session-checkpoint-policy/src/index.ts +++ b/packages/session-persistence/session-checkpoint-policy/src/index.ts @@ -76,7 +76,7 @@ export function apply(ctx: Context): void { // Before each request, persist everything committed by the preceding step; // the first step's call is an intentional no-op beyond any prompt intake. - ctx.on('agent/pre-step', async (agent, _messages, _context, next): Promise => { + ctx.on('agent/pre-step', async ({ agent }, next): Promise => { await ctx.sessions.flush(agent.session) return next() }) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index b619871156..dde59610c5 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -228,7 +228,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { ctx.on('session/flush', (current) => { flushed.push(current.id) }) const signal = new AbortController().signal await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [] }), ) expect(flushed).toEqual([session.id]) diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index f51e08cb03..64e9f147b1 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -44,6 +44,26 @@ export interface SettingsRegisterOptions { base?: Partial /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */ applies?: SettingsApplies + /** + * Reject a resolved section the owner could not act on, for constraints its + * schema cannot express — a cross-field requirement, or one field's validity + * depending on another's. Throwing here refuses the *write* that produced the + * value, so a caller learns at `update`/`replace`/`mutate` instead of storing + * something that would silently disable the owner. + * + * Kept separate from the schema because the schema is also what a + * configuration surface renders and what an absent section resolves through; + * folding a cross-field check into it would change both. + * + * Once the owner is registered, a stored section that fails this keeps the + * namespace's last good value and warns, exactly as a schema failure does, + * so an externally edited document cannot strand a running owner. At + * registration there is no last good value yet, so a stored section that + * already fails rejects the registration itself — again exactly as a schema + * failure does. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } /** One registered namespace as surfaced to configuration UIs. */ @@ -343,6 +363,8 @@ interface SettingsRegistration { schema: z base: unknown applies: SettingsApplies + /** Owner-supplied check for constraints the schema cannot express. */ + validate?: (value: unknown) => void resolved: unknown /** * Monotonic counter over this namespace's RAW user section — bumped by any @@ -456,7 +478,10 @@ export abstract class Settings extends Service { schema: schema as z, base: options?.base, applies: options?.applies ?? 'live', - resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))), + ...options?.validate === undefined + ? {} + : { validate: options.validate as (value: unknown) => void }, + resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)), revision: 0, watchers: new Set(), } @@ -642,7 +667,7 @@ export abstract class Settings extends Service { : mode === 'replace' ? snapshot : (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current) - const next = deepFreeze(this.resolve(registration.schema, registration.base, section)) + const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate)) await this.persist(ns, section) // The write reached storage either way; the cache must say so. Commit // only when this registration is still the namespace owner — a fiber @@ -684,7 +709,7 @@ export abstract class Settings extends Service { for (const registration of this.registrations.values()) { let next: unknown try { - next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns))) + next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns), registration.validate)) } catch (error) { this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns) this.ctx.logger.warn(error) @@ -706,10 +731,19 @@ export abstract class Settings extends Service { } /** Resolve one namespace value: schema defaults, then `base`, then the user layer. */ - private resolve(schema: z, base: unknown, section: Record | undefined): T { + private resolve( + schema: z, + base: unknown, + section: Record | undefined, + validate?: (value: T) => void, + ): T { // The merged candidate is untyped by construction; the schema call is the // runtime validation that admits it into T. - return schema(mergeLayers(base, section) as never) + const value = schema(mergeLayers(base, section) as never) + // The owner's own check runs on the admitted value, so it sees defaults + // and the composition base exactly as the owner will. + validate?.(value) + return value } /** @@ -842,6 +876,12 @@ export interface SettingsSectionHooks { * memoized resolutions — after an attach, a detach, or a committed change. */ onChange(): void + /** + * Reject a resolved section this consumer could not act on, for constraints + * its schema cannot express. See {@link SettingsRegisterOptions.validate}. + * @param value - the resolved section, schema-valid by construction. + */ + validate?: (value: T) => void } /** @@ -865,7 +905,10 @@ export function installSettingsSection( hooks: SettingsSectionHooks, ): void { ctx.inject(['settings'], (sctx) => { - const scope = sctx.settings.register(ns, schema, { base: entry }) + const scope = sctx.settings.register(ns, schema, { + base: entry, + ...hooks.validate === undefined ? {} : { validate: hooks.validate }, + }) hooks.setSource(() => scope.get()) sctx.effect(() => () => { // This disposer runs for two different reasons. A settings provider diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index dd3d5e1bc3..c6a1e57016 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -95,6 +95,44 @@ describe('registration', () => { expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 }) }) + it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => { + const { ctx } = await boot() + const ns = settingsNamespace('ui-theme') + // A constraint the schema cannot express: this owner cannot serve a size + // it considers unreadable, whatever the schema admits. + const scope = ctx.settings.register(ns, ThemeSchema, { + validate: (value) => { + if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) + }, + }) + const before = scope.get() + + await expect(ctx.settings.update(ns, { fontSize: 4 })).rejects.toThrow(/unreadable/) + expect(scope.get()).toEqual(before) + + // An externally edited document must not strand the owner: the namespace + // keeps its last good value, exactly as a schema failure would. + ;(ctx.settings as unknown as { publish(doc: Record): void }) + .publish({ 'ui-theme': { fontSize: 4 } }) + expect(scope.get()).toEqual(before) + + await ctx.settings.update(ns, { fontSize: 18 }) + expect(scope.get()).toMatchObject({ fontSize: 18 }) + }) + + it('fails the registration itself when the already-stored section is unserviceable', async () => { + // The other direction of the same contract: `register` resolves inline, so + // at cold start there is no last good value to keep. A stored section the + // owner cannot serve therefore refuses the registration rather than + // mounting an owner over configuration it rejects. + const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } }) + expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { + validate: (value) => { + if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`) + }, + })).toThrow(/unreadable/) + }) + it('rejects a duplicate namespace loud', async () => { const { ctx } = await boot() ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 634824d6c2..ddc45d18e9 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -163,9 +163,7 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 5d14b7c523..0755e398a0 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -86,8 +86,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): const signal = new AbortController().signal const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn, step, signal }, + { messages: [], turn, step, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -105,8 +104,7 @@ async function proposeStep( const signal = new AbortController().signal return await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - messages, - { turn: 1, step: 1, signal }, + { messages, turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages }), ) } @@ -138,8 +136,7 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal }, + { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) if (decision.kind === 'enter') { @@ -241,7 +238,7 @@ describe('dsh-tool-skill', () => { source: 'runtime', content: 'User-only body.', }) - ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { const decision = await next() if (decision.kind === 'reject') return decision return { diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 9cde7517ab..11357c76d2 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/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/subagent/README.md -README.md: 6aeb7fb1eaa9341dd72df614ca11d114f321fb83 -README.zh.md: a78cb365a8e96ad44c0c930c072372f88930906c +README.md: 0a342569e66539e4987710b2e56f2946c97b1ac1 +README.zh.md: 5d2f7beef478b8bfd27b4772c7a951ea62cb10ef diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 6aeb7fb1ea..0a342569e6 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -11,6 +11,8 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`subagent-spawn/`](subagent-spawn/README.md) | Starts a fresh in-process child | registers on `ctx.subagents` | | [`subagent-fork/`](subagent-fork/README.md) | Starts an in-process child from the parent's completed history | registers on `ctx.subagents` | | [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` | +| [`subagent-codex/`](subagent-codex/README.md) | Starts a real Codex app-server child | registers on `ctx.subagents` | +| [`subagent-claude-code/`](subagent-claude-code/README.md) | Starts a real Claude Code child through the official Claude Agent SDK | registers on `ctx.subagents` | | [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` | | [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` | | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index a78cb365a8..5d2f7beef4 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -11,6 +11,8 @@ | [`subagent-spawn/`](subagent-spawn/README.md) | 启动全新的进程内子 agent | 注册到 `ctx.subagents` | | [`subagent-fork/`](subagent-fork/README.md) | 从父 agent 已完成的历史记录启动进程内子 agent | 注册到 `ctx.subagents` | | [`subagent-acp/`](subagent-acp/README.md) | 通过 ACP(Agent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` | +| [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` | +| [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` | | [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` | | [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` | | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 2f5532e9f4..f44ae87197 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/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/subagent/subagent-acp/README.md -README.md: 83a5f60414528bdb768ffccd29f3091793f44b6b -README.zh.md: 4ea8daef9341897463f3dbedca86fd2c83b45514 +README.md: 4fdd3a09e128d4dc7ec7395d9578803c64a33bc6 +README.zh.md: 7cb1e3d18602ef839e4af316962fc2d10bc67640 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 83a5f60414..4fdd3a09e1 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -30,8 +30,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. | | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | -| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. | -| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. | +| `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 4ea8daef93..7cb1e3d186 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 @@ -30,8 +30,8 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | -| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间。 | -| `disposeGraceMs` | `3000` | 终止后的退出确认宽限时间;POSIX 在 SIGTERM 后、SIGKILL 前也会等待同样时长。 | +| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | +| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 382d2e219a..424cb9cb5c 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -47,6 +48,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 8616f7ae94..fa7c031760 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -17,6 +17,7 @@ import type { SubagentProvider, SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' @@ -54,10 +55,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. + * before the parent escalates to a signal. Must not exceed + * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination confirmation window (ms), including forced exit on every platform. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -72,10 +74,10 @@ export const Config: z = z.object({ disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) -/** A dispose grace must be a positive finite number (it bounds the teardown wait). */ +/** A dispose grace must fit the single Node timer that owns its teardown tier. */ function assertPositiveFinite(name: string, value: number): void { - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`subagent-acp: ${name} must be a positive finite number`) + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index fba0403739..f3e155e649 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -62,9 +62,9 @@ export interface AcpRunSpec { */ disposeEofGraceMs: number /** - * Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after - * `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin - * fills this from its `disposeGraceMs` config. + * Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX + * waits this long after `SIGTERM` before `SIGKILL`, while Windows + * force-terminates directly. The plugin fills it from `disposeGraceMs`. */ disposeGraceMs: number /** @@ -105,14 +105,12 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { +export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise { // A spawn failure has no process to tear down; observe the rejection so // disposal in a finally block cannot surface it as unhandled. if (child.pid <= 0) { @@ -121,13 +119,10 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe } child.stdin?.end() if (await treeExitsWithin(child, eofGraceMs)) return - // terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace - // (this plugin passes disposeGraceMs there), so the bound covers both the - // escalation window and an equal confirmation window after the SIGKILL. + // terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is + // the process owner's exit proof, not a second derived grace that can overflow. child.terminate() - if (!(await treeExitsWithin(child, graceMs * 2))) { - throw new Error('ACP child process tree did not exit within its dispose windows') - } + await child.waitForExit() } /** @@ -235,7 +230,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs)) + const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs)) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index f2cbeda27b..6c6c238e74 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -7,6 +7,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -147,7 +148,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { const child = bash('read -r line; exit 0') - await disposeAcpChild(child, 5_000, 200) + await disposeAcpChild(child, 5_000) const outcome = await child.done expect(outcome.exitCode).toBe(0) expect(outcome.signal).toBeNull() @@ -155,7 +156,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { const child = bash('sleep 60') - await disposeAcpChild(child, 100, 5_000) + await disposeAcpChild(child, 100) const outcome = await child.done expect(outcome.signal).toBe('SIGTERM') }) @@ -166,30 +167,11 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { await new Promise(resolve => setTimeout(resolve, 10)) } - await disposeAcpChild(child, 50, 2_000) + await disposeAcpChild(child, 50) const outcome = await child.done expect(outcome.signal).toBe('SIGKILL') }) - it('throws when the tree survives even the escalation window', async () => { - // A handle whose tree never exits (waitForExit only ever aborts): the - // ladder must fail loud instead of resolving over survivors. Built as a - // stub because the ladder composes only public verbs. - const never: Parameters[0] = { - pid: 1, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: {}, - done: new Promise(() => {}), - terminate: () => {}, - waitForExit: (signal?: AbortSignal) => new Promise((resolve) => { - signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) - }), - } - await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) - }) - it('observes a spawn-level rejection and returns without a process to reap', async () => { const child = spawnSubprocess({ argv: ['bash', '-c', 'true'], @@ -197,7 +179,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, graceMs: 200, }) - await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined() + await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined() await expect(child.done).rejects.toThrow() }) }) @@ -721,13 +703,20 @@ describe('dsh-subagent-acp', () => { } }) - it('rejects a non-positive dispose grace at load', async () => { - for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { + it('rejects a dispose grace outside the Node timer range at load', async () => { + for (const bad of [ + { disposeEofGraceMs: 0 }, + { disposeGraceMs: -1 }, + { disposeEofGraceMs: Number.NaN }, + { disposeGraceMs: Number.POSITIVE_INFINITY }, + { disposeEofGraceMs: MAX_TIMER_DELAY_MS + 1 }, + { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }, + ]) { const ctx = new Context() await ctx.plugin(SubagentService) await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) - .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) + .rejects.toThrow(new RegExp(`subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)) await ctx.fiber.dispose() } }) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 2d60858d4a..c7966ddc6f 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/loader-smoke" }, diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml new file mode 100644 index 0000000000..6bc638bdc4 --- /dev/null +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md +README.md: e62f60fceea16749296a91377785b81d94d751ca +README.zh.md: e171524157b2b1696df31753816210d41637a911 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md new file mode 100644 index 0000000000..e62f60fcee --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.md @@ -0,0 +1,96 @@ +# @deepseek-ai/dsh-subagent-claude-code + +English | [中文](README.zh.md) + +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`. + +The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; this version produces neither `max-tokens` nor `refusal`. + +Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate. + +## Native settings and interaction + +The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. + +Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | + +Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. + +Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. + +```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product. + +The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. + +## Model Experience + +### Child request + +#### What the model sees + +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation. + +#### Token effect + +The child pays for an independent Claude Code context and query. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Claude Code's own model, instructions, tools, native settings, and fresh query. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent sees only the strict final Claude Code answer or the consumer's exact error for a non-completed result. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. + +#### Token effect + +Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: the new tool result follows the reusable parent request prefix. + +## Known Limitations and Deferred Work + +- **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. +- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. +- **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md new file mode 100644 index 0000000000..e171524157 --- /dev/null +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -0,0 +1,96 @@ +# @deepseek-ai/dsh-subagent-claude-code + +[English](README.md) | 中文 + +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 + +SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;本版本不会产生 `max-tokens` 或 `refusal`。 + +本地取消会在结果竞态中胜出并映射为 `aborted`。`dispose()` 具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。 + +## 原生设置与交互 + +提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。 + +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | + +生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 + +请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 + +```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + config: + env: + ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。 + +项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 + +## 模型体验 + +### 子任务请求 + +#### 模型看到的内容 + +Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。 + +#### 对 token 的影响 + +子任务需为独立的 Claude Code 上下文和 query 承担 token 开销。子任务 token 不会进入父级上下文。 + +#### 对 KV Cache 的影响 + +这与父请求缓存相互独立。能否复用只取决于 Claude Code 自身的模型、指令、工具、原生设置和全新 query。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级模型只会看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 + +#### 对 token 的影响 + +父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。 + +#### 对 KV Cache 的影响 + +仅追加:新的工具结果接在可复用的父请求前缀之后。 + +## 已知限制与后续工作 + +- **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 +- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 +- **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json new file mode 100644 index 0000000000..8b25c6919c --- /dev/null +++ b/packages/subagent/subagent-claude-code/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-subagent-claude-code", + "description": "One-shot Claude Code subagent provider over the official Agent SDK", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "@anthropic-ai/sdk": "0.93.0", + "@anthropic-ai/claude-agent-sdk": "0.3.220", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts new file mode 100644 index 0000000000..e4d6fbac5f --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -0,0 +1,107 @@ +/** + * Fixed Claude Code one-shot subagent provider. Every accepted run invokes + * the official Agent SDK in the delegating Session's workspace and places + * the SDK-spawned real CLI under the shared subprocess owner. + * + * @module @deepseek-ai/dsh-subagent-claude-code + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + DEFAULT_DISPOSE_GRACE_MS, + startClaudeCodeRun, + type ClaudeCodeRunSpec, +} from './run.ts' + +export const name = 'subagent-claude-code' +export const inject = ['subagents', 'subprocess'] + +/* jscpd:ignore-start -- sibling product providers intentionally expose the + * same two deployment-owned fields without adding a shared config owner. */ +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for Claude Code process-tree termination. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +type ResolvedConfig = Required +/* jscpd:ignore-end */ + +/* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror + * the Codex sibling; each product's lifecycle remains package-private. */ +class ClaudeCodeProvider implements SubagentProvider { + readonly name = 'claude-code' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + start(request: ResolvedSubagentStartRequest) { + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error( + 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', + ) + } + const spec: ClaudeCodeRunSpec = { + cwd: resolveChildCwd( + 'subagent-claude-code', + undefined, + parentCwd, + ), + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-claude-code: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startClaudeCodeRun(request, spec) + } +} + +/** + * Register the fixed `claude-code` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-claude-code', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved)) +} +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts new file mode 100644 index 0000000000..462692590f --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for + * `@deepseek-ai/dsh-subagent-claude-code`. + * @module @deepseek-ai/dsh-subagent-claude-code/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code' + +/** Cordis companion plugin name. */ +export const name = 'subagent-claude-code-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts new file mode 100644 index 0000000000..32a545bf08 --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -0,0 +1,156 @@ +/** + * Projection from the shared managed-process handle to the official Claude + * Agent SDK's custom-spawn process interface. + * + * @module @deepseek-ai/dsh-subagent-claude-code/process + */ + +import { EventEmitter } from 'node:events' +import type { + SpawnedProcess, + SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import { + scrubbedParentEnv, + type SubprocessHandle, + type SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' + +function thrown(value: unknown): Error { + /* v8 ignore next -- the subprocess seam rejects with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Encode the SDK's complete child environment as a subprocess overlay. + * @param env - SDK-composed child environment after its removals and replacements. + * @returns explicit values plus tombstones for surviving ambient names the SDK removed. + */ +export function sdkEnvironmentOverlay( + env: SpawnOptions['env'], +): NodeJS.ProcessEnv { + const overlay: NodeJS.ProcessEnv = { ...env } + for (const name of Object.keys(scrubbedParentEnv())) { + if (!(name in env)) overlay[name] = undefined + } + return overlay +} + +/** + * Translate one official SDK spawn request to the shared process owner. + * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. + * @param graceMs - process-tree termination grace. + * @returns the fully explicit shared subprocess request. + */ +export function claudeSpawnSpec( + options: SpawnOptions, + graceMs: number, +): SubprocessSpawnSpec { + if (options.cwd === undefined || options.cwd.length === 0) { + throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') + } + return { + argv: [options.command, ...options.args], + cwd: options.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs, + signal: options.signal, + env: sdkEnvironmentOverlay(options.env), + } +} + +/** + * SDK-facing view of one shared managed process. Protocol transport remains + * in the official SDK; this adapter only projects streams and exit events. + */ +export class ManagedClaudeCodeProcess implements SpawnedProcess { + readonly stdin + readonly stdout + private readonly events = new EventEmitter() + private exitCodeValue: number | null = null + private signalCodeValue: NodeJS.Signals | null = null + private killRequested = false + + /** + * Project a managed process with piped stdin and stdout. + * @param child - shared handle that remains the process-tree authority. + */ + constructor(private readonly child: SubprocessHandle) { + this.stdin = child.stdin as NonNullable + this.stdout = child.stdout as NonNullable + // EventEmitter gives `error` special throw semantics without a listener. + // The SDK attaches its listener synchronously after custom spawn returns, + // while this no-op also contains an already-rejected spawn handle. + this.events.on('error', () => {}) + void child.done.then( + (outcome) => { + this.exitCodeValue = outcome.exitCode + this.signalCodeValue = outcome.signal + this.events.emit('exit', outcome.exitCode, outcome.signal) + }, + (error: unknown) => { + this.events.emit('error', thrown(error)) + }, + ) + } + + /** Whether the SDK has requested managed tree termination. */ + get killed(): boolean { + return this.killRequested + } + + /** Direct-child exit code, or null while running or after signal exit. */ + get exitCode(): number | null { + return this.exitCodeValue + } + + /** Direct-child terminating signal, if any. */ + get signalCode(): NodeJS.Signals | null { + return this.signalCodeValue + } + + /** + * Route the SDK's termination request to the tree-scoped process owner. + * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. + * @returns false only after exit or a previous termination request. + */ + kill(_signal: NodeJS.Signals): boolean { + if ( + this.killRequested + || this.exitCodeValue !== null + || this.signalCodeValue !== null + ) { + return false + } + this.killRequested = true + this.child.terminate() + return true + } + + /** Register a persistent process lifecycle listener. */ + on( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.on(event, listener) + } + + /** Register a one-shot process lifecycle listener. */ + once( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.once(event, listener) + } + + /** Remove a process lifecycle listener. */ + off( + event: 'exit' | 'error', + listener: ((code: number | null, signal: NodeJS.Signals | null) => void) + | ((error: Error) => void), + ): void { + this.events.off(event, listener) + } +} diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts new file mode 100644 index 0000000000..d5f222b6c4 --- /dev/null +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -0,0 +1,287 @@ +/** + * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its + * real CLI process under the shared subprocess owner, map only strict SDK + * success to completion, and dispose to whole-tree quiescence. + * + * @module @deepseek-ai/dsh-subagent-claude-code/run + */ + +import { randomUUID } from 'node:crypto' +import { + query as officialQuery, + type Options, + type Query, + type SDKMessage, + type SDKResultMessage, + type SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import { + scrubbedParentEnv, + type SubprocessHandle, + type SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import { + claudeSpawnSpec, + ManagedClaudeCodeProcess, +} from './process.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/* jscpd:ignore-start -- sibling providers intentionally keep product-private + * run inputs and error normalization instead of adding a shared lifecycle owner. */ +/** Fully resolved inputs for one official Claude Agent SDK query. */ +export interface ClaudeCodeRunSpec { + /** Parent Session workspace supplied to the SDK and real CLI. */ + readonly cwd: string + /** Explicit deployment/test environment layered after shared scrubbing. */ + readonly env: Record + /** Subprocess termination grace passed to the shared process-tree owner. */ + readonly disposeGraceMs: number + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} +/* jscpd:ignore-end */ + +/** + * Validate and preserve the one-shot task before crossing the SDK boundary. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact text sequence as one SDK prompt. + */ +export function textTask(prompt: readonly ContentBlock[]): string { + if (prompt.length === 0) { + throw new Error('subagent-claude-code: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-claude-code: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-claude-code: the one-shot task must not be empty') + } + return texts.join('') +} + +/** + * Strictly derive the only SDK result that can complete a shared run. + * @param message - an official discriminated result union. + * @returns exact final text for a successful, non-error result. + */ +export function successfulResult(message: SDKResultMessage): string { + if ( + message.subtype !== 'success' + || message.is_error + || message.result.trim().length === 0 + ) { + const detail = message.subtype === 'success' + ? 'success result was marked as an error or contained no answer' + : message.errors.join('; ') || message.subtype + throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`) + } + return message.result +} + +/** + * Consume the complete SDK stream and require one strict success plus normal + * iterator completion. + * @param query - published official SDK query. + * @returns the completed shared result. + */ +export async function consumeClaudeQuery( + query: AsyncIterable, +): Promise { + let answer: string | undefined + for await (const message of query) { + if (message.type !== 'result') continue + answer = successfulResult(message) + } + if (answer === undefined) { + throw new Error('subagent-claude-code: Claude Code ended without a result') + } + return { + output: [{ type: 'text', text: answer }], + stopReason: 'completed', + } +} + +/** + * Close the official query, terminate the managed process tree, and wait for + * the subprocess owner to prove it is gone. + * @param query - official SDK query, when creation reached that point. + * @param child - shared-service handle that owns the CLI process tree. + */ +export async function disposeClaudeCodeChild( + query: Pick | undefined, + child: SubprocessHandle, +): Promise { + const failures: Error[] = [] + try { + query?.close() + } catch (error: unknown) { + failures.push(thrown(error)) + } + + if (child.pid > 0) { + child.terminate() + try { + await child.waitForExit() + } catch (error: unknown) { + failures.push(thrown(error)) + } + } + try { + await child.done + } catch (error: unknown) { + failures.push(thrown(error)) + } + + const firstFailure = failures[0] + if (failures.length === 1 && firstFailure !== undefined) throw firstFailure + if (failures.length > 1) { + throw new AggregateError( + failures, + 'subagent-claude-code: query and process cleanup failed', + ) + } +} + +/** + * Build the fixed official SDK options for one one-shot provider run. + * @param spec - workspace, environment, process seam, and disposal policy. + * @param controller - per-run cancellation owner. + * @param capture - receives the real managed child synchronously from the SDK hook. + * @returns options that inherit native settings while disabling persistence and user questions. + */ +export function claudeQueryOptions( + spec: ClaudeCodeRunSpec, + controller: AbortController, + capture: (child: SubprocessHandle) => void, +): Options { + return { + abortController: controller, + cwd: spec.cwd, + env: { ...scrubbedParentEnv(), ...spec.env }, + persistSession: false, + disallowedTools: ['AskUserQuestion'], + spawnClaudeCodeProcess: (options: SpawnOptions) => { + const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) + capture(child) + return new ManagedClaudeCodeProcess(child) + }, + } +} + +/** + * Start one official Claude Agent SDK query and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - workspace, environment, process seam, and diagnostic policy. + * @returns the published run after both Query and real CLI handle exist. + */ +export async function startClaudeCodeRun( + request: SubagentStartRequest, + spec: ClaudeCodeRunSpec, +): Promise { + const prompt = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + + const controller = new AbortController() + const requestCancel = (): void => { + if (!controller.signal.aborted) { + controller.abort(new Error('subagent-claude-code: run cancelled locally')) + } + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + let child: SubprocessHandle | undefined + let query: Query | undefined + try { + query = officialQuery({ + prompt, + options: claudeQueryOptions(spec, controller, (captured) => { + child = captured + }), + }) + if (child === undefined || child.pid <= 0) { + throw new Error( + 'subagent-claude-code: official SDK did not publish a controllable Claude Code process', + ) + } + if (controller.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + const cancelledBeforeCleanup = controller.signal.aborted + requestCancel() + if (child !== undefined) { + try { + await disposeClaudeCodeChild(query, child) + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-claude-code: startup failed and CLI cleanup also failed', + ) + } + } else if (query !== undefined) { + try { + query.close() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-claude-code: startup failed and query cleanup also failed', + ) + } + } + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. + if (cancelledBeforeCleanup || request.signal.aborted) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + throw thrown(error) + } + + const publishedQuery = query + const publishedChild = child + const result = settleRunResult({ + attempt: () => consumeClaudeQuery(publishedQuery), + collectOutput: () => [], + cancelled: () => controller.signal.aborted, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: () => disposeClaudeCodeChild( + publishedQuery, + publishedChild, + ), + }) +} diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..51a2ea0025 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -0,0 +1,72 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/', + import.meta.url, +)) +const driver = join(fixtureDir, 'driver.ts') +const configPath = join(fixtureDir, 'cordis.yml') +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +describe('product-provider public Loader composition', () => { + it('loads both opt-in packages and foreground tools without starting either product', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'product-provider Loader composition', + tempDirPrefix: 'dsh-product-provider-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + // Loading either optional package must not probe or start its binary. + PATH: '', + }, + }) + + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + registeredProviders: ['codex', 'claude-code'], + providers: [ + { + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + { + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + ], + tools: [ + { + name: 'subagent_codex', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + { + name: 'subagent_claude_code', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + ], + starts: 0, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts new file mode 100644 index 0000000000..d8a04cf953 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -0,0 +1,150 @@ +import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http' + +/** One deterministic response emitted by the package-private Messages server. */ +export type MessagesBehavior = + | { readonly kind: 'complete'; readonly text: string } + | { readonly kind: 'hold' } + +/** One recorded Anthropic Messages request. */ +interface RecordedMessagesRequest { + readonly method: string + readonly path: string + readonly headers: IncomingHttpHeaders + readonly body: Record +} + +/** Running package-private Anthropic Messages fixture. */ +export interface MessagesFixture { + readonly baseUrl: string + readonly requests: RecordedMessagesRequest[] + readonly requestStarted: Promise + close(): Promise +} + +function event( + response: ServerResponse, + type: string, + payload: Record, +): void { + response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`) +} + +function complete( + response: ServerResponse, + body: Record, + text: string, +): void { + const model = typeof body.model === 'string' ? body.model : 'fixture-model' + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + event(response, 'message_start', { + type: 'message_start', + message: { + id: 'msg_dsh_fixture', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 7, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + event(response, 'content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }) + event(response, 'content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text }, + }) + event(response, 'content_block_stop', { + type: 'content_block_stop', + index: 0, + }) + event(response, 'message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 1 }, + }) + event(response, 'message_stop', { type: 'message_stop' }) + response.end() +} + +/** + * Start a loopback-only Anthropic Messages SSE fixture. + * @param behavior - the single response behavior for this fixture. + * @returns the bound server and its recorded requests. + */ +export async function startMessagesFixture( + behavior: MessagesBehavior, +): Promise { + const requests: RecordedMessagesRequest[] = [] + let requestStartedResolve!: () => void + const requestStarted = new Promise((resolve) => { + requestStartedResolve = resolve + }) + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + request.on('end', () => { + const path = request.url ?? '' + if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) { + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ + type: 'error', + error: { type: 'not_found_error', message: `unexpected path ${path}` }, + })) + return + } + const text = Buffer.concat(chunks).toString('utf8') + const body = JSON.parse(text) as Record + requests.push({ + method: request.method ?? '', + path, + headers: request.headers, + body, + }) + requestStartedResolve() + if (behavior.kind === 'complete') { + complete(response, body, behavior.text) + } + // A hold deliberately leaves the response pending until client abort. + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('Messages fixture did not bind a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + requestStarted, + async close(): Promise { + server.closeAllConnections() + await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + }) + }, + } +} diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts new file mode 100644 index 0000000000..806181ad13 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -0,0 +1,160 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as claudeCode from '../src/index.ts' + +const execFileAsync = promisify(execFile) +const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com' +const sdkRoot = dirname(fileURLToPath( + import.meta.resolve('@anthropic-ai/claude-agent-sdk'), +)) +const sdkPackage = JSON.parse(readFileSync( + join(sdkRoot, 'package.json'), + 'utf8', +)) as { + version: string + claudeCodeVersion: string + optionalDependencies: Record +} +const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}` +const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!) +const claudeBin = join( + platformRoot, + process.platform === 'win32' ? 'claude.exe' : 'claude', +) + +const roots: string[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function deepSeekBaseUrl(): string { + const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL) + .replace(/\/+$/, '') + if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) { + throw new Error('Claude Code DeepSeek e2e requires the official DeepSeek base URL') + } + return configured +} + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toHaveProperty('exitCode') + } +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)( + 'Claude Code provider with real DeepSeek API', + () => { + it('returns one unique nonce through the production provider and real SDK/CLI', async () => { + const apiKey = process.env.DEEPSEEK_API_KEY + if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-deepseek-e2e-')) + roots.push(root) + const workspace = join(root, 'workspace') + const claudeConfig = join(root, 'claude-config') + const xdgConfig = join(root, 'xdg-config') + const xdgCache = join(root, 'xdg-cache') + const xdgData = join(root, 'xdg-data') + const xdgState = join(root, 'xdg-state') + for (const directory of [ + workspace, + claudeConfig, + xdgConfig, + xdgCache, + xdgData, + xdgState, + ]) mkdirSync(directory) + + const env = { + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, + ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'deepseek-v4-pro[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v4-pro[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'deepseek-v4-flash', + CLAUDE_CODE_SUBAGENT_MODEL: 'deepseek-v4-flash', + CLAUDE_CODE_EFFORT_LEVEL: 'max', + CLAUDE_CONFIG_DIR: claudeConfig, + HOME: root, + XDG_CONFIG_HOME: xdgConfig, + XDG_CACHE_HOME: xdgCache, + XDG_DATA_HOME: xdgData, + XDG_STATE_HOME: xdgState, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1', + DISABLE_TELEMETRY: '1', + DISABLE_ERROR_REPORTING: '1', + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 }) + + expect(sdkPackage.version).toBe('0.3.220') + expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') + expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') + const version = await execFileAsync(claudeBin, ['--version'], { + env: { ...process.env, ...env }, + }) + expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') + + const nonce = `DSH_CLAUDE_DEEPSEEK_${randomUUID()}` + const parent = { + id: 'deepseek-e2e-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + const run = await ctx.subagents.start('claude-code', { + prompt: [{ + type: 'text', + text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`, + }], + parent, + signal: new AbortController().signal, + }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + const text = result.output + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .trim() + expect(text).toBe(nonce) + await expectQuiescent(handles) + }, 180_000) + }, +) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts new file mode 100644 index 0000000000..f76b4038f6 --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -0,0 +1,272 @@ +import { execFile } from 'node:child_process' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import type { + Query, + SDKMessage, + SDKSystemMessage, +} from '@anthropic-ai/claude-agent-sdk' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as claudeCode from '../src/index.ts' +import { + startMessagesFixture, + type MessagesBehavior, + type MessagesFixture, +} from './messages-fixture.ts' + +const observedSdkMessages = vi.hoisted((): SDKMessage[] => []) + +vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@anthropic-ai/claude-agent-sdk') + >() + return { + ...actual, + query(options: Parameters[0]): Query { + const query = actual.query(options) + // Observe the real SDK stream without replacing its protocol or CLI. + return new Proxy(query, { + get(target, property) { + if (property === Symbol.asyncIterator) { + return async function* (): AsyncGenerator { + for await (const message of target) { + observedSdkMessages.push(message) + yield message + } + } + } + const value: unknown = Reflect.get(target, property, target) + if (typeof value === 'function') { + const method = value as (...args: unknown[]) => unknown + return method.bind(target) + } + return value + }, + }) + }, + } +}) + +const execFileAsync = promisify(execFile) +const sdkRoot = dirname(fileURLToPath( + import.meta.resolve('@anthropic-ai/claude-agent-sdk'), +)) +const sdkPackage = JSON.parse(readFileSync( + join(sdkRoot, 'package.json'), + 'utf8', +)) as { + version: string + claudeCodeVersion: string + optionalDependencies: Record +} +const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}` +const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!) +const claudeBin = join( + platformRoot, + process.platform === 'win32' ? 'claude.exe' : 'claude', +) +const settingsModel = 'dsh-settings-inheritance-marker' +const fakeKey = 'dsh-fake-anthropic-key' + +const roots: string[] = [] +const fixtures: MessagesFixture[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + observedSdkMessages.length = 0 +}) + +interface RealHarness { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly parent: Agent + readonly workspace: string + readonly env: Record +} + +async function realHarness(behavior: MessagesBehavior): Promise<{ + readonly harness: RealHarness + readonly fixture: MessagesFixture +}> { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-')) + roots.push(root) + const workspace = join(root, 'workspace') + const claudeConfig = join(root, 'claude-config') + const xdgConfig = join(root, 'xdg') + mkdirSync(workspace) + mkdirSync(claudeConfig) + mkdirSync(xdgConfig) + writeFileSync( + join(claudeConfig, 'settings.json'), + `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, + ) + const fixture = await startMessagesFixture(behavior) + fixtures.push(fixture) + const env = { + ANTHROPIC_API_KEY: fakeKey, + ANTHROPIC_BASE_URL: fixture.baseUrl, + CLAUDE_CONFIG_DIR: claudeConfig, + HOME: root, + XDG_CONFIG_HOME: xdgConfig, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1', + DISABLE_TELEMETRY: '1', + DISABLE_ERROR_REPORTING: '1', + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 }) + const parent = { + id: 'real-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + return { + harness: { ctx, handles, parent, workspace, env }, + fixture, + } +} + +async function expectQuiescent( + handles: readonly SubprocessHandle[], +): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome).toHaveProperty('exitCode') + expect(outcome).toHaveProperty('signal') + } +} + +function startRequest( + harness: RealHarness, + prompt: string, + signal = new AbortController().signal, +) { + return harness.ctx.subagents.start('claude-code', { + prompt: [{ type: 'text', text: prompt }], + parent: harness.parent, + signal, + }) +} + +describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { + timeout: 60_000, +}, () => { + it('inherits host settings and sends the exact task and fake key to local Messages', async () => { + const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220' + const task = 'Return the fixture sentinel exactly.' + const { harness, fixture } = await realHarness({ + kind: 'complete', + text: sentinel, + }) + expect(sdkPackage.version).toBe('0.3.220') + expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') + expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') + const version = await execFileAsync(claudeBin, ['--version'], { + env: { ...process.env, ...harness.env }, + }) + expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') + + const run = await startRequest(harness, task) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + const initMessage = observedSdkMessages.find( + (message): message is SDKSystemMessage => + message.type === 'system' && message.subtype === 'init', + ) + expect(initMessage?.claude_code_version).toBe('2.1.220') + + expect(fixture.requests).toHaveLength(1) + const recorded = fixture.requests[0]! + expect(recorded.method).toBe('POST') + expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/) + expect(recorded.headers['x-api-key']).toBe(fakeKey) + expect(recorded.body.model).toBe(settingsModel) + expect(Array.isArray(recorded.body.messages)).toBe(true) + const messageTexts = ( + recorded.body.messages as Array<{ content?: unknown }> + ).flatMap((message): unknown[] => + Array.isArray(message.content) ? message.content as unknown[] : []) + .filter((block): block is { type: string; text: string } => + typeof block === 'object' + && block !== null + && 'type' in block + && block.type === 'text' + && 'text' in block + && typeof block.text === 'string') + .map(block => block.text) + expect(messageTexts.filter(text => text.includes(task))).toEqual([task]) + await expectQuiescent(harness.handles) + }) + + it('maps a real CLI process failure to error', async () => { + const { harness, fixture } = await realHarness({ kind: 'hold' }) + const run = await startRequest(harness, 'Exercise the failure path.') + await fixture.requestStarted + expect(harness.handles).toHaveLength(1) + harness.handles[0]!.terminate() + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await run.dispose() + expect(fixture.requests).toHaveLength(1) + expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey) + await expectQuiescent(harness.handles) + }) + + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { + const { harness, fixture } = await realHarness({ kind: 'hold' }) + const controller = new AbortController() + const run = await startRequest( + harness, + 'Wait for cancellation.', + controller.signal, + ) + await fixture.requestStarted + controller.abort(new Error('real product cancellation')) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await run.dispose() + await expectQuiescent(harness.handles) + }) +}) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts new file mode 100644 index 0000000000..8c4ac1708d --- /dev/null +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -0,0 +1,881 @@ +import { PassThrough } from 'node:stream' +import type { + Options, + Query, + SDKMessage, + SDKResultMessage, + SpawnOptions, +} from '@anthropic-ai/claude-agent-sdk' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { + SubprocessHandle, + SubprocessOutcome, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import * as claudeCode from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + claudeSpawnSpec, + ManagedClaudeCodeProcess, + sdkEnvironmentOverlay, +} from '../src/process.ts' +import { + claudeQueryOptions, + consumeClaudeQuery, + disposeClaudeCodeChild, + startClaudeCodeRun, + successfulResult, + textTask, + type ClaudeCodeRunSpec, +} from '../src/run.ts' + +type QueryFactory = (params: { + prompt: string + options: Options +}) => Query + +const queryMock = vi.hoisted(() => vi.fn()) + +vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({ + ...await importOriginal(), + query: queryMock, +})) + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +interface FakeChildOptions { + readonly pid?: number + readonly exitOnTerminate?: boolean + readonly waitForExitError?: Error + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly stdin: PassThrough + readonly stdout: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: Mock + readonly waitForExit: Mock +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const stdin = new PassThrough() + const stdout = new PassThrough() + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + // Individual tests deliberately exercise rejected and still-pending handles. + void done.catch(() => {}) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal): Promise => { + if (options.waitForExitError !== undefined) { + throw options.waitForExitError + } + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 1234, + stdin, + stdout, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + stdin, + stdout, + settle, + fail, + terminate, + waitForExit, + } +} + +function success( + result = 'answer', + isError = false, +): SDKResultMessage { + return { + type: 'result', + subtype: 'success', + is_error: isError, + result, + } as SDKResultMessage +} + +type ErrorSubtype = Exclude + +function failure( + subtype: ErrorSubtype, + errors: string[] = ['fixture failure'], +): SDKResultMessage { + return { + type: 'result', + subtype, + is_error: true, + errors, + } as SDKResultMessage +} + +function queryFrom( + messages: readonly SDKMessage[], + after?: Error, + close = vi.fn(), +): Query { + async function* stream(): AsyncGenerator { + for (const message of messages) yield message + if (after !== undefined) throw after + } + return Object.assign(stream(), { close }) as unknown as Query +} + +function waitingQuery(signal: AbortSignal, close = vi.fn()): Query { + async function* stream(): AsyncGenerator { + await new Promise((_resolve, reject) => { + const fail = (): void => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) + } + if (signal.aborted) fail() + else signal.addEventListener('abort', fail, { once: true }) + }) + } + return Object.assign(stream(), { close }) as unknown as Query +} + +function sdkSpawnOptions( + overrides: Partial = {}, +): SpawnOptions { + return { + command: '/sdk/claude', + args: ['--output-format', 'stream-json'], + cwd: '/workspace', + env: { PATH: '/bin', OMITTED: undefined }, + signal: new AbortController().signal, + ...overrides, + } +} + +interface FakeRun { + readonly child: FakeChild + readonly close: ReturnType + readonly spawnSpecs: SubprocessSpawnSpec[] + readonly options: Options[] + readonly spec: ClaudeCodeRunSpec +} + +function fakeRun( + messages: readonly SDKMessage[] = [success()], + after?: Error, + child = fakeChild(), +): FakeRun { + const close = vi.fn() + const query = queryFrom(messages, after, close) + const spawnSpecs: SubprocessSpawnSpec[] = [] + const options: FakeRun['options'] = [] + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: { ANTHROPIC_API_KEY: 'fake-key' }, + disposeGraceMs: 5, + spawn: (spawnSpec) => { + spawnSpecs.push(spawnSpec) + return child.handle + }, + } + queryMock.mockImplementation((params) => { + options.push(params.options) + params.options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return query + }) + return { child, close, spawnSpecs, options, spec } +} + +beforeEach(() => { + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions({ + cwd: options.cwd!, + env: options.env!, + signal: options.abortController!.signal, + })) + return queryFrom([]) + }) +}) + +afterEach(() => { + queryMock.mockReset() + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe('task admission and package contracts', () => { + it('preserves text sequences and rejects empty, blank, and non-text tasks', () => { + expect(textTask([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ])).toBe('onetwo') + expect(() => textTask([])).toThrow('only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'hidden' }])) + .toThrow('only text blocks') + expect(() => textTask([{ type: 'text', text: ' \n ' }])) + .toThrow('must not be empty') + }) + + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const fiber = await ctx.plugin(claudeCode, {}) + expect(ctx.subagents.getProvider('claude-code')).toMatchObject({ + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['claude-code']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(claudeCode, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await expect(ctx.plugin(claudeCode, { + disposeGraceMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow( + `disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + await ctx.fiber.dispose() + }) + + it('starts through the registered provider with its resolved config and diagnostics', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const child = fakeChild() + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + .mockImplementation(() => child.handle) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + await ctx.plugin(claudeCode, { + env: { + ANTHROPIC_API_KEY: 'provider-fake-key', + CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config', + HOME: '/private/tmp/dsh-claude-code-unit-home', + }, + disposeGraceMs: 29, + }) + + await expect(ctx.subagents.start('claude-code', { + ...request(), + parent: { + id: 'parent-without-cwd', + session: { header: {} }, + } as unknown as Agent, + })).rejects.toThrow( + 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', + ) + expect(queryMock).not.toHaveBeenCalled() + + const run = await ctx.subagents.start('claude-code', request()) + child.settle({ exitCode: 9, signal: null }) + child.stdout.end() + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining( + 'subagent-claude-code: child run failed (error):', + )) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + cwd: process.cwd(), + graceMs: 29, + })) + expect(spawn.mock.calls[0]?.[0].env).toMatchObject({ + ANTHROPIC_API_KEY: 'provider-fake-key', + }) + await run.dispose() + await ctx.fiber.dispose() + }) + + it('keeps the Loader namespace shape and package-owned empty invariant', async () => { + expect('default' in claudeCode).toBe(false) + expect(claudeCode.name).toBe('subagent-claude-code') + expect(claudeCode.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(claudeCode)).toBe(claudeCode) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-claude-code', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-claude-code-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('official spawn projection', () => { + it('forwards command, arguments, cwd, environment, and signal exactly', () => { + vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value') + const signal = new AbortController().signal + const options = sdkSpawnOptions({ + command: '/official/claude', + args: ['--one', 'two'], + cwd: '/parent/workspace', + env: { A: 'one', B: undefined, C: 'three' }, + signal, + }) + expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) + const spawnSpec = claudeSpawnSpec(options, 321) + expect(spawnSpec).toMatchObject({ + argv: ['/official/claude', '--one', 'two'], + cwd: '/parent/workspace', + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: 321, + signal, + }) + expect(spawnSpec.env).toEqual(expect.objectContaining({ + A: 'one', + B: undefined, + C: 'three', + SDK_REMOVED_AMBIENT: undefined, + })) + const missingCwd = sdkSpawnOptions() + delete missingCwd.cwd + expect(() => claudeSpawnSpec( + missingCwd, + 321, + )).toThrow('SDK spawn request omitted its workspace') + expect(() => claudeSpawnSpec( + sdkSpawnOptions({ cwd: '' }), + 321, + )).toThrow('SDK spawn request omitted its workspace') + }) + + it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const process = new ManagedClaudeCodeProcess(child.handle) + expect(process.stdin).toBe(child.stdin) + expect(process.stdout).toBe(child.stdout) + expect(process.killed).toBe(false) + expect(process.exitCode).toBeNull() + expect(process.signalCode).toBeNull() + + const exit = vi.fn() + const once = vi.fn() + const removed = vi.fn() + process.on('exit', exit) + process.once('exit', once) + process.on('exit', removed) + process.off('exit', removed) + expect(process.kill('SIGTERM')).toBe(true) + expect(process.killed).toBe(true) + expect(process.kill('SIGKILL')).toBe(false) + expect(child.terminate).toHaveBeenCalledOnce() + + child.settle({ exitCode: null, signal: 'SIGTERM' }) + await nextTask() + expect(exit).toHaveBeenCalledWith(null, 'SIGTERM') + expect(once).toHaveBeenCalledOnce() + expect(removed).not.toHaveBeenCalled() + expect(process.signalCode).toBe('SIGTERM') + expect(process.kill('SIGTERM')).toBe(false) + }) + + it('emits spawn errors', async () => { + const child = fakeChild() + const process = new ManagedClaudeCodeProcess(child.handle) + const errorListener = vi.fn() + const removed = vi.fn() + process.once('error', errorListener) + process.on('error', removed) + process.off('error', removed) + child.fail(new Error('spawn boom')) + await nextTask() + expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({ + message: 'spawn boom', + })) + expect(removed).not.toHaveBeenCalled() + }) + + it('exposes a settled direct-child exit code', async () => { + const child = fakeChild() + const process = new ManagedClaudeCodeProcess(child.handle) + child.settle({ exitCode: 7, signal: null }) + await nextTask() + expect(process.exitCode).toBe(7) + expect(process.signalCode).toBeNull() + expect(process.kill('SIGTERM')).toBe(false) + }) +}) + +describe('query options and result mapping', () => { + it('builds the fixed unattended options over the scrubbed environment', () => { + vi.stubEnv('HOST_VISIBLE', 'visible') + vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak') + vi.stubEnv('DSH_INTERNAL', 'must-not-leak') + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const captured: SubprocessHandle[] = [] + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: { + HOST_VISIBLE: 'overridden', + ANTHROPIC_API_KEY: 'explicit-fake-key', + }, + disposeGraceMs: 17, + spawn, + } + const controller = new AbortController() + const options = claudeQueryOptions(spec, controller, (value) => { + captured.push(value) + }) + + expect(options).toMatchObject({ + abortController: controller, + cwd: '/workspace', + persistSession: false, + disallowedTools: ['AskUserQuestion'], + }) + expect(options.env).toMatchObject({ + HOST_VISIBLE: 'overridden', + ANTHROPIC_API_KEY: 'explicit-fake-key', + }) + expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN') + expect(options.env).not.toHaveProperty('DSH_INTERNAL') + for (const omitted of [ + 'settingSources', + 'canUseTool', + 'onElicitation', + 'onUserDialog', + 'supportedDialogKinds', + ]) { + expect(options).not.toHaveProperty(omitted) + } + + const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess) + expect(captured).toEqual([child.handle]) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: ['/sdk/claude', '--output-format', 'stream-json'], + cwd: '/workspace', + graceMs: 17, + })) + }) + + it('accepts only a non-error success with a non-blank final result', () => { + expect(successfulResult(success('exact final'))).toBe('exact final') + expect(() => successfulResult(success('answer', true))) + .toThrow('marked as an error') + expect(() => successfulResult(success(' \n '))) + .toThrow('contained no answer') + expect(() => successfulResult(failure( + 'error_during_execution', + ['first', 'second'], + ))).toThrow('first; second') + expect(() => successfulResult(failure( + 'error_max_turns', + [], + ))).toThrow('error_max_turns') + }) + + it('consumes the complete stream and keeps the latest strict success', async () => { + const query = queryFrom([ + { type: 'system', subtype: 'init' } as SDKMessage, + success('first'), + success('last'), + ]) + await expect(consumeClaudeQuery(query)).resolves.toEqual({ + output: [{ type: 'text', text: 'last' }], + stopReason: 'completed', + }) + await expect(consumeClaudeQuery( + queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), + )).rejects.toThrow('ended without a result') + }) +}) + +describe('run publication, cancellation, and settlement', () => { + it('publishes only after Query and managed child exist, then disposes once', async () => { + const fixture = fakeRun([success('exact answer')]) + const run = await startClaudeCodeRun( + request([ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' }, + ]), + fixture.spec, + ) + expect(fixture.options).toHaveLength(1) + expect(fixture.spawnSpecs).toHaveLength(1) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'exact answer' }], + stopReason: 'completed', + }) + const first = run.dispose() + const second = run.dispose() + expect(second).toBe(first) + await first + expect(fixture.close).toHaveBeenCalledOnce() + expect(fixture.child.terminate).toHaveBeenCalledOnce() + }) + + it('flattens every SDK error result without inventing shared stop reasons', async () => { + const subtypes: ErrorSubtype[] = [ + 'error_during_execution', + 'error_max_turns', + 'error_max_budget_usd', + 'error_max_structured_output_retries', + ] + for (const subtype of subtypes) { + const fixture = fakeRun([failure(subtype)]) + const onError = vi.fn() + const run = await startClaudeCodeRun( + request(), + { ...fixture.spec, onError }, + ) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + 'error', + ) + await run.dispose() + } + }) + + it('fails closed when iteration rejects after a result', async () => { + const fixture = fakeRun( + [success('partial final')], + new Error('iterator boom'), + ) + const run = await startClaudeCodeRun(request(), fixture.spec) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await run.dispose() + }) + + it('maps invalid success and missing result to error', async () => { + for (const messages of [ + [success('answer', true)], + [success('')], + [{ type: 'system', subtype: 'init' } as SDKMessage], + ]) { + const fixture = fakeRun(messages) + const run = await startClaudeCodeRun(request(), fixture.spec) + await expect(run.result).resolves.toMatchObject({ + stopReason: 'error', + }) + await run.dispose() + } + }) + + it('gives local cancellation precedence and isolates overlapping controllers', async () => { + const firstChild = fakeChild() + const secondChild = fakeChild() + const children = [firstChild, secondChild] + const controllers: AbortController[] = [] + let index = 0 + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + env: {}, + disposeGraceMs: 5, + spawn: () => children[index++]!.handle, + } + queryMock.mockImplementation(({ prompt, options }) => { + controllers.push(options.abortController!) + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'wait' + ? waitingQuery(options.abortController!.signal) + : queryFrom([success('second answer')]) + }) + const firstAbort = new AbortController() + const first = await startClaudeCodeRun( + request([{ type: 'text', text: 'wait' }], firstAbort.signal), + spec, + ) + const second = await startClaudeCodeRun( + request([{ type: 'text', text: 'finish' }]), + spec, + ) + expect(controllers).toHaveLength(2) + expect(controllers[0]).not.toBe(controllers[1]) + firstAbort.abort(new Error('parent cancelled')) + await expect(first.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await expect(second.result).resolves.toEqual({ + output: [{ type: 'text', text: 'second answer' }], + stopReason: 'completed', + }) + expect(controllers[1]!.signal.aborted).toBe(false) + await Promise.all([first.dispose(), second.dispose()]) + }) + + it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => { + const parentAbort = new AbortController() + const child = fakeChild() + async function* stream(): AsyncGenerator { + yield success('candidate answer') + parentAbort.abort(new Error('parent cancelled at iterator completion')) + } + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return Object.assign(stream(), { close: vi.fn() }) as unknown as Query + }) + const run = await startClaudeCodeRun( + request(undefined, parentAbort.signal), + { + cwd: '/workspace', + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }, + ) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await run.dispose() + }) + + it('rejects pre-abort and every incomplete startup transaction', async () => { + const preAborted = new AbortController() + preAborted.abort() + const unused = fakeRun() + await expect(startClaudeCodeRun( + request(undefined, preAborted.signal), + unused.spec, + )).rejects.toThrow('aborted before SDK startup') + expect(unused.options).toEqual([]) + + const noChildClose = vi.fn() + queryMock.mockImplementationOnce( + () => queryFrom([], undefined, noChildClose), + ) + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + })).rejects.toThrow('did not publish a controllable') + expect(noChildClose).toHaveBeenCalledOnce() + + const closeFailure = vi.fn(() => { throw new Error('close boom') }) + queryMock.mockImplementationOnce( + () => queryFrom([], undefined, closeFailure), + ) + const noChild = startClaudeCodeRun(request(), { + ...unused.spec, + }) + await expect(noChild).rejects.toBeInstanceOf(AggregateError) + + const startupAbort = new AbortController() + const abortedChild = fakeChild() + const abortedClose = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + startupAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, abortedClose) + }) + const abortedDuringStartup = startClaudeCodeRun( + request(undefined, startupAbort.signal), + { + ...unused.spec, + spawn: () => abortedChild.handle, + }, + ) + await expect(abortedDuringStartup) + .rejects.toThrow('aborted before SDK startup') + expect(abortedClose).toHaveBeenCalledOnce() + expect(abortedChild.terminate).toHaveBeenCalledOnce() + + queryMock.mockImplementationOnce(() => { + throw new Error('query failed before resource creation') + }) + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + })).rejects.toThrow('query failed before resource creation') + + const spawned = fakeChild() + const spawnSpecs: SubprocessSpawnSpec[] = [] + let factoryController: AbortController | undefined + queryMock.mockImplementationOnce(({ options }) => { + factoryController = options.abortController + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw new Error('query construction failed') + }) + const factoryFailure = startClaudeCodeRun(request(), { + ...unused.spec, + spawn: (spawnSpec) => { + spawnSpecs.push(spawnSpec) + return spawned.handle + }, + }) + await expect(factoryFailure).rejects.toThrow('query construction failed') + expect(spawnSpecs).toHaveLength(1) + expect(factoryController?.signal.aborted).toBe(true) + expect(spawned.terminate).toHaveBeenCalledOnce() + + const failedSpawn = fakeChild({ + pid: -1, + doneError: new Error('spawn failed'), + }) + const failed = fakeRun([], undefined, failedSpawn) + await expect(startClaudeCodeRun(request(), failed.spec)) + .rejects.toBeInstanceOf(AggregateError) + expect(failed.close).toHaveBeenCalledOnce() + }) +}) + +describe('query and process disposal', () => { + it('closes the query, terminates the tree, and waits for direct-child outcome', async () => { + const child = fakeChild() + const close = vi.fn() + await disposeClaudeCodeChild({ close }, child.handle) + expect(close).toHaveBeenCalledOnce() + expect(child.terminate).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledWith() + await expect(child.handle.done).resolves.toEqual({ + exitCode: 0, + signal: null, + }) + }) + + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + let disposed = false + const disposal = disposeClaudeCodeChild( + { close: vi.fn() }, + child.handle, + ).then(() => { + disposed = true + }) + await nextTask() + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) + }) + + it('reports wait, close, and direct-child failures without skipping cleanup', async () => { + const waitFailure = fakeChild({ + waitForExitError: new Error('wait boom'), + }) + const closeFailure = vi.fn(() => { throw new Error('close boom') }) + await expect(disposeClaudeCodeChild( + { close: closeFailure }, + waitFailure.handle, + )).rejects.toBeInstanceOf(AggregateError) + expect(waitFailure.terminate).toHaveBeenCalledOnce() + + const doneFailure = fakeChild({ + pid: -1, + doneError: new Error('spawn boom'), + }) + await expect(disposeClaudeCodeChild( + { close: vi.fn() }, + doneFailure.handle, + )).rejects.toThrow('spawn boom') + + const both = fakeChild({ + pid: -1, + doneError: new Error('spawn boom'), + }) + await expect(disposeClaudeCodeChild( + { close: () => { throw new Error('close boom') } }, + both.handle, + )).rejects.toBeInstanceOf(AggregateError) + }) +}) diff --git a/packages/subagent/subagent-claude-code/tsconfig.json b/packages/subagent/subagent-claude-code/tsconfig.json new file mode 100644 index 0000000000..751aa08a9e --- /dev/null +++ b/packages/subagent/subagent-claude-code/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/types/.tsbuildinfo" + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml new file mode 100644 index 0000000000..97c2b9f705 --- /dev/null +++ b/packages/subagent/subagent-codex/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/subagent/subagent-codex/README.md +README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f +README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md new file mode 100644 index 0000000000..c25ee90edf --- /dev/null +++ b/packages/subagent/subagent-codex/README.md @@ -0,0 +1,90 @@ +# @deepseek-ai/dsh-subagent-codex + +English | [中文](README.zh.md) + +This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. + +## Start and ownership + +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. + +The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. + +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. + +Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and this version produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. + +## Capabilities and context + +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | + +Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. + +Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default. + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## Product compatibility and evidence + +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. + +## Model Experience + +### Child request + +#### What the model sees + +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. + +#### Token effect + +The child pays for an independent Codex context and turn. Child tokens do not enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Reuse depends only on Codex's own provider, model, instructions, tools, and ephemeral-thread request. + +### Parent tool result, indirectly + +#### What the model sees + +Through `dsh-tool-subagent`, the parent sees only the selected final Codex answer or the consumer's exact error for a non-completed result. Codex commentary, reasoning, tool activity, stderr, workspace diffs, and product ids are not copied into the parent Session. + +#### Token effect + +Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself. + +#### KV Cache effect + +Append-only: the new tool result follows the reusable parent request prefix. + +## Known Limitations and Deferred Work + +- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. +- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. +- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. +- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. +- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md new file mode 100644 index 0000000000..10c8fcc47a --- /dev/null +++ b/packages/subagent/subagent-codex/README.zh.md @@ -0,0 +1,90 @@ +# @deepseek-ai/dsh-subagent-codex + +[English](README.md) | 中文 + +本包(package)注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。 + +## 启动与所有权 + +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 + +已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 + +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 + +本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且本版本不会产生 `refusal`。`dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 + +## 能力与上下文 + +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 + +## 配置 + +| 配置键 | 默认值 | 含义 | +|---|---|---| +| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | + +生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 + +请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。 + +```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + config: + env: + OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY + +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed +``` + +## 产品兼容性与证据 + +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 + +## 模型体验 + +### 子任务请求 + +#### 模型看到的内容 + +Codex 子任务会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 + +#### 对 token 的影响 + +子任务需为独立的 Codex 上下文和轮次承担 token 开销。子任务 token 不会进入父级上下文。 + +#### 对 KV Cache 的影响 + +这与父请求缓存相互独立。能否复用只取决于 Codex 自身的提供方、模型、指令、工具和临时线程请求。 + +### 父级工具结果(间接) + +#### 模型看到的内容 + +通过 `dsh-tool-subagent`,父级模型只会看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异和产品标识符均不会复制到父会话。 + +#### 对 token 的影响 + +父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。 + +#### 对 KV Cache 的影响 + +仅追加:新的工具结果接在可复用的父请求前缀之后。 + +## 已知限制与后续工作 + +- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 +- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 +- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 +- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json new file mode 100644 index 0000000000..873cf1d77c --- /dev/null +++ b/packages/subagent/subagent-codex/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-subagent-codex", + "description": "One-shot Codex subagent provider over the official app-server protocol", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@openai/codex": "0.146.0", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts new file mode 100644 index 0000000000..23077e3b54 --- /dev/null +++ b/packages/subagent/subagent-codex/src/index.ts @@ -0,0 +1,101 @@ +/** + * Fixed Codex one-shot subagent provider. Every accepted run starts a fresh + * official `codex app-server --stdio` process in the delegating Session's + * workspace and publishes only after an ephemeral thread exists. + * + * @module @deepseek-ai/dsh-subagent-codex + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { + assertPositiveFinite, + NO_START_CAPABILITIES, + resolveChildCwd, + type ResolvedSubagentStartRequest, + type SubagentCapabilities, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { + DEFAULT_DISPOSE_GRACE_MS, + startCodexRun, + type CodexRunSpec, +} from './run.ts' + +export const name = 'subagent-codex' +export const inject = ['subagents', 'subprocess'] + +/** Deployment-owned environment and process-release bound. */ +export interface Config { + /** + * Explicit environment entries layered over the subprocess seam's + * credential-scrubbed parent environment. + */ + env?: Record + /** Grace in milliseconds for app-server process-tree termination. */ + disposeGraceMs?: number +} + +export const Config: z = z.object({ + env: z.dict(z.string()).default({}), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), +}) + +type ResolvedConfig = Required + +class CodexProvider implements SubagentProvider { + readonly name = 'codex' + readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + + constructor( + private readonly ctx: Context, + private readonly config: ResolvedConfig, + ) {} + + start(request: ResolvedSubagentStartRequest) { + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error( + 'subagent-codex: no working directory for the child — delegate from a parent session that has one', + ) + } + const spec: CodexRunSpec = { + cwd: resolveChildCwd( + 'subagent-codex', + undefined, + parentCwd, + ), + env: this.config.env, + disposeGraceMs: this.config.disposeGraceMs, + spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), + onError: (error, stopReason) => { + this.ctx.logger.warn( + `subagent-codex: child run failed (${stopReason}): ${error.message}`, + ) + }, + } + return startCodexRun(request, spec) + } +} + +/** + * Register the fixed `codex` provider. + * @param ctx - context carrying shared subagent and subprocess services. + * @param config - explicit child environment and disposal grace. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite( + 'subagent-codex', + 'disposeGraceMs', + resolved.disposeGraceMs, + ) + if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) { + throw new Error( + `subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + ctx.subagents.registerProvider(new CodexProvider(ctx, resolved)) +} diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts new file mode 100644 index 0000000000..a0c094af9c --- /dev/null +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`. + * @module @deepseek-ai/dsh-subagent-codex/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex' + +/** Cordis companion plugin name. */ +export const name = 'subagent-codex-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: lifecycle pairing belongs to the shared subagent + * service and process-tree ownership belongs to the subprocess service. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - plugin context carrying the invariant registry. + * @returns the installed registration's disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts new file mode 100644 index 0000000000..c3ebf4ba19 --- /dev/null +++ b/packages/subagent/subagent-codex/src/run.ts @@ -0,0 +1,200 @@ +/** + * One-shot Codex child lifecycle: spawn the real app-server through the + * subprocess seam, publish only after initialization and ephemeral thread + * creation, flatten post-publication failures, and dispose to whole-tree + * quiescence. + * + * @module @deepseek-ai/dsh-subagent-codex/run + */ + +import { randomUUID } from 'node:crypto' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + settleRunResult, + subprocessRunHandle, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, + type SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { CodexAppServerWire } from './wire.ts' + +/** Default POSIX grace between subprocess termination tiers. */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** + * Resolve the fixed app-server command for a platform. + * + * Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`; + * the argv is constant so no task or configuration text enters the + * shell boundary. + * @param platform - host platform used to select the executable boundary. + * @returns argv for the fixed Codex app-server command. + */ +export function codexAppServerArgv( + platform: NodeJS.Platform = process.platform, +): string[] { + return platform === 'win32' + ? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio'] + : ['codex', 'app-server', '--stdio'] +} + +/** Fully resolved inputs for one Codex app-server run. */ +export interface CodexRunSpec { + /** Parent Session workspace, also supplied to `thread/start`. */ + readonly cwd: string + /** Explicit deployment/test environment layered after the shared scrub. */ + readonly env: Record + /** Subprocess termination grace passed to the shared process-tree owner. */ + readonly disposeGraceMs: number + /** Shared subprocess service spawn operation. */ + readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle + /** Diagnostic sink for a post-publication error flattened into a result. */ + readonly onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed subprocess/wire failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** + * Validate and preserve the one-shot task before crossing the process seam. + * @param prompt - task content accepted from the shared subagent service. + * @returns the exact non-empty text block sequence. + */ +export function textTask(prompt: readonly ContentBlock[]): string[] { + if (prompt.length === 0) { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + const texts: string[] = [] + for (const block of prompt) { + if (block.type !== 'text') { + throw new Error('subagent-codex: the one-shot task must contain only text blocks') + } + texts.push(block.text) + } + if (texts.every(text => text.trim().length === 0)) { + throw new Error('subagent-codex: the one-shot task must not be empty') + } + return texts +} + +/** + * Close the private wire, terminate the managed process tree, and wait for the + * subprocess owner to prove it is gone. + * @param wire - private app-server protocol connection. + * @param child - shared-service handle that owns the process tree. + */ +export async function disposeCodexChild( + wire: CodexAppServerWire, + child: SubprocessHandle, +): Promise { + wire.close() + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + try { + child.stdin?.end() + } catch { + // A concurrently closed stdin does not change tree ownership below. + } + child.terminate() + await child.waitForExit() + await child.done +} + +/** + * Start the real `codex app-server --stdio` child and publish its one-shot run. + * @param request - resolved shared subagent request. + * @param spec - workspace, environment, process seam, and diagnostic policy. + * @returns the published run after initialization and ephemeral thread creation. + */ +export async function startCodexRun( + request: SubagentStartRequest, + spec: CodexRunSpec, +): Promise { + const texts = textTask(request.prompt) + if (request.signal.aborted) { + throw new Error('subagent-codex: request was aborted before app-server startup') + } + + const child = spec.spawn({ + argv: codexAppServerArgv(), + cwd: spec.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + env: spec.env, + }) + + const wire = new CodexAppServerWire( + child.stdout as NonNullable, + child.stdin as NonNullable, + ) + const disposeProcess = (): Promise => disposeCodexChild(wire, child) + + const processFailure: Promise = child.done.then( + outcome => Promise.reject(new Error( + 'subagent-codex: app-server exited before the run settled ' + + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, + )), + (error: unknown) => Promise.reject(thrown(error)), + ) + // A normal post-result dispose also closes the process. Keep that expected + // late rejection observed after the result race has already settled. + processFailure.catch(() => {}) + + const runAbort = new AbortController() + const requestCancel = (): void => { + if (runAbort.signal.aborted) return + runAbort.abort(new Error('subagent-codex: run cancelled locally')) + wire.interrupt() + } + const onAbort = (): void => { requestCancel() } + request.signal.addEventListener('abort', onAbort, { once: true }) + + try { + wire.start() + await Promise.race([wire.initialize(request.signal), processFailure]) + await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) + } catch (error: unknown) { + request.signal.removeEventListener('abort', onAbort) + try { + await disposeProcess() + } catch (disposeError: unknown) { + throw new AggregateError( + [thrown(error), thrown(disposeError)], + 'subagent-codex: startup failed and app-server cleanup also failed', + ) + } + if (runAbort.signal.aborted) { + throw new Error('subagent-codex: request was aborted before run publication') + } + throw thrown(error) + } + + const collectOutput = (): ContentBlock[] => wire.collectOutput() + const result: Promise = settleRunResult({ + attempt: () => Promise.race([ + wire.runTurn(texts, runAbort.signal), + processFailure, + ]), + collectOutput, + cancelled: () => runAbort.signal.aborted, + onError: spec.onError, + signal: request.signal, + onAbort, + }) + + return subprocessRunHandle({ + id: SessionId(randomUUID()), + result, + signal: request.signal, + onAbort, + requestCancel, + teardown: disposeProcess, + }) +} diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts new file mode 100644 index 0000000000..51be212841 --- /dev/null +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -0,0 +1,374 @@ +/** + * Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC + * transport owns framing and request correlation; this module owns only the + * product methods, current thread/turn association, unattended approval + * responses, and terminal-answer selection. + * + * @module @deepseek-ai/dsh-subagent-codex/wire + */ + +import type { Readable, Writable } from 'node:stream' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult } from '@deepseek-ai/dsh-subagent' +import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' + +type JsonObject = Record + +function object(value: unknown, label: string): JsonObject { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value as JsonObject +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`subagent-codex: app-server returned invalid ${label}`) + } + return value +} + +function unattendedDecision(params: JsonObject): 'cancel' | 'decline' { + const available = params.availableDecisions + if (available === undefined || available === null) return 'decline' + if (Array.isArray(available)) { + if (available.includes('cancel')) return 'cancel' + if (available.includes('decline')) return 'decline' + } + throw new Error('subagent-codex: app-server offered no unattended approval decision') +} + +function isContextWindowExceeded(turn: JsonObject): boolean { + if (turn.status !== 'failed') return false + const error = turn.error + return error !== null + && typeof error === 'object' + && !Array.isArray(error) + && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' +} + +function thrown(value: unknown): Error { + /* v8 ignore next -- typed protocol and stream failures reject with Error. */ + return value instanceof Error ? value : new Error(String(value)) +} + +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`) +} + +async function raceAbort(pending: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + void pending.catch(() => {}) + throw abortError(signal) + } + let rejectAbort!: (error: Error) => void + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { rejectAbort(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([pending, aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** + * One app-server connection and its single ephemeral thread/turn. + * + * The class deliberately exposes no generic request surface. Supporting + * another product method must first become part of the provider contract. + */ +export class CodexAppServerWire { + private readonly transport: JsonRpcLineTransport + private readonly fatal = Promise.withResolvers() + private threadId: string | undefined + private turnId: string | undefined + private pendingTurnId: string | undefined + private turnCompleted: PromiseWithResolvers | undefined + private readonly earlyTurnNotifications: Array<{ + readonly method: string + readonly params: JsonObject + }> = [] + private lastFinalAnswer: string | undefined + private lastUnphasedAnswer: string | undefined + private closed = false + + constructor( + private readonly input: Readable, + output: Writable, + ) { + this.transport = new JsonRpcLineTransport(input, output) + // Fatal protocol state can arrive after the current guarded operation has + // already settled. Keep the shared rejection observed without inserting + // another promise-adoption hop into active races. + void this.fatal.promise.catch(() => {}) + this.transport.onRequest((method, params) => this.handleServerRequest(method, params)) + this.transport.onNotification((method, params) => { + try { + this.handleNotification(method, params) + } catch (error: unknown) { + this.fail(thrown(error)) + } + }) + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + // Pipe errors can race protocol closure and process teardown. Retain both + // error listeners for the lifetime of their per-run streams so no late + // EPIPE or read failure becomes an unhandled EventEmitter error. + output.on('error', this.onOutputError) + } + + /** Start reading app-server frames. */ + start(): void { + this.transport.start() + } + + /** + * Perform the required app-server initialize/initialized handshake. + * @param signal - unpublished-start cancellation. + */ + async initialize(signal: AbortSignal): Promise { + object(await this.guarded(this.transport.request('initialize', { + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }, signal), signal), 'initialize response') + this.transport.notify('initialized') + await this.guarded(this.transport.flush(), signal) + } + + /** + * Create the run's private ephemeral thread and retain its identity. + * @param cwd - parent Session workspace. + * @param signal - unpublished-start cancellation. + */ + async startThread(cwd: string, signal: AbortSignal): Promise { + const response = object(await this.guarded(this.transport.request('thread/start', { + cwd, + ephemeral: true, + }, signal), signal), 'thread/start response') + const thread = object(response.thread, 'thread/start thread') + const id = string(thread.id, 'thread/start thread id') + if (thread.ephemeral !== true) { + throw new Error('subagent-codex: app-server did not create an ephemeral thread') + } + this.threadId = id + } + + /** + * Submit the one text-only task and wait for this thread/turn's authoritative + * terminal notification. + * @param texts - already validated task text blocks. + * @param signal - local cancellation for the published run. + * @returns the shared subagent result. + */ + async runTurn( + texts: readonly string[], + signal: AbortSignal, + ): Promise { + const completion = Promise.withResolvers() + this.turnCompleted = completion + const threadId = this.threadId as string + const response = object(await this.guarded(this.transport.request('turn/start', { + threadId, + input: texts.map(text => ({ type: 'text', text, text_elements: [] })), + }, signal), signal), 'turn/start response') + const turn = object(response.turn, 'turn/start turn') + this.commitTurnId(string(turn.id, 'turn/start turn id')) + + const completed = await this.guarded(completion.promise, signal) + const terminal = object(completed.turn, 'turn/completed turn') + const status = terminal.status + if (isContextWindowExceeded(terminal)) { + return { output: this.collectOutput(), stopReason: 'max-tokens' } + } + if (status !== 'completed') { + const detail = status === 'failed' + ? `: ${JSON.stringify(terminal.error)}` + : '' + throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) + } + const output = this.collectOutput() + if (output.length === 0) { + throw new Error('subagent-codex: Codex completed without a final answer') + } + return { output, stopReason: 'completed' } + } + + /** + * Best-effort remote cancellation. Local settlement and process teardown + * remain authoritative when the child no longer accepts protocol requests. + */ + interrupt(): void { + if (this.threadId === undefined || this.turnId === undefined || this.closed) return + void this.transport.request('turn/interrupt', { + threadId: this.threadId, + turnId: this.turnId, + }).catch(() => {}) + } + + /** + * The best non-commentary answer observed so far, preserving exact bytes. + * @returns the selected final or nullable-phase text block, if any. + */ + collectOutput(): ContentBlock[] { + const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer + return selected !== undefined && selected.trim().length > 0 + ? [{ type: 'text', text: selected }] + : [] + } + + /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.input.off('end', this.onInputEnd) + this.transport.close() + } + + private async guarded(pending: Promise, signal: AbortSignal): Promise { + const withFatal = Promise.race([this.fatal.promise, pending]) + return raceAbort(withFatal, signal) + } + + private fail(error: Error): void { + this.fatal.reject(error) + } + + private readonly onInputError = (error: Error): void => { + this.fail(error) + } + + private readonly onOutputError = (error: Error): void => { + this.fail(error) + } + + private readonly onInputEnd = (): void => { + this.fail(new Error('subagent-codex: app-server protocol stream closed')) + } + + private observePendingTurnId(id: string): void { + if (this.turnCompleted === undefined) { + throw new Error('subagent-codex: app-server referenced a turn before turn/start') + } + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: app-server referenced conflicting turns') + } + this.pendingTurnId = id + } + + private commitTurnId(id: string): void { + if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { + throw new Error('subagent-codex: turn/start response did not match the active turn') + } + this.turnId = id + const notifications = this.earlyTurnNotifications.splice(0) + for (const notification of notifications) { + this.handleNotification(notification.method, notification.params) + } + } + + private validateRunIds(params: JsonObject, nullableTurn = false): void { + if (params.threadId !== this.threadId) { + throw new Error('subagent-codex: app-server request referenced another thread') + } + if (nullableTurn && params.turnId === null) return + const id = string(params.turnId, 'server request turn id') + if (this.turnId === undefined) { + this.observePendingTurnId(id) + return + } + if (id !== this.turnId) { + throw new Error('subagent-codex: app-server request referenced another turn') + } + } + + private handleServerRequest(method: string, params: JsonObject): Promise { + try { + switch (method) { + case 'item/commandExecution/requestApproval': + case 'item/fileChange/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ decision: unattendedDecision(params) }) + case 'item/permissions/requestApproval': + this.validateRunIds(params) + return Promise.resolve({ permissions: {}, scope: 'turn' }) + case 'item/tool/requestUserInput': + this.validateRunIds(params) + return Promise.resolve({ answers: {} }) + case 'mcpServer/elicitation/request': + this.validateRunIds(params, true) + return Promise.resolve({ action: 'decline', content: null, _meta: null }) + default: + throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) + } + } catch (error: unknown) { + const normalized = thrown(error) + this.fail(normalized) + return Promise.reject(normalized) + } + } + + private handleNotification(method: string, params: JsonObject): void { + if (method === 'turn/started') { + const threadId = string(params.threadId, 'turn/started thread id') + if (threadId !== this.threadId) return + const turn = object(params.turn, 'turn/started turn') + if (this.turnCompleted !== undefined && this.turnId === undefined) { + this.observePendingTurnId(string(turn.id, 'turn/started turn id')) + } + return + } + if (method === 'item/completed') { + const threadId = string(params.threadId, 'item/completed thread id') + if (threadId !== this.threadId) return + const id = string(params.turnId, 'item/completed turn id') + if (this.turnId === undefined) { + if (this.turnCompleted !== undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + } + return + } + if (id !== this.turnId) return + const item = object(params.item, 'item/completed item') + if (item.type !== 'agentMessage') return + const text = typeof item.text === 'string' + ? item.text + : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })() + if (item.phase === 'final_answer') { + this.lastFinalAnswer = text + } else if (item.phase === null) { + this.lastUnphasedAnswer = text + } else if (item.phase !== 'commentary') { + throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`) + } + return + } + if (method !== 'turn/completed') return + const threadId = string(params.threadId, 'turn/completed thread id') + if (threadId !== this.threadId) return + const turn = object(params.turn, 'turn/completed turn') + const id = string(turn.id, 'turn/completed turn id') + const turnCompleted = this.turnCompleted + if (turnCompleted === undefined) return + if (this.turnId === undefined) { + this.observePendingTurnId(id) + this.earlyTurnNotifications.push({ method, params }) + return + } + if (id !== this.turnId) return + if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { + throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) + } + turnCompleted.resolve(params) + } +} diff --git a/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts new file mode 100644 index 0000000000..b59738031e --- /dev/null +++ b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts @@ -0,0 +1,190 @@ +import { createServer } from 'node:http' +import type { + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' +import { completeResponsesEvents } from './responses-fixture.ts' + +const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com' +const MAX_REQUEST_BYTES = 1_048_576 + +/** One running test-only Responses-to-DeepSeek bridge. */ +export interface DeepSeekResponsesBridge { + readonly baseUrl: string + readonly completedRequests: number + close(): Promise +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { + body += chunk + if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) { + request.destroy(new Error('DeepSeek bridge request exceeded its byte limit')) + } + }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +function taskText(body: Record): string { + const input = responseInputTexts(body).join('\n') + if (input.trim().length > 0) return input + return typeof body.instructions === 'string' ? body.instructions : '' +} + +function deepSeekBaseUrl(): string { + const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL) + .replace(/\/+$/, '') + if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) { + throw new Error('Codex DeepSeek e2e requires the official DeepSeek base URL') + } + return configured +} + +async function completeWithDeepSeek( + authorization: string, + task: string, +): Promise { + const response = await fetch(`${deepSeekBaseUrl()}/chat/completions`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-v4-flash', + messages: [ + { + role: 'system', + content: 'Follow the user instruction and return only the requested nonce.', + }, + { role: 'user', content: task }, + ], + temperature: 0, + max_tokens: 64, + stream: false, + }), + }) + if (!response.ok) { + void response.body?.cancel() + throw new Error(`DeepSeek bridge upstream returned HTTP ${response.status}`) + } + const payload = await response.json() as { + choices?: Array<{ message?: { content?: unknown } }> + } + const content = payload.choices?.[0]?.message?.content + if (typeof content !== 'string' || content.trim().length === 0) { + throw new Error('DeepSeek bridge upstream returned no text') + } + return content +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start the single-purpose loopback bridge used by the Codex credentialed e2e. + * @param nonce - unique answer the incoming Responses task must request. + * @returns loopback endpoint, completion count, and close operation. + */ +export async function startDeepSeekResponsesBridge( + nonce: string, +): Promise { + let seenRequests = 0 + let completedRequests = 0 + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void (async () => { + if (request.method !== 'POST' || request.url !== '/v1/responses') { + response.writeHead(404) + response.end() + return + } + if (seenRequests !== 0) { + response.writeHead(409) + response.end() + return + } + seenRequests += 1 + const authorization = request.headers.authorization + if ( + typeof authorization !== 'string' + || !authorization.startsWith('Bearer ') + || authorization.length === 'Bearer '.length + ) { + throw new Error('Codex DeepSeek bridge received no bearer credential') + } + const body = JSON.parse(await readRequest(request)) as Record + const task = taskText(body) + if (!task.includes(nonce)) { + throw new Error('Codex DeepSeek bridge request omitted the expected nonce') + } + const text = await completeWithDeepSeek(authorization, task) + completedRequests += 1 + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_deepseek_e2e', + }) + for (const event of completeResponsesEvents(text)) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + })().catch(() => { + if (!response.headersSent) { + response.writeHead(502, { 'content-type': 'application/json' }) + } + response.end(JSON.stringify({ error: { message: 'DeepSeek bridge request failed' } })) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('DeepSeek bridge did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + get completedRequests(): number { return completedRequests }, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..6c4019f8c8 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -0,0 +1,53 @@ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + LOADER_SMOKE_TEST_TIMEOUT_MS, + runLoaderSmoke, +} from '@deepseek-ai/dsh-loader-smoke' + +const fixtureDir = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-codex/', + import.meta.url, +)) +const driver = join(fixtureDir, 'driver.ts') +const configPath = join(fixtureDir, 'cordis.yml') +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +describe('Codex provider public Loader composition', () => { + it('loads the opt-in package and foreground tool without starting Codex', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'subagent-codex Loader composition', + tempDirPrefix: 'dsh-subagent-codex-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { + // Loading the optional package must not probe or start a Codex binary. + PATH: '', + }, + }) + + expect(stderr).toBe('') + expect(JSON.parse(stdout)).toEqual({ + providers: ['codex'], + provider: { + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, + tool: { + name: 'subagent_codex', + parameterNames: ['description', 'prompt'], + required: ['description', 'prompt'], + }, + starts: 0, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts new file mode 100644 index 0000000000..29c5536bc0 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -0,0 +1,141 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startDeepSeekResponsesBridge, + type DeepSeekResponsesBridge, +} from './deepseek-responses-bridge.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const contexts: Context[] = [] +const bridges: DeepSeekResponsesBridge[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(bridges.splice(0).map(bridge => bridge.close())) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toHaveProperty('exitCode') + } +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)( + 'Codex provider with real DeepSeek API', + () => { + it('returns one unique nonce through the production provider and real Codex', async () => { + const apiKey = process.env.DEEPSEEK_API_KEY + if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-deepseek-e2e-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + mkdirSync(workspace) + mkdirSync(codexHome) + const nonce = `DSH_CODEX_DEEPSEEK_${randomUUID()}` + const bridge = await startDeepSeekResponsesBridge(nonce) + bridges.push(bridge) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "deepseek-v4-flash"', + 'model_provider = "deepseek-e2e"', + 'approval_policy = "never"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.deepseek-e2e]', + 'name = "DeepSeek E2E bridge"', + `base_url = "${bridge.baseUrl}"`, + 'env_key = "DEEPSEEK_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + DEEPSEEK_API_KEY: apiKey, + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg-config'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...env }, + }) + expect(codexPackage.version).toBe('0.146.0') + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const parent = { + id: 'deepseek-e2e-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + const run = await ctx.subagents.start('codex', { + prompt: [{ + type: 'text', + text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`, + }], + parent, + signal: new AbortController().signal, + }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + const text = result.output + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .trim() + expect(text).toBe(nonce) + expect(bridge.completedRequests).toBe(1) + await expectQuiescent(handles) + }, 180_000) + }, +) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts new file mode 100644 index 0000000000..5f73adaf7e --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -0,0 +1,225 @@ +import { execFile } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startResponsesFixture, + type ResponsesBehavior, + type ResponsesFixture, +} from './responses-fixture.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const fixtures: ResponsesFixture[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +interface RealHarness { + readonly ctx: Context + readonly handles: SubprocessHandle[] + readonly parent: Agent + readonly env: Record + readonly workspace: string +} + +async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ + readonly harness: RealHarness + readonly fixture: ResponsesFixture +}> { + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + const fixture = await startResponsesFixture(script) + fixtures.push(fixture) + mkdirSync(workspace) + mkdirSync(codexHome) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "fixture-model"', + 'model_provider = "fixture"', + 'approval_policy = "on-request"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.fixture]', + 'name = "Fixture Responses"', + `base_url = "${fixture.baseUrl}"`, + 'env_key = "OPENAI_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + OPENAI_API_KEY: 'dsh-fake-openai-key', + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const parent = { + id: 'real-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + return { harness: { ctx, handles, parent, env, workspace }, fixture } +} + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + const outcome = await handle.done + expect(outcome).toHaveProperty('exitCode') + expect(outcome).toHaveProperty('signal') + } +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +describe('real @openai/codex 0.146.0 product', () => { + it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { + const sentinel = 'REAL_CODEX_SENTINEL_0_146_0' + const task = 'Return the fixture sentinel exactly.' + const { harness, fixture } = await realHarness([ + { kind: 'complete', text: sentinel }, + ]) + expect(codexPackage.version).toBe('0.146.0') + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...harness.env }, + }) + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: task }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: sentinel }], + stopReason: 'completed', + }) + await run.dispose() + + expect(fixture.requests).toHaveLength(1) + const recorded = fixture.requests[0]! + expect(recorded.method).toBe('POST') + expect(recorded.path).toBe('/v1/responses') + expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key') + expect(responseInputTexts(recorded.body)).toContain(task) + await expectQuiescent(harness.handles) + }, 60_000) + + it('cancels a real app-server command approval without executing the command', async () => { + const { harness, fixture } = await realHarness([ + { + kind: 'functionCall', + name: 'exec_command', + arguments: { + cmd: 'touch approval-side-effect', + sandbox_permissions: 'require_escalated', + justification: 'exercise the unattended approval boundary', + }, + }, + ]) + const sideEffect = join(harness.workspace, 'approval-side-effect') + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Attempt the fixture command.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await run.dispose() + + expect(existsSync(sideEffect)).toBe(false) + expect(fixture.requests).toHaveLength(1) + const tools = fixture.requests[0]!.body.tools as Array> + expect(tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'function', name: 'exec_command' }), + ])) + expect(fixture.requests.every(requestEntry => + requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key', + )).toBe(true) + await expectQuiescent(harness.handles) + }, 60_000) + + it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const controller = new AbortController() + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Wait for cancellation.' }], + parent: harness.parent, + signal: controller.signal, + }) + await fixture.requestStarted + controller.abort(new Error('real product cancellation')) + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + await run.dispose() + await expectQuiescent(harness.handles) + }, 60_000) +}) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts new file mode 100644 index 0000000000..cac49b9158 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -0,0 +1,288 @@ +import { createServer } from 'node:http' +import type { + IncomingHttpHeaders, + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' + +/** One request observed by the package-private Responses fixture. */ +interface RecordedResponsesRequest { + readonly method: string | undefined + readonly path: string | undefined + readonly headers: IncomingHttpHeaders + readonly body: Record +} + +/** Behavior consumed by one Responses request. */ +export type ResponsesBehavior = + | { readonly kind: 'complete'; readonly text: string } + | { + readonly kind: 'functionCall' + readonly name: string + readonly arguments: Record + } + | { readonly kind: 'hold' } + +/** Running package-private Responses fixture. */ +export interface ResponsesFixture { + readonly baseUrl: string + readonly requests: RecordedResponsesRequest[] + readonly requestStarted: Promise + close(): Promise +} + +function responseObject(text: string): Record { + const message = { + id: 'msg_fixture', + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ + type: 'output_text', + annotations: [], + logprobs: [], + text, + }], + } + return { + id: 'resp_fixture', + object: 'response', + created_at: 1, + status: 'completed', + background: false, + error: null, + incomplete_details: null, + instructions: null, + max_output_tokens: null, + max_tool_calls: null, + model: 'fixture-model', + output: [message], + parallel_tool_calls: true, + previous_response_id: null, + prompt_cache_key: null, + prompt_cache_retention: null, + reasoning: { effort: null, summary: null }, + safety_identifier: null, + service_tier: 'default', + store: false, + temperature: null, + text: { format: { type: 'text' }, verbosity: 'medium' }, + tool_choice: 'auto', + tools: [], + top_logprobs: 0, + top_p: null, + truncation: 'disabled', + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 11, + }, + user: null, + metadata: {}, + } +} + +/** + * Build the minimal Responses SSE event sequence consumed by Codex 0.146.0. + * @param text - exact assistant answer. + * @returns ordered response lifecycle events. + */ +export function completeResponsesEvents(text: string): Record[] { + const completed = responseObject(text) + const message = (completed.output as Record[])[0]! + const part = (message.content as Record[])[0]! + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...message, status: 'in_progress', content: [] }, + }, + { + type: 'response.content_part.added', + item_id: message.id, + output_index: 0, + content_index: 0, + part: { ...part, text: '' }, + }, + { + type: 'response.output_text.delta', + item_id: message.id, + output_index: 0, + content_index: 0, + delta: text, + logprobs: [], + }, + { + type: 'response.output_text.done', + item_id: message.id, + output_index: 0, + content_index: 0, + text, + logprobs: [], + }, + { + type: 'response.content_part.done', + item_id: message.id, + output_index: 0, + content_index: 0, + part, + }, + { + type: 'response.output_item.done', + output_index: 0, + item: message, + }, + { type: 'response.completed', response: completed }, + ] +} + +function functionCallEvents( + name: string, + argumentsValue: Record, +): Record[] { + const argumentsText = JSON.stringify(argumentsValue) + const item = { + id: 'fc_fixture', + type: 'function_call', + status: 'completed', + name, + arguments: argumentsText, + call_id: 'call_fixture', + } + const completed = { + ...responseObject(''), + output: [item], + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 15, + }, + } + return [ + { + type: 'response.created', + response: { ...completed, status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...item, status: 'in_progress', arguments: '' }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: item.id, + output_index: 0, + delta: argumentsText, + }, + { + type: 'response.function_call_arguments.done', + item_id: item.id, + output_index: 0, + arguments: argumentsText, + }, + { + type: 'response.output_item.done', + output_index: 0, + item, + }, + { type: 'response.completed', response: completed }, + ] +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start a loopback-only Responses SSE fixture. + * @param script - one behavior per expected Responses request. + * @returns the running fixture and its observed requests. + */ +export async function startResponsesFixture( + script: readonly ResponsesBehavior[], +): Promise { + const behaviors = [...script] + const requests: RecordedResponsesRequest[] = [] + const started = Promise.withResolvers() + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void readRequest(request).then((body) => { + requests.push({ + method: request.method, + path: request.url, + headers: request.headers, + body: JSON.parse(body) as Record, + }) + started.resolve(undefined) + const behavior = behaviors.shift() + if (behavior === undefined) { + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } })) + return + } + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_fixture', + }) + if (behavior.kind === 'hold') return + const events = behavior.kind === 'complete' + ? completeResponsesEvents(behavior.text) + : functionCallEvents(behavior.name, behavior.arguments) + for (const event of events) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + }).catch((error: unknown) => { + response.destroy(error instanceof Error ? error : new Error(String(error))) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('responses fixture did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + requests, + requestStarted: started.promise, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts new file mode 100644 index 0000000000..de89aa4854 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -0,0 +1,1120 @@ +import { PassThrough } from 'node:stream' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { + SubprocessHandle, + SubprocessOutcome, +} from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import * as invariant from '../src/invariant.ts' +import { + codexAppServerArgv, + DEFAULT_DISPOSE_GRACE_MS, + disposeCodexChild, + startCodexRun, + textTask, + type CodexRunSpec, +} from '../src/run.ts' +import { CodexAppServerWire } from '../src/wire.ts' + +type JsonObject = Record + +const fakeParent = { + id: 'parent', + session: { header: { cwd: process.cwd() } }, +} as unknown as Agent + +function request( + prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }], + signal = new AbortController().signal, +) { + return { prompt, parent: fakeParent, signal } +} + +async function nextTask(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} + +class ProtocolPeer { + private buffer = '' + private readonly frames: JsonObject[] = [] + private readonly wakeups = new Set<() => void>() + + constructor( + input: PassThrough, + private readonly output: PassThrough, + ) { + input.on('data', (chunk: Buffer | string) => { + this.buffer += chunk.toString() + for (;;) { + const newline = this.buffer.indexOf('\n') + if (newline < 0) break + const line = this.buffer.slice(0, newline) + this.buffer = this.buffer.slice(newline + 1) + if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject) + } + for (const wake of this.wakeups) wake() + this.wakeups.clear() + }) + } + + async next(predicate: (frame: JsonObject) => boolean): Promise { + for (;;) { + const index = this.frames.findIndex(predicate) + if (index >= 0) return this.frames.splice(index, 1)[0]! + await new Promise((resolve) => { this.wakeups.add(resolve) }) + } + } + + nextMethod(method: string): Promise { + return this.next(frame => frame.method === method) + } + + nextResponse(id: unknown): Promise { + return this.next(frame => frame.id === id && frame.method === undefined) + } + + send(...frames: readonly JsonObject[]): void { + this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`) + } + + respond(requestFrame: JsonObject, result: unknown): void { + this.send({ id: requestFrame.id, result }) + } +} + +interface FakeChildOptions { + readonly pid?: number + readonly exitOnTerminate?: boolean + readonly doneError?: Error +} + +interface FakeChild { + readonly handle: SubprocessHandle + readonly peer: ProtocolPeer + readonly fromChild: PassThrough + readonly toChild: PassThrough + readonly settle: (outcome?: SubprocessOutcome) => void + readonly fail: (error: Error) => void + readonly terminate: () => void + readonly waitForExit: (signal?: AbortSignal) => Promise +} + +function fakeChild(options: FakeChildOptions = {}): FakeChild { + const fromChild = new PassThrough() + const toChild = new PassThrough() + const peer = new ProtocolPeer(toChild, fromChild) + let exited = false + let resolveDone!: (outcome: SubprocessOutcome) => void + let rejectDone!: (error: Error) => void + const done = new Promise((resolve, reject) => { + resolveDone = resolve + rejectDone = reject + }) + const settle = ( + outcome: SubprocessOutcome = { exitCode: 0, signal: null }, + ): void => { + if (exited) return + exited = true + resolveDone(outcome) + } + const fail = (error: Error): void => { + if (exited) return + exited = true + rejectDone(error) + } + if (options.doneError !== undefined) fail(options.doneError) + const terminate = vi.fn(() => { + if (options.exitOnTerminate !== false) settle() + }) + const waitForExit = vi.fn(async (signal?: AbortSignal) => { + if (exited) return true + if (signal === undefined) { + await done.catch(() => {}) + return true + } + return await new Promise((resolve) => { + const onAbort = (): void => { resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + void done.then( + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + () => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, + ) + }) + }) + const handle: SubprocessHandle = { + pid: options.pid ?? 1234, + stdin: toChild, + stdout: fromChild, + stderr: undefined, + collected: {}, + done, + terminate, + waitForExit, + } + return { + handle, + peer, + fromChild, + toChild, + settle, + fail, + terminate, + waitForExit, + } +} + +function runSpec( + child: FakeChild, + overrides: Partial = {}, +): CodexRunSpec { + return { + cwd: process.cwd(), + env: {}, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: () => child.handle, + ...overrides, + } +} + +async function initializeWire(): Promise<{ + readonly child: FakeChild + readonly wire: CodexAppServerWire +}> { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + expect(await child.peer.nextMethod('initialized')).toEqual({ + jsonrpc: '2.0', + method: 'initialized', + }) + const starting = wire.startThread(process.cwd(), new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + return { child, wire } +} + +async function publishRun( + child = fakeChild(), + signal = new AbortController().signal, + specOverrides: Partial = {}, +) { + const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + const turnStart = await child.peer.nextMethod('turn/start') + return { child, run, turnStart } +} + +function agentMessage( + text: unknown, + phase: unknown, + turnId = 'turn-1', + threadId = 'thread-1', +): JsonObject { + return { + method: 'item/completed', + params: { + threadId, + turnId, + item: { type: 'agentMessage', text, phase }, + }, + } +} + +function turnCompleted( + status: unknown, + turnId = 'turn-1', + threadId = 'thread-1', + error: unknown = null, +): JsonObject { + return { + method: 'turn/completed', + params: { + threadId, + turn: { id: turnId, status, error }, + }, + } +} + +describe('task admission and package contracts', () => { + it('resolves the fixed app-server command through the Windows npm shim boundary', () => { + expect(codexAppServerArgv('win32')).toEqual([ + 'cmd.exe', + '/d', + '/s', + '/c', + 'codex', + 'app-server', + '--stdio', + ]) + expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio']) + }) + + it('accepts one or more text blocks and rejects empty or non-text tasks', () => { + expect(textTask([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + ])).toEqual(['one', 'two']) + expect(() => textTask([])).toThrow('only text blocks') + expect(() => textTask([{ type: 'reasoning', text: 'hidden' }])) + .toThrow('only text blocks') + expect(() => textTask([{ type: 'text', text: ' \n ' }])) + .toThrow('must not be empty') + }) + + it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const fiber = await ctx.plugin(codex, {}) + const provider = ctx.subagents.getProvider('codex')! + expect(provider).toMatchObject({ + name: 'codex', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }) + expect(ctx.subagents.list()).toEqual(['codex']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + + for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(ctx.plugin(codex, { disposeGraceMs })) + .rejects.toThrow('disposeGraceMs must be a positive finite number') + } + await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + await ctx.fiber.dispose() + }) + + it('requires a parent session cwd without suggesting unsupported config', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const spawn = vi.spyOn(ctx.subprocess, 'spawn') + await ctx.plugin(codex, {}) + + await expect(ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'task' }], + parent: { + id: 'parent-without-cwd', + session: { header: {} }, + } as unknown as Agent, + signal: new AbortController().signal, + })).rejects.toThrow( + 'subagent-codex: no working directory for the child — delegate from a parent session that has one', + ) + expect(spawn).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('keeps the namespace export shape and package-owned empty invariant', async () => { + expect('default' in codex).toBe(false) + expect(codex.name).toBe('subagent-codex') + expect(codex.inject).toEqual(['subagents', 'subprocess']) + const loader = Object.create(Loader.prototype) as Loader + expect(loader.unwrapExports(codex)).toBe(codex) + + const dispose = vi.fn() + const register = vi.fn(( + _packageName: string, + _installer: InvariantInstaller, + ) => dispose) + const ctx = { invariants: { register } } as unknown as Context + await expect(invariant.apply(ctx)).resolves.toBe(dispose) + expect(register).toHaveBeenCalledWith( + '@deepseek-ai/dsh-subagent-codex', + expect.any(Function), + ) + const install = register.mock.calls[0]![1] + await install(new Context(), (message) => { throw new Error(message) }) + expect(invariant.name).toBe('subagent-codex-invariant') + expect(invariant.inject).toEqual(['invariants']) + }) +}) + +describe('CodexAppServerWire', () => { + it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + expect(wire.collectOutput()).toEqual([]) + wire.start() + + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + expect(initialize.params).toEqual({ + clientInfo: { + name: 'deepseek-harness', + title: 'DeepSeek Harness', + version: '0.0.1', + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, + }) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await initializing + await child.peer.nextMethod('initialized') + + const starting = wire.startThread('/workspace', new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true }) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + + const result = wire.runTurn( + ['first', 'second'], + new AbortController().signal, + ) + const turnStart = await child.peer.nextMethod('turn/start') + expect(turnStart.params).toEqual({ + threadId: 'thread-1', + input: [ + { type: 'text', text: 'first', text_elements: [] }, + { type: 'text', text: 'second', text_elements: [] }, + ], + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'), + agentMessage('other turn', 'final_answer', 'turn-2'), + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'reasoning', text: 'not output' }, + }, + }, + agentMessage('commentary', 'commentary'), + agentMessage('unphased', null), + agentMessage('first final', 'final_answer'), + agentMessage('last final', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'last final' }], + stopReason: 'completed', + }) + expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }]) + wire.close() + wire.close() + }) + + it('uses the last nullable-phase answer when no explicit final exists', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('first', null), + agentMessage('fallback', null), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'fallback' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('maps only an explicit context-window failure to max-tokens', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'too much context', + codexErrorInfo: 'contextWindowExceeded', + }), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + stopReason: 'max-tokens', + }) + wire.close() + }) + + it('rejects invalid handshake, thread, and turn response shapes', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + const frame = await child.peer.nextMethod('initialize') + child.peer.respond(frame, null) + await expect(pending).rejects.toThrow('invalid initialize response') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.startThread('/workspace', new AbortController().signal) + const frame = await child.peer.nextMethod('thread/start') + child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } }) + await expect(pending).rejects.toThrow('did not create an ephemeral thread') + wire.close() + } + { + const { child, wire } = await initializeWire() + const pending = wire.runTurn(['task'], new AbortController().signal) + const frame = await child.peer.nextMethod('turn/start') + child.peer.respond(frame, { turn: { id: '' } }) + await expect(pending).rejects.toThrow('turn/start turn id') + wire.close() + } + }) + + it('fails closed for empty output, malformed messages, phases, and terminal status', async () => { + const scenarios: Array<{ + readonly frames: JsonObject[] + readonly message: string + }> = [ + { + frames: [turnCompleted('completed')], + message: 'without a final answer', + }, + { + frames: [ + agentMessage('fallback', null), + agentMessage(' \n ', 'final_answer'), + turnCompleted('completed'), + ], + message: 'without a final answer', + }, + { + frames: [agentMessage(42, 'final_answer')], + message: 'invalid agent message', + }, + { + frames: [agentMessage('answer', 'future_phase')], + message: 'unknown agent message phase', + }, + { + frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })], + message: 'status failed', + }, + { + frames: [turnCompleted('interrupted')], + message: 'status interrupted', + }, + { + frames: [turnCompleted('inProgress')], + message: 'invalid terminal turn status', + }, + ] + for (const scenario of scenarios) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(...scenario.frames) + await expect(result).rejects.toThrow(scenario.message) + wire.close() + } + }) + + it('fails closed when terminal notification params are not an object', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send({ method: 'turn/completed', params: null }) + await expect(result).rejects.toThrow('invalid turn/completed thread id') + wire.close() + }) + + it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + { id: 'future-request', method: 'future/request', params: {} }, + agentMessage('early answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).rejects.toThrow('unsupported app-server request') + wire.close() + }) + + it('answers all five unattended request classes without granting authority', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + + child.peer.send({ + id: 'command', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline', 'cancel'], + }, + }) + expect(await child.peer.nextResponse('command')).toMatchObject({ + result: { decision: 'cancel' }, + }) + + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + const requests = [ + { + id: 'file', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + result: { decision: 'decline' }, + }, + { + id: 'file-default', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { decision: 'decline' }, + }, + { + id: 'permissions', + method: 'item/permissions/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + result: { permissions: {}, scope: 'turn' }, + }, + { + id: 'user-input', + method: 'item/tool/requestUserInput', + params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] }, + result: { answers: {} }, + }, + { + id: 'mcp', + method: 'mcpServer/elicitation/request', + params: { threadId: 'thread-1', turnId: null }, + result: { action: 'decline', content: null, _meta: null }, + }, + ] as const + for (const serverRequest of requests) { + child.peer.send(serverRequest) + expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({ + result: serverRequest.result, + }) + } + + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + }) + + it('fails the run on unknown requests or wrong request association', async () => { + for (const serverRequest of [ + { + id: 'unknown', + method: 'future/request', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }, + { + id: 'approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['accept'], + }, + }, + { + id: 'malformed-approval', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: 'decline', + }, + }, + { + id: 'thread', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-2', turnId: 'turn-1' }, + }, + { + id: 'turn', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-2' }, + }, + ]) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send(serverRequest) + const response = await child.peer.nextResponse(serverRequest.id) + expect(response.error).toMatchObject({ code: -32603 }) + await expect(result).rejects.toThrow() + wire.close() + } + }) + + it('rejects conflicting early turn identities before accepting output', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-early' } }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + wire.close() + }) + + it('rejects conflicting early notifications and requests before turn/start', async () => { + { + const { child, wire } = await initializeWire() + child.peer.send({ + id: 'too-early', + method: 'item/fileChange/requestApproval', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + const response = await child.peer.nextResponse('too-early') + expect(response.error).toMatchObject({ code: -32603 }) + wire.close() + } + { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + await child.peer.nextMethod('turn/start') + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-1' } }, + }, + agentMessage('wrong', 'final_answer', 'turn-2'), + ) + await expect(result).rejects.toThrow('conflicting turns') + wire.close() + } + }) + + it('interrupts only an active open turn and contains remote interrupt failure', async () => { + const { child, wire } = await initializeWire() + wire.interrupt() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + wire.interrupt() + const interrupt = await child.peer.nextMethod('turn/interrupt') + expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' }) + child.peer.send({ + id: interrupt.id, + error: { code: -32000, message: 'already done' }, + }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + wire.close() + wire.interrupt() + }) + + it('ignores unrelated and out-of-window notifications', async () => { + const { child, wire } = await initializeWire() + child.peer.send( + { + method: 'turn/started', + params: { threadId: 'thread-2', turn: { id: 'turn-other' } }, + }, + { + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-before' } }, + }, + agentMessage('before', 'final_answer'), + { method: 'future/notification', params: {} }, + turnCompleted('completed'), + turnCompleted('completed', 'turn-other', 'thread-2'), + ) + await nextTask() + + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send( + agentMessage('wrong turn', 'final_answer', 'turn-2'), + turnCompleted('completed', 'turn-2'), + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('rejects pending work on abort, EOF, and stream error', async () => { + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + controller.abort('pre-aborted') + await expect(wire.initialize(controller.signal)) + .rejects.toThrow('app-server request aborted: pre-aborted') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const controller = new AbortController() + const pending = wire.initialize(controller.signal) + await child.peer.nextMethod('initialize') + controller.abort(new Error('cancel initialize')) + await expect(pending).rejects.toThrow('cancel initialize') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.end() + await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/) + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.fromChild.emit('error', new Error('stdout broke')) + await expect(pending).rejects.toThrow('stdout broke') + wire.close() + } + { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + wire.start() + const pending = wire.initialize(new AbortController().signal) + await child.peer.nextMethod('initialize') + child.toChild.emit('error', new Error('stdin broke')) + await expect(pending).rejects.toThrow('stdin broke') + wire.close() + child.toChild.emit('error', new Error('late stdin close')) + } + }) +}) + +describe('run lifecycle and quiescence', () => { + it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => { + const child = fakeChild() + const spawn = vi.fn(() => child.handle) + const starting = startCodexRun( + request([{ type: 'text', text: 'task' }]), + runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }), + ) + let published = false + void starting.then(() => { published = true }) + const initialize = await child.peer.nextMethod('initialize') + expect(published).toBe(false) + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + expect(published).toBe(false) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + expect(spawn).toHaveBeenCalledWith({ + argv: codexAppServerArgv(), + cwd: process.cwd(), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: DEFAULT_DISPOSE_GRACE_MS, + env: { OPENAI_API_KEY: 'fake' }, + }) + expect(run.localAgent).toBeUndefined() + + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send( + { id: turnStart.id, result: { turn: { id: 'turn-1' } } }, + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + const disposal = run.dispose() + expect(run.dispose()).toBe(disposal) + await disposal + await nextTask() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + }) + + it('settles local cancellation immediately and sends best-effort interrupt', async () => { + const controller = new AbortController() + const { child, run, turnStart } = await publishRun( + fakeChild(), + controller.signal, + ) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + controller.abort(new Error('stop')) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({ + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }) + await run.dispose() + }) + + it('flattens child exit and protocol failures after publication', async () => { + const errors: string[] = [] + { + const child = fakeChild({ exitOnTerminate: false }) + const { run } = await publishRun(child, undefined, { + onError: (error) => { errors.push(error.message) }, + }) + child.settle({ exitCode: 9, signal: null }) + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + expect(errors.at(-1)).toContain('code 9') + await run.dispose().catch(() => {}) + } + { + const child = fakeChild() + const { run, turnStart } = await publishRun(child, undefined, { + onError: () => { throw new Error('diagnostic sink') }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.fromChild.end() + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + await run.dispose() + } + }) + + it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { + const controller = new AbortController() + controller.abort() + const spawn = vi.fn() + await expect(startCodexRun( + request(undefined, controller.signal), + { + cwd: process.cwd(), + env: {}, + disposeGraceMs: 10, + spawn, + }, + )).rejects.toThrow('aborted before app-server startup') + expect(spawn).not.toHaveBeenCalled() + + const child = fakeChild() + const starting = startCodexRun(request(), runSpec(child)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, null) + await expect(starting).rejects.toThrow('invalid initialize response') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back an abort that wins immediately after thread creation', async () => { + const controller = new AbortController() + const child = fakeChild() + const starting = startCodexRun( + request(undefined, controller.signal), + runSpec(child), + ) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + controller.abort('startup race') + await expect(starting).rejects.toThrow('aborted before run publication') + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('rolls back a subprocess done rejection during startup', async () => { + const child = fakeChild({ doneError: new Error('spawn observer failed') }) + const error: unknown = await startCodexRun(request(), runSpec(child)).then( + () => undefined, + (failure: unknown) => failure, + ) + expect(error).toBeInstanceOf(AggregateError) + if (!(error instanceof AggregateError)) { + throw new Error('expected startup and rollback failures') + } + expect(error.errors).toEqual([ + expect.objectContaining({ message: 'spawn observer failed' }), + expect.objectContaining({ message: 'spawn observer failed' }), + ]) + expect(child.terminate).toHaveBeenCalledTimes(1) + }) + + it('keeps overlapping runs isolated', async () => { + const first = fakeChild() + const second = fakeChild() + const runs = await Promise.all([ + publishRun(first), + publishRun(second), + ]) + for (const [index, entry] of runs.entries()) { + const id = `turn-${index + 1}` + entry.child.peer.send( + { id: entry.turnStart.id, result: { turn: { id } } }, + agentMessage(`answer-${index + 1}`, 'final_answer', id), + turnCompleted('completed', id), + ) + } + const results = await Promise.all(runs.map(entry => entry.run.result)) + expect(results.map(result => result.output)).toEqual([ + [{ type: 'text', text: 'answer-1' }], + [{ type: 'text', text: 'answer-2' }], + ]) + expect(runs[0].run.id).not.toBe(runs[1].run.id) + await Promise.all(runs.map(entry => entry.run.dispose())) + }) + + it('uses the registered provider config and logs flattened errors', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const child = fakeChild() + const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { + warnings.push(String(message)) + }) as typeof ctx.logger.warn + await ctx.plugin(codex, { + env: { OPENAI_API_KEY: 'fake' }, + disposeGraceMs: 25, + }) + const starting = ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'task' }], + parent: fakeParent, + signal: new AbortController().signal, + }) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + const run = await starting + await child.peer.nextMethod('turn/start') + child.settle({ exitCode: 1, signal: null }) + await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + env: { OPENAI_API_KEY: 'fake' }, + graceMs: 25, + cwd: process.cwd(), + })) + expect(warnings).toEqual([ + expect.stringContaining('subagent-codex: child run failed (error):'), + ]) + await run.dispose().catch(() => {}) + await ctx.fiber.dispose() + }) +}) + +describe('disposeCodexChild', () => { + it('closes stdin, terminates, and waits for the managed tree', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const end = vi.spyOn(child.toChild, 'end') + await disposeCodexChild(wire, child.handle) + expect(end).toHaveBeenCalled() + expect(child.terminate).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledTimes(1) + expect(child.waitForExit).toHaveBeenCalledWith() + }) + + it('does not finish disposal before the managed tree exits', async () => { + const child = fakeChild({ exitOnTerminate: false }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + let disposed = false + const disposal = disposeCodexChild(wire, child.handle).then(() => { + disposed = true + }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(disposed).toBe(false) + child.settle() + await disposal + expect(disposed).toBe(true) + }) + + it('contains a concurrently closed stdin error', async () => { + const child = fakeChild() + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + vi.spyOn(child.toChild, 'end').mockImplementation(() => { + throw new Error('already closed') + }) + await expect(disposeCodexChild(wire, child.handle)) + .resolves.toBeUndefined() + }) + + it('handles a spawn-level failure with no process tree', async () => { + const child = fakeChild({ + pid: -1, + doneError: new Error('spawn failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle)) + .resolves.toBeUndefined() + expect(child.terminate).not.toHaveBeenCalled() + expect(child.waitForExit).not.toHaveBeenCalled() + }) + + it('reports direct-child observer failure and accepts absent stdin', async () => { + { + const child = fakeChild({ + doneError: new Error('close observer failed'), + }) + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, child.handle)) + .rejects.toThrow('close observer failed') + } + { + const child = fakeChild() + const handle = { ...child.handle, stdin: undefined } + const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() + } + }) +}) diff --git a/packages/subagent/subagent-codex/tsconfig.json b/packages/subagent/subagent-codex/tsconfig.json new file mode 100644 index 0000000000..b9f33967ba --- /dev/null +++ b/packages/subagent/subagent-codex/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sdk/sdk-protocol" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 04ce4e23f9..acb4e4d36e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -75,7 +75,7 @@ function prePublicationAbort(): Error { /** Append one one-shot descriptor inside the child's initial turn before its first request. */ function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { let appended = false - childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => { + childCtx.on('agent/pre-step', async ({ agent }, next) => { const decision = await next() if (!appended && decision.kind === 'enter') { appended = true diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 389ef5e2a7..33de6d0cc6 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -42,7 +42,7 @@ export async function spawnHarness(workdir: string): Promise { export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index c8aefc7533..bc73d345e5 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: a21aa6ae2822d68d513fd9409d77b3f3bf74a7a3 -README.zh.md: 3caa612aefcdac4f1dcdbcf4a3c1b81adc52c3d3 +README.md: 08b6175e018db072b99490a25b8df887bb89eb47 +README.zh.md: 435be7660b3f004a1f0bcb59a8d9a74ac8e8aae3 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index a21aa6ae28..08b6175e01 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -48,7 +48,7 @@ The seam owns the versioned `subagent/descriptor` session event vocabulary (`src The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level. -`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority. +`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. ## One-shot ownership and lifecycle diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3caa612aef..435be7660b 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -48,7 +48,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth` 和 `delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。 -`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和 ACP(Agent Client Protocol)不可以),不表示是否继承工具、服务或权限。 +`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 ## 一次性所有权与生命周期 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 4b038871a1..212536e713 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -287,7 +287,7 @@ export class SubagentContinuationManager { // child-first ordering. const scope = ctx.plugin(function activationOwner() {}) this.ownerCtx = scope.ctx - ctx.on('agent/disposed', (agent) => { + ctx.on('agent/disposed', ({ agent }) => { this.closingScopes.delete(agent) }) ctx.effect(function* (this: SubagentContinuationManager) { @@ -859,12 +859,12 @@ export class SubagentContinuationManager { // quiet Agent from one whose accepted turn has not been admitted yet. // Registered through the child's own scoped context, so scope filtering // already restricts both listeners to this exact agent. - handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => { /* v8 ignore next -- a claim of an id this manager never admitted needs * another sender on the same child, which no current path allows. */ if (activation.accepted.delete(message.id)) this.wake(activation) }) - handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => { + handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => { if (activation.accepted.delete(message.id)) this.wake(activation) }) // Agent creation committed setup at its publication boundary; diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index fc78fb28fa..d049dba2be 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -132,9 +132,9 @@ function toError(value: unknown): Error { export interface RunResultSettlement { /** The turn attempt (typically racing local cancellation); returns the terminal result. */ attempt: () => Promise - /** Snapshot of the child output streamed so far (a partial answer survives failure). */ + /** Snapshot the provider exposes when cancellation or failure wins settlement. */ collectOutput: () => ContentBlock[] - /** Whether local cancellation settled (an in-flight rejection then reads as `aborted`). */ + /** Whether local cancellation settled before the attempt's outcome is observed. */ cancelled: () => boolean /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */ onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined @@ -146,16 +146,19 @@ export interface RunResultSettlement { /** * Settle an out-of-process run result under the seam contract: `result` never - * rejects after publication. A rejection from the attempt resolves as - * `aborted` when cancellation already settled locally, else it is flattened - * to `stopReason: 'error'` through the contained diagnostic sink; the abort - * listener is removed on every path. + * rejects after publication. A normally completed or rejected attempt resolves + * as `aborted` when cancellation already settled locally; another rejection is + * flattened to `stopReason: 'error'` through the contained diagnostic sink. + * The abort listener is removed on every path. * @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring. * @returns the terminal result (never a rejection). */ export async function settleRunResult(parts: RunResultSettlement): Promise { try { - return await parts.attempt() + const result = await parts.attempt() + return parts.cancelled() + ? { output: parts.collectOutput(), stopReason: 'aborted' } + : result } catch (error: unknown) { // Cover a rejection already queued when cancellation arrives. if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' } diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 7b7a2ab541..dca06add38 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -159,10 +159,10 @@ describe('SubagentService.startContinuable', () => { it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { const { ctx, parent, adapter } = await setup([textResponse('first answer')]) const enqueued: { id: MessageId; loggedYet: boolean }[] = [] - ctx.on('agent/inbox/inserted', (agent, accepted) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. - enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -231,7 +231,7 @@ describe('SubagentService.startContinuable', () => { const { ctx, parent } = await setup([textResponse('unused')]) const controller = new AbortController() // Abort inside the child's creation window: setup runs before publication. - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child !== parent) controller.abort('caller gave up') }) @@ -753,7 +753,7 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) const disposals: SessionId[] = [] - ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) + ctx.on('agent/disposed', ({ agent }) => { disposals.push(agent.id) }) const drained = drainManager(ctx) // Let the held model call observe its cancellation so quiescence can settle. hold.resolve(undefined) @@ -984,7 +984,7 @@ describe('continuable durability and teardown', () => { const drains: Promise[] = [] const accepted: MessageId[] = [] ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) - ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) }) + ctx.on('agent/inbox/inserted', ({ message }) => { accepted.push(message.id) }) await expect(ctx.subagents.startContinuable(startSpec(parent))) .rejects.toMatchObject({ code: 'DRAINING' }) @@ -998,12 +998,12 @@ describe('continuable durability and teardown', () => { const { ctx, parent } = await setup([]) const order: string[] = [] const drains: Promise[] = [] - ctx.on('agent/created', (child) => { + ctx.on('agent/created', ({ agent: child }) => { if (child === parent) return const draining = drainManager(ctx).then(() => { order.push('drain') }) drains.push(draining) }) - ctx.on('agent/disposed', (child) => { + ctx.on('agent/disposed', ({ agent: child }) => { if (child !== parent) order.push('disposed') }) @@ -1025,8 +1025,8 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! const order: string[] = [] - child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { + child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'before drain')) { order.push('enqueue') } }) @@ -1208,7 +1208,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block the resumed prompt so this epoch produces nothing of its own. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1356,8 +1356,8 @@ describe('continuable review regressions', () => { // Cancel from the synchronous enqueue observer: the discard fires after the // id is recorded but before `followup()` returns. - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1388,8 +1388,8 @@ describe('continuable review regressions', () => { await followup(ctx, parent, started.childId, message('queued')) expect(activation.accepted.size).toBe(1) - const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { - if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { + const off = child.ctx.on('agent/inbox/inserted', ({ message }) => { + if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) { child.cancel({ kind: 'user' }) } }) @@ -1406,7 +1406,7 @@ describe('continuable review regressions', () => { const ends: SubagentRunEndInfo[] = [] ctx.on('subagent/end', (info) => { ends.push(info) }) // Block admission so the child's only turn never opens. - ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async ({ agent: subject }, next) => { if (subject === parent) return next() return { kind: 'reject' } }) @@ -1428,7 +1428,7 @@ describe('continuable review regressions', () => { const registeredAtEnqueue: boolean[] = [] // A synchronous inbox observer runs before the admitting microtask, the // exact window where `Agent.status` is still idle. - ctx.on('agent/inbox/inserted', (agent) => { + ctx.on('agent/inbox/inserted', ({ agent }) => { if (agent.session.header.parentSession !== undefined) { registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent) } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 64c29d5122..ac90b4612b 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -164,9 +164,9 @@ describe('dsh-tool-subagent-report', () => { const { started, child } = await startChild(ctx, parent) const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) @@ -190,9 +190,9 @@ describe('dsh-tool-subagent-report', () => { const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) const { child } = await startChild(ctx, parent) const enqueues: string[] = [] - ctx.on('agent/inbox/inserted', (agent, item) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (agent === parent) { - enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') + enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering') } }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f95c5ad09d..67894c32cb 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -67,8 +67,8 @@ export interface Config { * requires the provider's `depthLimit` capability (mount fails loud * otherwise). The provider checks the calling agent's current depth at every * start; the tool remains model-visible so runtime policy owns rejection. - * `'provider-managed'` is for an out-of-process provider (ACP) whose - * recursion budget belongs to the child harness's own deployment. + * `'provider-managed'` is for an out-of-process provider whose recursion + * budget belongs to the child runtime or its own deployment. */ maxDepth?: number | 'provider-managed' } diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 2aa69b82e1..e1429bffd0 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -27,11 +27,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 90d460c2c5..462da41382 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { CollectedOutput, SubprocessCollect, @@ -26,12 +27,12 @@ import type { /** * Build a child environment: explicit caller entries merge after the scrubbed - * parent base, so a deliberately supplied credential or current `DSH_*` fact - * wins over the scrub that dropped its ambient namesake. - * @param extra - explicit caller entries, merged verbatim after the scrub. + * parent base. A string deliberately restores or overrides an entry; an + * explicit `undefined` tombstone removes an ordinary ambient entry. + * @param extra - explicit caller entries and tombstones, merged after the scrub. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv(extra?: Readonly>): NodeJS.ProcessEnv { +export function childEnv(extra?: Readonly): NodeJS.ProcessEnv { return { ...scrubbedParentEnv(), ...extra } } @@ -298,8 +299,12 @@ function signalTree( * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. * @param internals - test-only spill-directory, platform, and taskkill overrides. * @returns live subprocess handle. + * @throws when `graceMs` cannot be represented by one Node timer. */ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { + if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } const spillDir = internals.spillDir ?? privateSpillDir() const platform = internals.platform ?? process.platform const taskkill = internals.taskkill ?? taskkillProcessTree @@ -341,7 +346,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') const stderrCollector = collectStream(errMode, child.stderr, 'stderr') - let graceTimer: NodeJS.Timeout | undefined + let graceTimer: ReturnType | undefined + let treeExitObserved = false + let treeExitObservation: Promise | undefined let settled = false // Failed spawns use pid -1 so signalling remains a no-op. @@ -349,6 +356,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter /** Whether the detached tree's root (or POSIX group) is still alive. */ const treeAlive = (): boolean => { + /* v8 ignore next -- only a timer callback already queued when the observer settles can enter here; + the guard is the final defense against probing an id after its tree was confirmed absent. */ + if (treeExitObserved) return false if (pid <= 0) return false if (platform === 'win32') { // Windows has no group-liveness probe; the direct child's exit is the @@ -371,19 +381,40 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } } + /** + * Start or reuse the handle's single whole-tree exit observer. The first + * confirmed absence is a permanent no-more-signals boundary: it cancels a + * pending escalation before this process-group id can be reused. + */ + const observeTreeExit = (): Promise => { + treeExitObservation ??= (async () => { + while (treeAlive()) await sleepTick() + treeExitObserved = true + if (graceTimer !== undefined) clearTimeout(graceTimer) + graceTimer = undefined + })() + return treeExitObservation + } + // The escalation's tier primitive (not on the handle — terminate() is the // only consumer-facing termination verb). Guards on TREE liveness, not // outcome settlement: a TERM-trapping helper can outlive the settled direct // child and must stay signalable, while a fully-dead tree (possible pid // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { + /* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer; + this remains the timer/death race guard and cannot be staged deterministically. */ if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } const terminate = (): void => { - if (graceTimer !== undefined) return // escalation already in flight - if (!treeAlive()) return + if (treeExitObserved || graceTimer !== undefined) return + // Observe from the first termination tier onward, even when inherited + // pipes delay `done` and no consumer has begun its own teardown wait. + void observeTreeExit() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await. + if (treeExitObserved) return kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying // does not mean the tree died — so settle does not clear this timer, and @@ -405,7 +436,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const done = new Promise((resolve, reject) => { - let pipeDrainTimer: NodeJS.Timeout | undefined + let pipeDrainTimer: ReturnType | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true @@ -428,7 +459,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // A surviving descendant that inherited a pipe must not hold the // outcome open indefinitely: after exit, the same bounded grace that // governs kills also bounds the close wait. - pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) + pipeDrainTimer = setTimeout(() => { + settle(exitCode, signal) + }, spec.graceMs) }) child.on('close', settle) function cleanup(): void { @@ -440,11 +473,23 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }) const waitForExit = async (signal?: AbortSignal): Promise => { - while (treeAlive()) { - if (signal?.aborted) return false - await sleepTick() + const observed = observeTreeExit() + if (treeExitObserved) return true + if (signal?.aborted) return false + if (signal === undefined) { + await observed + return true + } + const aborted = Promise.withResolvers() + const onAbort = (): void => { aborted.resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + /* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */ + if (signal.aborted) onAbort() + try { + return await Promise.race([observed.then(() => true), aborted.promise]) + } finally { + signal.removeEventListener('abort', onAbort) } - return true } return { diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 491756f01f..08b6b1dbf8 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -2,8 +2,14 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' +import { + killGroup, + OutputCollector, + spawnSubprocess, + taskkillProcessTree, +} from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -102,6 +108,14 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise } describe('spawnSubprocess', () => { + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])( + 'rejects an invalid grace before spawning: %s', + (graceMs) => { + expect(() => spawnSubprocess(spec('true', { graceMs }))) + .toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + }, + ) + it('captures stdout on success', async () => { const result = await finish(spawnSubprocess(spec('echo hello'))) expect(result.exitCode).toBe(0) @@ -164,6 +178,54 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) + it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => { + const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`) + const graceMs = 160 + const childScript = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: ['ignore', 1, 2], + }) + writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid)) + helper.unref() + setInterval(() => {}, 1000) + ` + const running = spawnSubprocess({ + ...spec('unused', { graceMs }), + argv: [process.execPath, '-e', childScript], + }) + const helper = await waitForPidFile(pidFile) + const realKill: typeof process.kill = process.kill.bind(process) + let termAt = 0 + let forceSignals = 0 + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (target !== -running.pid) return realKill(target, signal) + if (signal === 'SIGTERM') { + termAt = Date.now() + return realKill(target, signal) + } + if (signal === 'SIGKILL') { + forceSignals += 1 + return true + } + if (signal === 0 && termAt !== 0 && Date.now() - termAt < graceMs / 2) { + throw Object.assign(new Error('simulated vanished process group'), { code: 'ESRCH' }) + } + return true // Before TERM the original group is live; later its pgid is reused. + }) + try { + running.terminate() + await running.done + expect(forceSignals).toBe(0) + } finally { + killSpy.mockRestore() + process.kill(helper, 'SIGKILL') + await waitGone(helper) + } + }) + it('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. @@ -254,6 +316,19 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('alpha/beta\n') }) + it('lets an explicit tombstone remove an ordinary ambient env entry', async () => { + process.env.SUBPROCESS_TOMBSTONE_PROBE = 'ambient-value' + try { + const result = await finish(spawnSubprocess(spec( + 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"', + { env: { SUBPROCESS_TOMBSTONE_PROBE: undefined } }, + ))) + expect(result.stdout.text).toBe('absent\n') + } finally { + delete process.env.SUBPROCESS_TOMBSTONE_PROBE + } + }) + it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_PASSWORD matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. @@ -627,7 +702,6 @@ describe('coverage seams', () => { it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done - await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { running.terminate() @@ -636,6 +710,21 @@ describe('coverage seams', () => { } finally { spy.mockRestore() } + await running.waitForExit() + }) + + it('repeated terminate after exit never probes or signals a reused process group', async () => { + const running = spawnSubprocess(spec('sleep 60')) + running.terminate() + await running.done + await running.waitForExit() + const spy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + running.terminate() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } }) it('waitForExit on a failed spawn reports exited immediately', async () => { diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5a8dea211b..5272a4f78d 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 88567e6317..b178daafb2 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/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/subprocess/subprocess/README.md -README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260 -README.zh.md: 914ab16b40c688867eb20afc3de60a38fd88d42b +README.md: e59dd96df036826f36bd0286c977438d2d87d1cf +README.zh.md: e8fb89dfd1f8c41a0caefc96469c13d9ae7d415d diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index c360437bf2..e59dd96df0 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -7,10 +7,10 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes ## Contract - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). Grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so the implementation can represent it with one Node timer instead of accepting a value that Node collapses to one millisecond. `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a string deliberately forwards or overrides a value, while an `undefined` tombstone removes an ordinary ambient entry. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 914ab16b40..e8fb89dfd1 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -7,10 +7,10 @@ ## 契约 - `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 -- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样实现便可用一个 Node 定时器表示它,而不会接受会被 Node 折叠为 1 毫秒的值。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清理后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)会导入该环境清理定义。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[子进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index fdfc44b3c2..6084cc8c8e 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -80,10 +80,11 @@ export interface SubprocessSpawnSpec { /** Per-stream stdio dispositions. */ stdio: SubprocessStdio /** - * Grace period in milliseconds for the {@link SubprocessHandle.terminate} - * escalation and for draining still-open collected pipes after the process - * exits (an inherited descriptor held by a surviving descendant cannot hold - * the outcome open indefinitely). + * Positive finite grace period in milliseconds, no greater than + * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation + * and for draining still-open collected pipes after the process exits (an + * inherited descriptor held by a surviving descendant cannot hold the + * outcome open indefinitely). */ graceMs: number /** @@ -94,13 +95,12 @@ export interface SubprocessSpawnSpec { signal?: AbortSignal | undefined /** * Explicit environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`), with no namespace validation: - * every entry is a deliberate caller opt-in, so a forwarded - * credential-shaped entry or a current `DSH_*` fact survives precisely - * because this layer merges after the scrub that drops its ambient - * namesake. + * parent base (see `scrubbedParentEnv`), with no namespace validation. A + * string is a deliberate caller opt-in, so a forwarded credential-shaped + * entry or current `DSH_*` fact survives the scrub; `undefined` is a + * tombstone that removes an ordinary ambient entry from the child. */ - env?: Record | undefined + env?: NodeJS.ProcessEnv | undefined } /** diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 0bebbcc561..5cc17ddb79 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -89,7 +89,7 @@ export class TelemetryCoordinator { this.hintFlush(session) }) }) - ctx.on('agent/error', (agent, turn, step, error) => { + ctx.on('agent/error', ({ agent, turn, step, error }) => { this.contain(() => { this.relayAgentError(agent, turn, step, error) }) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 02ca434c0d..8bdf71ff7b 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -427,7 +427,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => { const session = liveSession(ctx, 'erring') // Only the members the relay reads; the full Agent surface is irrelevant here. const agent = { id: 'agent-1', session } as Agent - ctx.emit('agent/error', agent, 3, 2, error) + ctx.emit('agent/error', { agent, turn: 3, step: 2, error }) const record = backend.records.find(r => r.channel === 'ops')! expect(record.severity).toBe('error') expect(record.attributes).toMatchObject({ diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index aff2958de3..f8be1ec27f 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise { function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index e0e04a4fa1..005b8e2157 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -686,7 +686,9 @@ class FaceAnalyzer { const records: ExportRecord[] = [] for (const [subpath, target] of targets) { if (target.includes('*') || subpath === './package.json' - || subpath === './typert' || subpath === './client/typert' || target.endsWith('.json')) continue + || subpath === './typert' || subpath === './client/typert' + // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API. + || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue const sourcePath = sourcePathForExport(registration.root, target) const sourceFile = this.sourceFiles.get(realPath(sourcePath)) if (sourceFile === undefined) { diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 09d621691f..398ec6e923 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: fbdd4c1332a1cc52f15a8ce28264ea16d47fc552 -README.zh.md: b67fb126ea477acf2e79f5bc1d695a5fc9ca8c82 +README.md: cdd78047b6ad71148c6ebeba598b63b4ae4cfa7b +README.zh.md: ee2b07884e68510e2b59b9f2c27053c263d15f1a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index fbdd4c1332..cdd78047b6 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -12,10 +12,11 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `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 | -| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — 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 as personal config; read or parse failures throw a labelled error | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR | -| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` 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 personal overlay) and returns an async disposer | +| `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 | +| `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 | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | @@ -29,14 +30,16 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve 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. -## Personal config +## Profiles -A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's Web and headless modes ([`apps/cli`](../../../apps/cli/README.md)); raw config mode and the demo bins boot their named trees without this layer. Two optional files: +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps can never drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm ever managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). + +User-level machine-local preferences also live in the Harness home: - **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the Web settings page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone. -- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file. +- **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. -Web keeps `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. +Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. ## Model Experience @@ -51,4 +54,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. -- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. +- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b67fb126ea..ee2b07884e 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -12,10 +12,11 @@ | `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | -| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 | -| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer | +| `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(热模块替换)使用的确切根配置项 | +| `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 | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | @@ -29,14 +30,16 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 -## 个人配置 +## Profile -开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 Web 与 headless 模式([`apps/cli`](../../../apps/cli/README.md))使用;原始配置模式与 demo bin 会在不加该层的情况下启动指定的配置树。这里有两个可选文件: +profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。 + +用户级的机器本地偏好同样位于 Harness home 中: - **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 Web 设置页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。 -- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。 +- **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 -Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +长期运行的 surface 会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 ## 模型体验 @@ -51,4 +54,4 @@ Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 -- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 +- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 2e5a133f00..e14b249f5c 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,19 +1,19 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the - * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to + * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot */ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' 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 { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath } from '@deepseek-ai/dsh-paths' import type {} from '@cordisjs/plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -25,6 +25,27 @@ declare module 'cordis' { } } +export { + composeEntries, + DEFAULT_PROFILE_BUNDLES, + healProfilesModuleFallback, + initProfile, + loadProfile, + PROFILE_PATCH_FILENAME, + PROFILE_TEMPLATES, + PROFILES_DIR, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + writeProfileManifest, + type DshBundleManifest, + type DshManifestSection, + type DshProfileManifest, + type Profile, + type ProfileLayer, + type ProfileManifest, +} from './profile.ts' + /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. @@ -65,49 +86,100 @@ export function loadEnv( } } -/** File inside the Harness home holding the personal loader overlay patches. */ -export const PERSONAL_CONFIG_FILENAME = 'config.yaml' - const bootstrapIncludes = new WeakMap() // The include's YAML dialect (`!!js` scalars become expression nodes the // Loader interpolates against each entry's context at mount time), imported // from the include itself so patch parsing and config dumping can never drift -// from what the include mounts. Personal patches share it so they may +// from what the include mounts. User patch layers share it so they may // reference `process.env`. -const personalPatchesSchema = entryListSchema +const userPatchesSchema = entryListSchema + +/** Options for live user patch-layer reconciliation. */ +export interface UserPatchWatchOptions { + /** Diagnostic prefix used by {@link loadOptionalPatches}. */ + binName: string + /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */ + filename: string + /** + * Compose the full patch list for a fresh user-layer generation — + * the same composition the app booted with, so a reload can interleave the + * new user patches between app-owned layers (bundle layers below, + * overlay/flag patches above). Identity when omitted: the user layer + * is the whole patch list. + */ + compose?: (userPatches: PatchOptions[]) => PatchOptions[] +} /** - * Load the optional personal overlay patches (`config.yaml` under the Harness - * home). The file is a top-level YAML array of loader patch entries - * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides - * and `insert` lists, with `!!js` expressions allowed. A missing file means - * "no personal overlay"; an unreadable, unparsable, or non-array file throws — - * a present personal config that cannot apply is a misconfiguration and must - * fail loud at boot, never be silently skipped. + * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include. + * @param ctx - settled app context containing the root Include and an active HMR service. + * @param options - diagnostic, file, and patch-composition inputs. + * @returns an asynchronous disposer after the exact-path watcher is ready. + * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. + */ +export async function watchUserPatches( + ctx: Context, + options: UserPatchWatchOptions, +): Promise<() => Promise> { + const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options + const hmr = ctx.get('hmr') + if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`) + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`) + const register = hmr.registerConfig(filename, async () => { + // Re-read the include's non-patch options per refresh: a writer that + // updates the root Include's other options between refreshes (none exists + // today) must not have them silently reverted by a user-layer reload. + const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config + const userPatches = loadOptionalPatches(binName, filename) ?? [] + const patches = compose(userPatches) + await entry.update({ + config: { + ...includeConfig, + patches, + }, + }) + }) + try { + return await register + } catch (error) { + // A surface can dispose the whole tree while the watcher is still opening; + // the HMR effect registration then fails with INACTIVE_EFFECT. That is the + // app exiting exactly as asked, not a watch failure, so return a no-op + // disposer instead of crashing. + if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} + throw error + } +} + +/** + * Load an optional patch-list file: a top-level YAML array of loader patch + * entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config + * overrides and `insert` lists, with `!!js` expressions allowed. A missing + * file means "no layer"; an unreadable, unparsable, or non-array file throws — + * a present patch file that cannot apply is a misconfiguration and must fail + * loud at boot, never be silently skipped. * @param binName - the diagnostic prefix on the thrown error. - * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`). + * @param file - absolute path of the patch file. * @returns the parsed patches, or `undefined` when the file does not exist. */ -export function loadPersonalPatches( - binName: string, dir: string = resolveDshHome(), -): PatchOptions[] | undefined { - const file = join(dir, PERSONAL_CONFIG_FILENAME) +export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined { let content: string try { content = readFileSync(file, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined - throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`) + throw new Error(`${binName}: failed to read patches ${file}: ${String(error)}`) } - return parsePatchList(binName, file, content, 'personal patches') + return parsePatchList(binName, file, content, 'patches') } /** - * Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a - * `--config ` overlay applied over the shared base. Same file format as - * {@link loadPersonalPatches}, but a missing file throws, because the caller - * named this file — its absence is a misconfiguration, not "no overlay". + * Load a required overlay patch list: a bundle's `cordis.patch.yml` or a + * `--patch ` overlay. Same file format as {@link loadOptionalPatches}, + * but a missing file throws, because the caller named this file — its absence + * is a misconfiguration, not "no overlay". * @param binName - the diagnostic prefix on the thrown error. * @param file - absolute path of the overlay file. * @returns the parsed patch list. @@ -121,7 +193,6 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } return parsePatchList(binName, file, content, 'overlay') } - /** * Parse one loader patch list: a top-level YAML array of * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and @@ -132,7 +203,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ * @param binName - the diagnostic prefix on the thrown error. * @param file - the source path, quoted in errors. * @param content - the file's text. - * @param label - what to call this list in errors (`personal patches`, `overlay`). + * @param label - what to call this list in errors (`patches`, `overlay`). * @returns the parsed patch list. */ function parsePatchList( @@ -140,7 +211,7 @@ function parsePatchList( ): PatchOptions[] { let parsed: unknown try { - parsed = yaml.load(content, { schema: personalPatchesSchema }) + parsed = yaml.load(content, { schema: userPatchesSchema }) } catch (error) { throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`) } @@ -159,7 +230,7 @@ function parsePatchList( export interface ConfigDumpLayer { /** Source name shown in provenance comments (a file basename or path). */ label: string - /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */ + /** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */ patches: PatchOptions[] } @@ -290,70 +361,11 @@ function groupedDump( return lines.join('\n') + '\n' } -/** Options for live personal-config reconciliation. */ -export interface PersonalPatchWatchOptions { - /** Diagnostic prefix used by {@link loadPersonalPatches}. */ - binName: string - /** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */ - dir?: string - /** - * Compose the full patch list for a fresh personal-overlay generation — - * the same composition the app booted with, so a reload can interleave the - * new personal patches between app-owned layers (surface overlay below, - * profile/flag patches above). Identity when omitted: the personal overlay - * is the whole patch list. - */ - compose?: (personalPatches: PatchOptions[]) => PatchOptions[] -} - /** - * Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include. - * @param ctx - settled app context containing the root Include and an active HMR service. - * @param options - diagnostic, Harness-home, and patch-composition inputs. - * @returns an asynchronous disposer after the exact-path watcher is ready. - * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails. - */ -export async function watchPersonalPatches( - ctx: Context, - options: PersonalPatchWatchOptions, -): Promise<() => Promise> { - const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options - const hmr = ctx.get('hmr') - if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`) - const entry = bootstrapIncludes.get(ctx) - if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`) - const filename = join(dir, PERSONAL_CONFIG_FILENAME) - const register = hmr.registerConfig(filename, async () => { - // Re-read the include's non-patch options per refresh: a writer that - // updates the root Include's other options between refreshes (none exists - // today) must not have them silently reverted by a personal reload. - const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config - const personalPatches = loadPersonalPatches(binName, dir) ?? [] - const patches = compose(personalPatches) - await entry.update({ - config: { - ...includeConfig, - patches, - }, - }) - }) - try { - return await register - } catch (error) { - // A surface can dispose the whole tree while the watcher is still opening; - // the HMR effect registration then fails with INACTIVE_EFFECT. That is the - // app exiting exactly as asked, not a watch failure, so return a no-op - // disposer instead of crashing. - if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {} - throw error - } -} - -/** - * Mount and remember the exact root Include entry used by app boot and personal-config HMR. + * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR. * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. - * @param patches - initial app and personal patches, applied in order. + * @param patches - initial app and user patches, applied in order. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the * transactional create was still settling entry lifecycle. @@ -599,7 +611,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree - * (see {@link loadPersonalPatches}); an empty list mounts none. + * (see {@link loadOptionalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. * @returns the root context once every entry has started, or as soon as a * surface disposed the tree while startup was still in flight. diff --git a/packages/ui/app-boot/src/profile.ts b/packages/ui/app-boot/src/profile.ts new file mode 100644 index 0000000000..c353f18bef --- /dev/null +++ b/packages/ui/app-boot/src/profile.ts @@ -0,0 +1,388 @@ +/** + * Profile discovery, initialization, and patch-layer composition for the + * `dsh --profile` launcher family. + * + * A profile is a directory under `$DSH_HOME/profiles/` holding a + * `package.json` (out-of-tree plugin dependencies plus the profile manifest + * `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` + * (the user's own patch layer, applied after every bundle layer). Bundles are + * npm packages whose manifest declares + * `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the tree is + * composed by applying each bundle's patch list in `dsh.profile.bundles` order over + * an empty entry list, then the profile's own patches, then any launcher + * layers (`--patch` files and flag-derived patches). + * + * Module resolution is two-anchor by construction: a bundle name resolves + * first from the dsh installation (the launcher's own package), then from the + * profile directory. The Loader's `baseUrl` is the profile directory, whose + * `node_modules` pnpm manages for out-of-tree plugins, while the maintained + * flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per + * package the installation's app and bundles depend on) makes every in-box + * plugin Node-resolvable from any profile through the ordinary parent-walk. + * @module @deepseek-ai/dsh-app-boot/profile + */ + +import { createRequire } from 'node:module' +import { + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { loadOverlayPatches } from './index.ts' + +/** Directory under the Harness home holding every profile. */ +export const PROFILES_DIR = 'profiles' + +/** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */ +export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' + +/** The bundle half of the `dsh` manifest section: what a bundle package exports. */ +export interface DshBundleManifest { + /** The patch layer this bundle exports, relative to its package root. */ + patch: string +} + +/** The profile half of the `dsh` manifest section: what a profile directory composes. */ +export interface DshProfileManifest { + /** Ordered bundle layer list (package names). */ + bundles?: string[] +} + +/** + * The `dsh`-owned manifest section of a package.json. The nested key names + * the manifest kind: a bundle package declares `bundle`, a profile directory + * declares `profile`; nothing declares both. + */ +export interface DshManifestSection { + /** Present on bundle packages only. */ + bundle?: DshBundleManifest + /** Present on profile manifests only. */ + profile?: DshProfileManifest +} + +/** The slice of package.json both profiles and bundles use. */ +export interface ProfileManifest { + name?: string + dependencies?: Record + peerDependencies?: Record + dsh?: DshManifestSection +} + +/** One resolved bundle layer of a profile. */ +export interface ProfileLayer { + /** The bundle's package name, as listed in `dsh.profile.bundles`. */ + packageName: string + /** Absolute directory of the resolved bundle package. */ + packageDir: string + /** Absolute path of the bundle's patch file. */ + patchPath: string + /** The parsed patch list. */ + patches: PatchOptions[] +} + +/** A loaded profile: resolved bundle layers plus the user's own patch layer. */ +export interface Profile { + /** The profile name (its directory basename). */ + name: string + /** Absolute profile directory. */ + dir: string + /** Bundle layers in `dsh.profile.bundles` order. */ + layers: ProfileLayer[] + /** Absolute path of the profile's own patch file. */ + patchPath: string + /** The profile's own patches; empty when the file is absent. */ + patches: PatchOptions[] +} + +/** + * Resolve a profile's directory under the Harness home. + * @param name - the profile name (`dsh --profile `). + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @returns the absolute profile directory (which may not exist yet). + */ +export function resolveProfileDir(name: string, home: string = resolveDshHome()): string { + if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..' + // The launcher-maintained flat module fallback lives at this sibling path. + || name === 'node_modules') { + throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`) + } + return join(home, PROFILES_DIR, name) +} + +/** The shipped profile templates auto-initialized on first use, by name. */ +export const PROFILE_TEMPLATES: Record = { + web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], + headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'], +} + +/** The bundle list a `dsh plugin` init uses for a name with no shipped template. */ +export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base'] + +const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: +# a top-level YAML array of loader patch entries (id-targeted config +# overrides, disables, and insert lists; \`!!js\` expressions allowed). +[] +` + +// The hoisted linker gives out-of-tree plugins a flat node_modules whose +// missing peers (cordis and friends) fall through to the healed +// profiles/node_modules installation fallback, so every plugin shares the +// installation's single cordis instance instead of a duplicate. pnpm ≥10 +// reads its settings from pnpm-workspace.yaml, not .npmrc. +const PROFILE_PNPM_WORKSPACE = `packages: + - . + +nodeLinker: hoisted +autoInstallPeers: false +` + +/** + * Initialize a profile directory: manifest, empty user patch layer, and the + * pnpm settings out-of-tree plugins need. Existing files are never touched, + * so re-running is a no-op on an initialized profile. + * @param dir - the profile directory from {@link resolveProfileDir}. + * @param bundles - the initial `dsh.profile.bundles` layer list. + */ +export function initProfile(dir: string, bundles: readonly string[]): void { + mkdirSync(dir, { recursive: true }) + const manifestPath = join(dir, 'package.json') + if (!existsSync(manifestPath)) { + const manifest: ProfileManifest & { private: boolean } = { + name: `dsh-profile-${basename(dir)}`, + private: true, + dependencies: {}, + dsh: { profile: { bundles: [...bundles] } }, + } + writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n') + } + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE) + const workspacePath = join(dir, 'pnpm-workspace.yaml') + if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE) +} + +/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */ +function ensureSymlink(link: string, target: string): void { + let stat + try { + stat = lstatSync(link) + } catch { + // Missing link (first run) — created below. Any other lstat failure on a + // path we just created the parent of would resurface on symlinkSync. + stat = undefined + } + if (stat !== undefined) { + if (!stat.isSymbolicLink()) { + throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) + } + if (readlinkSync(link) === target) return + rmSync(link) + } + try { + symlinkSync(target, link, 'junction') + } catch (error) { + // Concurrent launches heal the same fallback; losing the race to a + // process writing the identical link is success, anything else is not. + // The window between the lstat miss above and this write cannot be + // staged deterministically from the public surface. + /* v8 ignore next 4 */ + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' + || !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) { + throw error + } + } +} + +/** + * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one + * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS + * over `dependencies` from the app manifest), each resolved from its own + * real location. Node's parent-directory walk from any profile finds this + * directory after the profile's own `node_modules`, so every in-box plugin + * resolves without pnpm ever managing it — the exact "bundles come from the + * installation" contract. The closure (not just direct dependencies) is + * required for out-of-tree plugins: their peer dependencies name seam + * packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only + * through its implementation packages. Symlinked packages resolve their own + * dependencies from their real directories (Node's default + * symlink-following), so each package needs only its one flat link. + * Idempotent: correct links are kept and moved installations are + * re-pointed; a stale link to a vanished package stays until its name is + * reused (dangling links are invisible to resolution). + * @param installAnchor - absolute path of the dsh app's package.json. + * @param home - the Harness home; defaults to {@link resolveDshHome}. + */ +export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void { + const profilesDir = join(home, PROFILES_DIR) + const modulesDir = join(profilesDir, 'node_modules') + mkdirSync(modulesDir, { recursive: true }) + const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest + const links = new Map() + /* v8 ignore next -- a real app manifest always declares its name */ + if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor)) + // BFS over the resolvable dependency graph; the visited set is the link + // map itself (first resolution wins, matching Node's own nearest-wins). + const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }] + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + // Peer dependencies participate: seam packages (dsh-subprocess, + // dsh-compact, ...) are peers of their implementations, never plain + // dependencies, yet out-of-tree plugins import them directly. + /* v8 ignore next -- a real app manifest always declares dependencies */ + for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { + if (links.has(dep)) continue + const dir = packageDirFromAnchor(next.anchor, dep) + // A declared-but-uninstalled dependency cannot be a loader-visible + // plugin; skip it rather than fail the whole boot. + if (dir === undefined) continue + links.set(dep, dir) + const manifestPath = join(dir, 'package.json') + queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) + } + } + for (const [packageName, target] of links) { + const link = join(modulesDir, packageName) + mkdirSync(dirname(link), { recursive: true }) + ensureSymlink(link, target) + } +} + +/** + * Read a profile's manifest. + * @param binName - the diagnostic prefix on the thrown error. + * @param dir - the profile directory. + * @returns the parsed manifest. + */ +export function readProfileManifest(binName: string, dir: string): ProfileManifest { + const path = join(dir, 'package.json') + let raw: string + try { + raw = readFileSync(path, 'utf8') + } catch (error) { + throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`) + } + // File boundary: the shape check below validates what the parse type asserts. + const parsed = JSON.parse(raw) as ProfileManifest | null + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`) + } + return parsed +} + +/** + * Write a profile's manifest back (2-space JSON, trailing newline). + * @param dir - the profile directory. + * @param manifest - the manifest value to persist. + */ +export function writeProfileManifest(dir: string, manifest: ProfileManifest): void { + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') +} + +/** + * Resolve a package's root directory from one anchor without depending on the + * package exporting `./package.json` (`require.resolve` would need that): + * probe the require resolution paths for a directory holding the named + * manifest. This is Node's own node_modules lookup order, so the result + * matches what the Loader would import from the same anchor, and + * `existsSync` follows the symlinks pnpm's isolated layout uses. + */ +function packageDirFromAnchor(anchor: string, packageName: string): string | undefined { + // resolve.paths returns null only for builtins, which no bundle name is. + /* v8 ignore next */ + for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) { + const candidate = join(searchPath, packageName) + if (existsSync(join(candidate, 'package.json'))) return candidate + } + return undefined +} + +/** + * Resolve one bundle package's directory: installation anchor first, then the + * profile directory. The installation-first order is the contract that + * `@deepseek-ai/dsh-base` (and every other in-box bundle) always comes from + * the same installation as the running dsh, never from a profile-local copy. + * Resolution does not require the package to export `./package.json`. + * @param binName - the diagnostic prefix on the thrown error. + * @param packageName - the bundle's package name from `dsh.profile.bundles`. + * @param installAnchor - absolute path of a file inside the dsh app package (its package.json). + * @param profileDir - the profile directory (second anchor). + * @returns the bundle package's absolute directory. + */ +export function resolveBundleDir( + binName: string, packageName: string, installAnchor: string, profileDir: string, +): string { + for (const anchor of [installAnchor, join(profileDir, 'package.json')]) { + const dir = packageDirFromAnchor(anchor, packageName) + if (dir !== undefined) return dir + } + throw new Error( + `${binName}: cannot resolve profile bundle ${JSON.stringify(packageName)} from the dsh installation or ${profileDir}; ` + + `run 'dsh plugin --profile ${basename(profileDir)} install' if its dependency is not installed`, + ) +} + +/** + * Load a profile: resolve every `dsh.profile.bundles` entry to its patch + * layer and parse the profile's own patch file. A listed bundle without a + * `dsh.bundle` manifest fails loud — naming a bundle-less package as a layer + * is a misconfiguration, not "no patches". + * @param binName - the diagnostic prefix on thrown errors. + * @param name - the profile name. + * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor). + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a + * bundles-only consumer (`--dump-default-config`, a recovery diagnostic) + * cannot fail on a broken user layer. + * @returns the loaded profile (empty `patches` when the user layer is skipped). + */ +export function loadProfile( + binName: string, name: string, installAnchor: string, home: string = resolveDshHome(), + options: { userLayer?: boolean } = {}, +): Profile { + const dir = resolveProfileDir(name, home) + if (!existsSync(join(dir, 'package.json'))) { + const template = PROFILE_TEMPLATES[name] + if (template === undefined) { + throw new Error( + `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add '`, + ) + } + initProfile(dir, template) + } + const manifest = readProfileManifest(binName, dir) + // A hand-written profile manifest may omit the dsh section entirely. + const bundles = manifest.dsh?.profile?.bundles ?? [] + const layers = bundles.map((packageName): ProfileLayer => { + const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) + const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest + const declared = bundleManifest.dsh?.bundle?.patch + if (declared === undefined) { + throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) + } + const patchPath = join(packageDir, declared) + return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + }) + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + const patches = options.userLayer !== false && existsSync(patchPath) + ? loadOverlayPatches(binName, patchPath) + : [] + return { name, dir, layers, patchPath, patches } +} + +/** + * Compose patch layers into the effective entry list over an empty root — + * the same single `applyEntryPatches` call the boot include makes, so flag + * derivation and config dumps see exactly what mounts. + * @param layers - patch lists in application order. + * @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them). + * @returns the composed entry list. + */ +export function composeEntries( + layers: readonly PatchOptions[][], warn: (message: string) => void = () => {}, +): EntryOptions[] { + return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => { + let index = 0 + warn(message.replace(/%C/g, () => JSON.stringify(args[index++]))) + }) +} diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 1e88954f69..45eab9ea7d 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -341,11 +341,11 @@ describe('include refresh with overlay patches', () => { describe('include patches layered over one base', () => { it('lets a later patch configure or disable a row an earlier patch inserted', async () => { - // The surface/`--config`/personal composition: `dsh` includes one shared - // base and applies each source as its own patch list at the SAME include + // The bundle/user-layer/`--patch` composition: `dsh` includes one root + // and applies each source as its own patch list at the SAME include // level, because patches never cross an include boundary. A later layer // must therefore be able to reach a row an earlier layer inserted, or - // surface-only rows would be invisible to the user's personal config. + // bundle-only rows would be invisible to the user's patch layer. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') @@ -355,30 +355,30 @@ describe('include patches layered over one base', () => { ' config:', ' path: ./base.yml', ' patches:', - // Layer 1 (a surface overlay): patch a base row and add two of its own. + // Layer 1 (a bundle layer): patch a base row and add two of its own. ' - id: shared', ' config:', - ' value: surface', + ' value: bundle', ' - insert:', - ' - id: surface-kept', + ' - id: bundle-kept', ' name: ./noop.mjs', ' config:', - ' value: surface-default', - ' - id: surface-dropped', + ' value: bundle-default', + ' - id: bundle-dropped', ' name: ./noop.mjs', // Layer 2 (the user): reconfigure one inserted row and disable the other. - ' - id: surface-kept', + ' - id: bundle-kept', ' config:', - ' value: personal', - ' - id: surface-dropped', + ' value: user', + ' - id: bundle-dropped', ' disabled: true', '', ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) try { - expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' }) - expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' }) - const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped') + expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' }) + expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' }) + const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped') expect(dropped?.options.disabled).toBe(true) expect(dropped?.fiber).toBeUndefined() } finally { diff --git a/packages/ui/app-boot/tests/profile.spec.ts b/packages/ui/app-boot/tests/profile.spec.ts new file mode 100644 index 0000000000..f0bd6f5da7 --- /dev/null +++ b/packages/ui/app-boot/tests/profile.spec.ts @@ -0,0 +1,245 @@ +/** + * Profile machinery of `dsh-app-boot`: directory resolution and init, + * manifest round-trips, two-anchor bundle resolution, patch-layer loading, + * empty-root composition, and the installation module-fallback healing. + */ + +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + composeEntries, + healProfilesModuleFallback, + initProfile, + loadProfile, + PROFILE_PATCH_FILENAME, + PROFILE_TEMPLATES, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + writeProfileManifest, +} from '../src/index.ts' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-')) + +/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ +function stageInstallation(bundles: Record }>): string { + const root = tmp() + const appDir = join(root, 'app') + mkdirSync(join(appDir, 'node_modules'), { recursive: true }) + const appDeps: Record = {} + for (const [name, spec] of Object.entries(bundles)) { + appDeps[name] = '0.0.0' + const dir = join(appDir, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name, + version: '0.0.0', + dependencies: spec.deps ?? {}, + ...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } }, + })) + if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch) + } + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps })) + return join(appDir, 'package.json') +} + +describe('resolveProfileDir', () => { + it('joins the home and rejects traversal-shaped names', () => { + const home = tmp() + expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui')) + for (const bad of ['', '.', '..', 'a/b', 'a\\b']) { + expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name') + } + }) +}) + +describe('initProfile', () => { + it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => { + const home = tmp() + const dir = resolveProfileDir('tui', home) + initProfile(dir, ['@deepseek-ai/dsh-base']) + const manifest = readProfileManifest('t', dir) + expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]') + expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted') + // Re-init keeps user edits. + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n') + initProfile(dir, ['other']) + expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x') + }) +}) + +describe('manifest round-trip', () => { + it('writes and reads back, and fails loud on a broken manifest', () => { + const dir = tmp() + writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } }) + expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a']) + writeFileSync(join(dir, 'package.json'), '[]') + expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object') + expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest') + }) +}) + +describe('resolveBundleDir', () => { + it('prefers the installation anchor, falls back to the profile, and fails loud', () => { + const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } }) + const profileDir = tmp() + mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), '{}') + writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' })) + expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box') + expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only') + expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle') + }) + + it('resolves a package whose exports map omits ./package.json', () => { + // Common on npm: an exports map without "./package.json" makes + // require.resolve('/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED; + // resolution must fall through to the paths probe instead of misreporting + // the installed package as missing. + const anchor = stageInstallation({}) + const profileDir = tmp() + writeFileSync(join(profileDir, 'package.json'), '{}') + const dir = join(profileDir, 'node_modules', 'sealed-bundle') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'sealed-bundle', + version: '0.0.0', + exports: { '.': './index.js' }, + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(dir, 'index.js'), '') + writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n') + expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir) + }) +}) + +describe('loadProfile', () => { + it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' }, + 'bundle-b': { patch: '- id: a\n config:\n v: 2\n' }, + }) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['bundle-a', 'bundle-b']) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n') + const profile = loadProfile('t', 'demo', anchor, home) + expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b']) + expect(profile.patches).toHaveLength(1) + const entries = composeEntries([ + ...profile.layers.map(layer => layer.patches), + profile.patches, + ]) + expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }]) + // A hand-made profile without the user layer file or dsh section: empty layers, no throw. + rmSync(join(dir, PROFILE_PATCH_FILENAME)) + expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([]) + writeProfileManifest(dir, { name: 'bare' }) + const bare = loadProfile('t', 'demo', anchor, home) + expect(bare.layers).toEqual([]) + }) + + it('auto-initializes only shipped templates and fails loud otherwise', () => { + const anchor = stageInstallation({}) + const home = tmp() + expect(() => loadProfile('t', 'custom', anchor, home)) + .toThrow('profile "custom" does not exist') + // The web template auto-initializes on first load. Bundle resolution + // cannot be asserted to fail here: the source-plane test runner resolves + // @deepseek-ai/* through tsconfig paths regardless of the staged anchor. + expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base') + try { + loadProfile('t', 'web', anchor, home) + } catch { + // Resolution failure is the plain-Node outcome for this empty anchor. + } + expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles) + .toEqual([...PROFILE_TEMPLATES.web ?? []]) + }) + + it('fails loud when a listed bundle declares no dsh.bundle', () => { + const anchor = stageInstallation({ 'not-a-bundle': {} }) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, ['not-a-bundle']) + expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle') + }) +}) + +describe('composeEntries', () => { + it('applies layers over an empty root and reports skipped patches', () => { + const warnings: string[] = [] + const entries = composeEntries([ + [{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }], + [{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }], + ], message => warnings.push(message)) + expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }]) + expect(warnings.join('\n')).toContain('"missing"') + // Default warn sink: skipped patches are silently dropped (boot repeats them). + expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([]) + }) +}) + +describe('healProfilesModuleFallback', () => { + it('links the app and bundle dependency surface flat under profiles/node_modules', () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } }, + 'plain-lib': {}, + }) + // An app dependency that is declared but not installed: skipped, not fatal. + const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record } + appManifest.dependencies['never-installed'] = '0.0.0' + writeFileSync(anchor, JSON.stringify(appManifest)) + // dep-of-a lives in the installation's node_modules too. + const modules = join(anchor, '..', 'node_modules') + mkdirSync(join(modules, 'dep-of-a'), { recursive: true }) + writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' })) + const home = tmp() + healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + // App deps, the bundle's own deps, and the bundle itself are linked; the + // plain library is linked as an app dep (harmless), the app itself too. + for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) { + expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true) + } + // Idempotent, and a moved target is re-pointed. + healProfilesModuleFallback(anchor, home) + const before = readlinkSync(join(fallback, 'dep-of-a')) + expect(before).toContain('dep-of-a') + }) + + it('throws when a fallback entry is a real directory', () => { + const anchor = stageInstallation({}) + const home = tmp() + mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true }) + expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink') + }) + + it('replaces a wrong symlink', () => { + const anchor = stageInstallation({}) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules') + mkdirSync(fallback, { recursive: true }) + symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction') + healProfilesModuleFallback(anchor, home) + expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') + }) + + it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => { + // The EEXIST arm: a second process wrote the link between our lstat miss + // and symlinkSync. Simulated by pre-creating the correct link and calling + // the internal path through a stale-lstat shim is not possible from + // outside, so probe the observable contract: healing twice concurrently + // is a no-op, and a foreign REAL directory still fails loud. + const anchor = stageInstallation({}) + const home = tmp() + healProfilesModuleFallback(anchor, home) + healProfilesModuleFallback(anchor, home) // second healer sees the correct link + const fallback = join(home, 'profiles', 'node_modules') + expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) + }) +}) diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/user-patches.spec.ts similarity index 63% rename from packages/ui/app-boot/tests/personal-config.spec.ts rename to packages/ui/app-boot/tests/user-patches.spec.ts index 7c92d53e56..333385ee50 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/user-patches.spec.ts @@ -1,7 +1,7 @@ /** - * Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`) - * `config.yaml` overlay loader and `boot()` applying the personal overlay over - * a real Loader tree. + * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader + * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over + * a real Loader tree, kept live through transactional HMR. */ import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' @@ -15,14 +15,14 @@ import Loader from '@cordisjs/plugin-loader' import Timer from '@cordisjs/plugin-timer' import { boot, - loadPersonalPatches, - PERSONAL_CONFIG_FILENAME, - watchPersonalPatches, + loadOptionalPatches, + PROFILE_PATCH_FILENAME, + watchUserPatches, } from '../src/index.ts' const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) async function eventually(test: () => boolean, message: string): Promise { const deadline = Date.now() + 10_000 @@ -34,20 +34,20 @@ async function eventually(test: () => boolean, message: string): Promise { const settleChokidarChangeThrottle = (): Promise => new Promise(resolve => setTimeout(resolve, 75)) -describe('loadPersonalPatches', () => { +describe('loadOptionalPatches', () => { afterEach(() => { delete process.env.DSH_HOME }) - it('returns undefined when no personal patches file exists', () => { - expect(loadPersonalPatches(NAME, tmp())).toBeUndefined() + it('returns undefined when no user patch file exists', () => { + expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined() }) it('parses a patch list and preserves !!js expressions as loader expression nodes', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [ + '- id: agent-loop', + " name: '@deepseek-ai/dsh-agent-loop'", ' config:', ' model: !!js process.env.DSH_SPEC_MODEL', '- insert:', @@ -55,51 +55,44 @@ describe('loadPersonalPatches', () => { " name: '@deepseek-ai/dsh-llm-pi-ai'", '', ].join('\n')) - const patches = loadPersonalPatches(NAME, dir) + const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)) expect(patches).toHaveLength(2) expect(patches?.[0]).toMatchObject({ - id: 'tui-agent', + id: 'agent-loop', config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } }, }) expect(patches?.[1]?.insert).toHaveLength(1) }) - it('defaults its directory to the Harness home ($DSH_HOME)', () => { + it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n') - process.env.DSH_HOME = dir - expect(loadPersonalPatches(NAME)).toHaveLength(1) - }) - - it('fails loud on an unreadable file (a present personal config is never skipped)', () => { - const dir = tmp() - mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to read personal patches `)) + mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to read patches `)) }) it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(new RegExp(`^${NAME}: failed to parse personal patches `)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to parse patches `)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config:\n a: !!js\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(new RegExp(`^${NAME}: failed to parse patches `)) }) it('fails loud when the file is not a top-level array or an entry is not an object', () => { const dir = tmp() - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n') - expect(() => loadPersonalPatches(NAME, dir)) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) .toThrow('must be a top-level YAML array of loader patch entries') - writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n') - expect(() => loadPersonalPatches(NAME, dir)) - .toThrow(`${NAME}: personal patches entry 1 in`) + writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n') + expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) + .toThrow(`${NAME}: patches entry 1 in`) }) }) -describe('boot with personal patches', () => { +describe('boot with user patches', () => { function writeTree(dir: string): string { writeFileSync(join(dir, 'noop.mjs'), [ 'export const name = "noop"', @@ -118,41 +111,41 @@ describe('boot with personal patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() - const personal = tmp() - writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [ + const userDir = tmp() + writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [ '- id: noop', ' name: ./noop.mjs', ' config:', - ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC', + ' value: !!js process.env.DSH_APP_BOOT_USER_SPEC', '- insert:', - ' - id: personal-extra', + ' - id: user-extra', ' name: ./noop.mjs', '', ].join('\n')) - process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value' - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal)) + process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value' + const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME))) try { const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'personal-value' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true) + expect(noop?.fiber?.config).toEqual({ value: 'user-value' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true) } finally { await ctx.fiber.dispose() - delete process.env['DSH_APP_BOOT_PERSONAL_SPEC'] + delete process.env['DSH_APP_BOOT_USER_SPEC'] } }) - it('mounts no patch layer for an absent or empty personal overlay', async () => { + it('mounts no patch layer for an absent or empty user layer', async () => { const dir = tmp() - const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp())) + const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))) try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' }) } finally { await ctx.fiber.dispose() } const empty = tmp() - writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n') - const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty)) + writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n') + const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME))) try { expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' }) } finally { @@ -162,8 +155,8 @@ describe('boot with personal patches', () => { it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { const dir = tmp() - const personal = tmp() - const filename = join(personal, PERSONAL_CONFIG_FILENAME) + const userDir = tmp() + const filename = join(userDir, PROFILE_PATCH_FILENAME) const basePatches = [{ id: 'noop', config: { value: 'generated' } }] const ctx = await boot(NAME, writeTree(dir), basePatches) await ctx.plugin(Timer) @@ -172,14 +165,14 @@ describe('boot with personal patches', () => { ctx.on('hmr/config-update-failed', (failedFilename, error) => { failures.push({ filename: failedFilename, error }) }) - const dispose = await watchPersonalPatches(ctx, { + const dispose = await watchUserPatches(ctx, { binName: NAME, - dir: personal, - compose: personalPatches => [...basePatches, ...personalPatches], + filename, + compose: userPatches => [...basePatches, ...userPatches], }) try { writeFileSync(filename, '- id: noop\n config:\n value: live\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied') writeFileSync(filename, '- id: noop\n config:\n fail: true\n') await eventually(() => failures.length === 1, 'failed candidate was not broadcast') @@ -199,17 +192,17 @@ describe('boot with personal patches', () => { await settleChokidarChangeThrottle() unlinkSync(filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch') expect(failures).toHaveLength(2) await settleChokidarChangeThrottle() - // Default compose: the personal overlay IS the whole patch list, so a + // Default compose: the user layer IS the whole patch list, so a // fresh generation replaces the app-owned layer instead of stacking on it. await dispose() - const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) + const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename }) try { writeFileSync(filename, '- id: noop\n config:\n value: identity\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied') } finally { await disposeDefault() } @@ -222,7 +215,7 @@ describe('boot with personal patches', () => { it('fails loud when the exact watcher lacks HMR or a root Include', async () => { const dir = tmp() const withoutHmr = await boot(NAME, writeTree(dir)) - await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service') + await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service') await withoutHmr.fiber.dispose() const withoutInclude = new Context() @@ -230,7 +223,7 @@ describe('boot with personal patches', () => { await withoutInclude.plugin(Loader) await withoutInclude.plugin(Timer) await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry') + await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry') await withoutInclude.fiber.dispose() }) @@ -245,7 +238,7 @@ describe('boot with personal patches', () => { try { const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() }) + const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) await expect(dispose()).resolves.toBeUndefined() } finally { await ctx.fiber.dispose() @@ -254,14 +247,14 @@ describe('boot with personal patches', () => { it('propagates registration failures other than mid-teardown', async () => { const dir = tmp() - const personal = tmp() + const filename = join(tmp(), PROFILE_PATCH_FILENAME) const ctx = await boot(NAME, writeTree(dir)) try { await ctx.plugin(Timer) await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal }) - // Same personal path registered twice: HMR refuses; not a teardown race. - await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered') + const dispose = await watchUserPatches(ctx, { binName: NAME, filename }) + // Same user-layer path registered twice: HMR refuses; not a teardown race. + await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered') await dispose() } finally { await ctx.fiber.dispose() diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index e44b171c37..797e0cbfea 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -72,7 +72,7 @@ export class HarnessSdkServer { const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) - this.disposers.push(ctx.on('agent/status', (agent, status) => { + this.disposers.push(ctx.on('agent/status', ({ agent, status }) => { this.transport.notify('session.status', { sessionId: String(agent.session.id), status }) })) this.disposers.push(ctx.on('session/created', (session) => { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 78f3e11983..c9d1944781 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -254,8 +254,8 @@ describe('HarnessSdkServer', () => { session, } satisfies Pick) as Agent - ctx.emit('agent/status', agent, 'running') - ctx.emit('agent/status', agent, 'idle') + ctx.emit('agent/status', { agent, status: 'running' }) + ctx.emit('agent/status', { agent, status: 'idle' }) expect(transport.notifications.filter(notification => notification.method === 'session.status')) .toEqual([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208e43aa5f..b826046bea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,348 +140,45 @@ importers: '@cordisjs/plugin-timer': specifier: workspace:* version: link:../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../packages/core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../packages/core/agent-loop '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot - '@deepseek-ai/dsh-bash-env': + '@deepseek-ai/dsh-base': specifier: workspace:^ - version: link:../../packages/bash/bash-env - '@deepseek-ai/dsh-bash-local': + version: link:../../packages/bundle/base + '@deepseek-ai/dsh-headless': specifier: workspace:^ - version: link:../../packages/bash/bash-local - '@deepseek-ai/dsh-bash-sandbox': - specifier: workspace:^ - version: link:../../packages/bash/bash-sandbox - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../packages/client/connection - '@deepseek-ai/dsh-client-hmr': - specifier: workspace:^ - version: link:../../packages/client/hmr - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../packages/client/locale - '@deepseek-ai/dsh-client-modules': - specifier: workspace:^ - version: link:../../packages/client/modules - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../packages/client/runtime - '@deepseek-ai/dsh-client-ui-command': - specifier: workspace:^ - version: link:../../packages/client/ui-command - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../../packages/client/ui-conversation - '@deepseek-ai/dsh-client-ui-goal': - specifier: workspace:^ - version: link:../../packages/client/ui-goal - '@deepseek-ai/dsh-client-ui-layout': - specifier: workspace:^ - version: link:../../packages/client/ui-layout - '@deepseek-ai/dsh-client-ui-model': - specifier: workspace:^ - version: link:../../packages/client/ui-model - '@deepseek-ai/dsh-client-ui-models': - specifier: workspace:^ - version: link:../../packages/client/ui-models - '@deepseek-ai/dsh-client-ui-permission': - specifier: workspace:^ - version: link:../../packages/client/ui-permission - '@deepseek-ai/dsh-client-ui-plan': - specifier: workspace:^ - version: link:../../packages/client/ui-plan - '@deepseek-ai/dsh-client-ui-question': - specifier: workspace:^ - version: link:../../packages/client/ui-question - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../../packages/client/ui-settings - '@deepseek-ai/dsh-client-ui-settings-general': - specifier: workspace:^ - version: link:../../packages/client/ui-settings-general - '@deepseek-ai/dsh-client-ui-sidebar': - specifier: workspace:^ - version: link:../../packages/client/ui-sidebar - '@deepseek-ai/dsh-client-ui-skill': - specifier: workspace:^ - version: link:../../packages/client/ui-skill - '@deepseek-ai/dsh-client-ui-slash': - specifier: workspace:^ - version: link:../../packages/client/ui-slash - '@deepseek-ai/dsh-client-ui-subagent': - specifier: workspace:^ - version: link:../../packages/client/ui-subagent - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../../packages/client/ui-theme - '@deepseek-ai/dsh-client-ui-trajectory': - specifier: workspace:^ - version: link:../../packages/client/ui-trajectory - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../packages/client/ui-workspace - '@deepseek-ai/dsh-code-runtime-worker': - specifier: workspace:^ - version: link:../../packages/code-runtime/code-runtime-worker - '@deepseek-ai/dsh-command-compact': - specifier: workspace:^ - version: link:../../packages/compact/command-compact - '@deepseek-ai/dsh-command-goal': - specifier: workspace:^ - version: link:../../packages/goal/command-goal - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../packages/ui/commands - '@deepseek-ai/dsh-compact-basic': - specifier: workspace:^ - version: link:../../packages/compact/compact-basic - '@deepseek-ai/dsh-compact-tool-result-prune': - specifier: workspace:^ - version: link:../../packages/compact/compact-tool-result-prune - '@deepseek-ai/dsh-credentials-local': - specifier: workspace:^ - version: link:../../packages/credentials/credentials-local - '@deepseek-ai/dsh-frontend': - specifier: workspace:^ - version: link:../web - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../packages/fs/fs-local - '@deepseek-ai/dsh-fs-policy': - specifier: workspace:^ - version: link:../../packages/fs/fs-policy - '@deepseek-ai/dsh-fs-sandbox': - specifier: workspace:^ - version: link:../../packages/fs/fs-sandbox - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../packages/goal/goal - '@deepseek-ai/dsh-goal-session': - specifier: workspace:^ - version: link:../../packages/goal/goal-session - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-auto': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-auto - '@deepseek-ai/dsh-host-directory-picker-browse': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-browse - '@deepseek-ai/dsh-host-directory-picker-native': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-native - '@deepseek-ai/dsh-host-webserver': - specifier: workspace:^ - version: link:../../packages/host/webserver - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../packages/llm/llm - '@deepseek-ai/dsh-llm-deepseek': - specifier: workspace:^ - version: link:../../packages/llm/llm-deepseek - '@deepseek-ai/dsh-llm-pi-ai': - specifier: workspace:^ - version: link:../../packages/llm/llm-pi-ai - '@deepseek-ai/dsh-llm-retry': - specifier: workspace:^ - version: link:../../packages/llm/llm-retry + version: link:../../packages/bundle/headless '@deepseek-ai/dsh-mcp-client': specifier: workspace:^ version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../packages/ui/permission - '@deepseek-ai/dsh-plan-mode': - specifier: workspace:^ - version: link:../../packages/plan/plan-mode '@deepseek-ai/dsh-pty': specifier: workspace:^ version: link:../../packages/pty/pty '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local - '@deepseek-ai/dsh-pwsh-local': + '@deepseek-ai/dsh-session-reference': specifier: workspace:^ - version: link:../../packages/bash/pwsh-local - '@deepseek-ai/dsh-repeat-tool-guard': + version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-tmux-context': specifier: workspace:^ - version: link:../../packages/guard/repeat-tool-guard - '@deepseek-ai/dsh-repository-plugin': + version: link:../../packages/context/tmux-context + '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:^ - version: link:../../packages/cordis/repository-plugin - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-policy - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../packages/core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../packages/core/session - '@deepseek-ai/dsh-session-checkpoint-policy': - specifier: workspace:^ - version: link:../../packages/session-persistence/session-checkpoint-policy - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../packages/session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../packages/session-projection/session-projection - '@deepseek-ai/dsh-session-projection-cache': - specifier: workspace:^ - version: link:../../packages/session-projection/session-projection-cache - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../packages/session-query/session-query - '@deepseek-ai/dsh-session-query-sqlite': - specifier: workspace:^ - version: link:../../packages/session-query/session-query-sqlite - '@deepseek-ai/dsh-session-telemetry-otel': - specifier: workspace:^ - version: link:../../packages/telemetry/session-telemetry-otel - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../packages/session-title/session-title - '@deepseek-ai/dsh-session-title-first-message-llm': - specifier: workspace:^ - version: link:../../packages/session-title/session-title-first-message-llm - '@deepseek-ai/dsh-settings-local': - specifier: workspace:^ - version: link:../../packages/settings/settings-local - '@deepseek-ai/dsh-skill': - specifier: workspace:^ - version: link:../../packages/skill/skill - '@deepseek-ai/dsh-skill-local': - specifier: workspace:^ - version: link:../../packages/skill/skill-local - '@deepseek-ai/dsh-spill-local': - specifier: workspace:^ - version: link:../../packages/spill/spill-local - '@deepseek-ai/dsh-spill-policy': - specifier: workspace:^ - version: link:../../packages/spill/spill-policy - '@deepseek-ai/dsh-storage': - specifier: workspace:^ - version: link:../../packages/storage/storage - '@deepseek-ai/dsh-storage-domain': - specifier: workspace:^ - version: link:../../packages/storage/storage-domain - '@deepseek-ai/dsh-storage-json': - specifier: workspace:^ - version: link:../../packages/storage/storage-json - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../packages/subagent/subagent - '@deepseek-ai/dsh-subagent-fork': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-fork - '@deepseek-ai/dsh-subagent-spawn': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-spawn - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../packages/subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../packages/core/system-prompt - '@deepseek-ai/dsh-tasks-local': - specifier: workspace:^ - version: link:../../packages/tasks/tasks-local - '@deepseek-ai/dsh-timeout-policy': - specifier: workspace:^ - version: link:../../packages/timeout/timeout-policy - '@deepseek-ai/dsh-token-meter': - specifier: workspace:^ - version: link:../../packages/llm/token-meter - '@deepseek-ai/dsh-tool-bash': - specifier: workspace:^ - version: link:../../packages/bash/tool-bash + version: link:../../packages/ui/tool-ask-user '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis - '@deepseek-ai/dsh-tool-fs': + '@deepseek-ai/dsh-web-app': specifier: workspace:^ - version: link:../../packages/fs/tool-fs - '@deepseek-ai/dsh-tool-fs-search': - specifier: workspace:^ - version: link:../../packages/fs/tool-fs-search - '@deepseek-ai/dsh-tool-goal': - specifier: workspace:^ - version: link:../../packages/goal/tool-goal - '@deepseek-ai/dsh-tool-pwsh': - specifier: workspace:^ - version: link:../../packages/bash/tool-pwsh - '@deepseek-ai/dsh-tool-ralph': - specifier: workspace:^ - version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../packages/skill/tool-skill - '@deepseek-ai/dsh-tool-str-replace-editor': - specifier: workspace:^ - version: link:../../packages/fs/tool-str-replace-editor - '@deepseek-ai/dsh-tool-subagent': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent - '@deepseek-ai/dsh-tool-subagent-control': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-subagent-report': - specifier: workspace:^ - version: link:../../packages/subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-tasks': - specifier: workspace:^ - version: link:../../packages/tasks/tool-tasks - '@deepseek-ai/dsh-tool-todo': - specifier: workspace:^ - version: link:../../packages/todo/tool-todo - '@deepseek-ai/dsh-tool-web': - specifier: workspace:^ - version: link:../../packages/web/tool-web - '@deepseek-ai/dsh-tool-workflow': - specifier: workspace:^ - version: link:../../packages/workflow/tool-workflow - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../packages/core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../packages/ui/user-approval - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../packages/ui/user-interaction - '@deepseek-ai/dsh-web': - specifier: workspace:^ - version: link:../../packages/web/web - '@deepseek-ai/dsh-web-search-deepseek': - specifier: workspace:^ - version: link:../../packages/web/web-search-deepseek - '@deepseek-ai/dsh-workflow-workerthread': - specifier: workspace:^ - version: link:../../packages/workflow/workflow-workerthread - '@deepseek-ai/dsh-workspace': - specifier: workspace:^ - version: link:../../packages/workspace/workspace - '@deepseek-ai/dsh-workspace-context': - specifier: workspace:^ - version: link:../../packages/context/workspace-context + version: link:../../packages/bundle/web-app commander: specifier: ^15.0.0 version: 15.0.0 @@ -495,6 +192,24 @@ importers: specifier: ^0.1.4 version: 0.1.4 devDependencies: + '@deepseek-ai/dsh-frontend-static': + specifier: workspace:^ + version: link:../../packages/host/frontend-static + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../packages/host/webserver + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../packages/support/loader-smoke + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../packages/core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../packages/core/tools '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -604,6 +319,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:* version: link:../packages/ui/commands + '@deepseek-ai/dsh-compact': + specifier: workspace:* + version: link:../packages/compact/compact '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic @@ -739,6 +457,12 @@ importers: '@deepseek-ai/dsh-subagent-acp': specifier: workspace:* version: link:../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:* + version: link:../packages/subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:* + version: link:../packages/subagent/subagent-codex '@deepseek-ai/dsh-subagent-dsh-sdk': specifier: workspace:* version: link:../packages/subagent/subagent-dsh-sdk @@ -1130,6 +854,375 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bundle/base: + dependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:* + version: link:../../../vendor/hmr + '@cordisjs/plugin-timer': + specifier: workspace:* + version: link:../../../vendor/timer + '@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-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../bash/bash-sandbox + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../compact/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../compact/compact-tool-result-prune + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../goal/goal-session + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../llm/llm-pi-ai + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../plan/plan-mode + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:^ + version: link:../../guard/repeat-tool-guard + '@deepseek-ai/dsh-repository-plugin': + specifier: workspace:^ + version: link:../../cordis/repository-plugin + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../session-persistence/session-checkpoint-policy + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../session-query/session-query-sqlite + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:^ + version: link:../../telemetry/session-telemetry-otel + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + '@deepseek-ai/dsh-session-title-first-message-llm': + specifier: workspace:^ + version: link:../../session-title/session-title-first-message-llm + '@deepseek-ai/dsh-settings-local': + specifier: workspace:^ + version: link:../../settings/settings-local + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../skill/skill-local + '@deepseek-ai/dsh-spill-local': + specifier: workspace:^ + version: link:../../spill/spill-local + '@deepseek-ai/dsh-spill-policy': + specifier: workspace:^ + version: link:../../spill/spill-policy + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../../subagent/subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tasks-local': + specifier: workspace:^ + version: link:../../tasks/tasks-local + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../timeout/timeout-policy + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../workflow/tool-ralph + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../fs/tool-str-replace-editor + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../subagent/tool-subagent + '@deepseek-ai/dsh-tool-subagent-control': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-subagent-report': + specifier: workspace:^ + version: link:../../subagent/tool-subagent-report + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:^ + version: link:../../tasks/tool-tasks + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../web/web + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../web/web-search-deepseek + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/bundle/headless: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/bundle/web-app: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-hmr': + specifier: workspace:^ + version: link:../../client/hmr + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../../client/ui-command + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-goal': + specifier: workspace:^ + version: link:../../client/ui-goal + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-model': + specifier: workspace:^ + version: link:../../client/ui-model + '@deepseek-ai/dsh-client-ui-models': + specifier: workspace:^ + version: link:../../client/ui-models + '@deepseek-ai/dsh-client-ui-permission': + specifier: workspace:^ + version: link:../../client/ui-permission + '@deepseek-ai/dsh-client-ui-plan': + specifier: workspace:^ + version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-question': + specifier: workspace:^ + version: link:../../client/ui-question + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-general': + specifier: workspace:^ + version: link:../../client/ui-settings-general + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../../client/ui-sidebar + '@deepseek-ai/dsh-client-ui-skill': + specifier: workspace:^ + version: link:../../client/ui-skill + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../../client/ui-slash + '@deepseek-ai/dsh-client-ui-subagent': + specifier: workspace:^ + version: link:../../client/ui-subagent + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../../client/ui-theme + '@deepseek-ai/dsh-client-ui-trajectory': + specifier: workspace:^ + version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:^ + version: link:../../code-runtime/code-runtime-worker + '@deepseek-ai/dsh-frontend': + specifier: workspace:^ + version: link:../../../apps/web + '@deepseek-ai/dsh-frontend-static': + specifier: workspace:^ + version: link:../../host/frontend-static + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-auto': + specifier: workspace:^ + version: link:../../host/directory-picker-auto + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../../host/directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session-projection/session-projection-cache + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + '@deepseek-ai/dsh-workspace': + specifier: workspace:^ + version: link:../../workspace/workspace + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-bash-env': + specifier: workspace:^ + version: link:../../bash/bash-env + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/connection: dependencies: '@deepseek-ai/dsh-commands': @@ -3182,6 +3275,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -3714,6 +3810,25 @@ importers: specifier: ^4.19.2 version: 4.22.4 + packages/host/frontend-static: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/host/webserver: dependencies: schemastery: @@ -5124,6 +5239,98 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/subagent/subagent-claude-code: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.220 + version: 0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + '@anthropic-ai/sdk': + specifier: 0.93.0 + version: 0.93.0(zod@4.4.3) + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/subagent/subagent-codex: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-sdk-protocol': + specifier: workspace:^ + version: link:../../sdk/sdk-protocol + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@openai/codex': + specifier: 0.146.0 + version: 0.146.0 cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5462,6 +5669,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -6964,6 +7174,58 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + resolution: {integrity: sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + resolution: {integrity: sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + resolution: {integrity: sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + resolution: {integrity: sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + resolution: {integrity: sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + resolution: {integrity: sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + resolution: {integrity: sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + resolution: {integrity: sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.220': + resolution: {integrity: sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -6973,6 +7235,15 @@ packages: zod: optional: true + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -7979,6 +8250,47 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@openai/codex@0.146.0': + resolution: {integrity: sha512-yG3sPWNda/2YAIQIDq9MrrjoCTIQ7rxYM5IasrG3VBcuhCLTkgeg/JzqmJq1V98RE4MJ5jCxDXXQlOjrditFRw==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.146.0-darwin-arm64': + resolution: {integrity: sha512-nb61yX4r5L6Z0dlC4o3u0GAK1YCd4TUvjaB382bajDoh84V+uv2hTBIVZ++fgXWV9yoeuNrNnNcn7GoTGOe2Tg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.146.0-darwin-x64': + resolution: {integrity: sha512-hTQR5jy/ObfTf1MDnuJCZJAe+SljKE8DDwQWN6lDFgjsPhMQz852U2tILt8Ei+G5GkQSzemHYKl2AYPwW0Y5xw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.146.0-linux-arm64': + resolution: {integrity: sha512-qiYDxkkEFnXG7joadJW6Q+XcgyDXCpGdpa9nk/c+i0gEomur1j7bHvx12NfWWCF/y8Tqri6ay+FLuC2MjdehtA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.146.0-linux-x64': + resolution: {integrity: sha512-fswvyGprAPCMiOEue/7MKMk7pCjh9kZIJfJX5i9atmfnmGYbYCcUhZsEH9LEP0+0t5xyPqDbfNXY7NSxIVuXxA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.146.0-win32-arm64': + resolution: {integrity: sha512-EW6zdjDe+SLX2Iw+xymJ5+Pz2+DGexdstfFHXh4Ub+TfJsQPiMjGfZfNaoWgdJ2FsqSIzVKu2+G0KCMGYz2W8g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.146.0-win32-x64': + resolution: {integrity: sha512-b3lxMYeR0+IhstNo4JjX1P9cPc1xwVcCVkPd1lD1wpWPJ0SBhpIkPczwbu3ZRkJcdyl342+rgyf4DUrbZLdrGA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -12221,12 +12533,57 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.220 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.220 + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: zod: 4.4.3 + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -13202,6 +13559,33 @@ snapshots: '@nodable/entities@2.2.0': {} + '@openai/codex@0.146.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.146.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.146.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.146.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.146.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.146.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.146.0-win32-x64' + + '@openai/codex@0.146.0-darwin-arm64': + optional: true + + '@openai/codex@0.146.0-darwin-x64': + optional: true + + '@openai/codex@0.146.0-linux-arm64': + optional: true + + '@openai/codex@0.146.0-linux-x64': + optional: true + + '@openai/codex@0.146.0-win32-arm64': + optional: true + + '@openai/codex@0.146.0-win32-x64': + optional: true + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/attribute-chunk-bytes.mjs b/scripts/attribute-chunk-bytes.mjs new file mode 100644 index 0000000000..d7cc1130d9 Binary files /dev/null and b/scripts/attribute-chunk-bytes.mjs differ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 6bd613c2ea..e0b9344cdf 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -102,6 +102,10 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { + // Profile bundles publish their dsh.bundle.patch layer beside the lib. + '@deepseek-ai/dsh-base': ['cordis.patch.yml'], + '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], + '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs index 64fbe0e72d..43a23ab250 100644 --- a/scripts/demo-cordis.mjs +++ b/scripts/demo-cordis.mjs @@ -6,7 +6,7 @@ import { spawn } from 'node:child_process' const SURFACES = new Map([ // The browser surface with the cordis toolset layered on: `dsh web --config` // applies this overlay over the shipped web composition; it owns port 3081. - ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--config', 'examples/web-cordis/cordis.yml']], + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--patch', 'examples/web-cordis/cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], ]) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 57c3d203d4..bcf90d1e82 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -32,6 +32,7 @@ export const LINK_MAP: Readonly> = { HookContext: 'core.md', SettleReason: 'core.md', AdapterRegistrationHandle: 'core.md', + DirectoryRegistrationHandle: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', LlmModelReasoningInfo: 'core.md', @@ -40,6 +41,8 @@ export const LINK_MAP: Readonly> = { LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', LlmConfigurableProvider: 'core.md', + LlmModelDiscoveryRequest: 'core.md', + LlmDiscoveredModel: 'core.md', ResolvedRetryPolicy: 'llm-streaming.md', Message: 'core.md', MessageSource: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1dd7973fd5..1c7e2c3a0c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'], - note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'], + note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', @@ -417,7 +417,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider and continuation service', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'], consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'], note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.', }, @@ -598,7 +598,8 @@ function parseExampleCordis(rel: string): ExamplePlugin[] { if (current?.name) plugins.push({ id: current.id, name: current.name }) } for (const line of text.split('\n')) { - const id = /^-\s+id:\s+(.+?)\s*$/.exec(line) + // Top-level rows (`- id:`) and bundle-patch insert rows (` - id:`). + const id = /^\s*-\s+id:\s+(.+?)\s*$/.exec(line) if (id?.[1] !== undefined) { flush() current = { id: stripYamlScalar(id[1]) } @@ -620,9 +621,9 @@ const APP_EXAMPLES = [ id: 'dsh_base', rel: 'apps/cli/composition.md', title: 'DSH Base Composition', - label: 'apps/cli/config/base.cordis.yml', - config: 'apps/cli/config/base.cordis.yml', - summary: 'The raw CLI applies one required caller-selected patch list over this shared base; Web and headless apply their own shipped overlays.', + label: 'packages/bundle/base/cordis.patch.yml', + config: 'packages/bundle/base/cordis.patch.yml', + summary: 'The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user\'s profile layer patch over it.', }, { id: 'headless', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index f31cca6879..d0c427c8f6 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -2,7 +2,20 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts' +import { + CLAUDE_AGENT_SDK_PACKAGE, + claudeDistributionFromManifest, + collectPythonDependencies, + isOwnerAuthorizedRuntime, + isPermissive, + type Manifest, + manifestPatterns, + parsePyprojectRequirements, + parseVendoredRows, + render, + tierExternalDeps, + virtualManifest, +} from './gen-third-party-notices.ts' const root = resolve(import.meta.dirname, '..') @@ -12,7 +25,9 @@ describe('THIRD_PARTY_NOTICES.md', () => { // Pre-commit regenerates the file whenever a manifest is staged, so reaching // this assertion means the notices were committed without that hook. it('matches what the generator produces from the current manifests', () => { - expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render()) + const generated = render() + expect(generated).toContain('It depends on the third-party software listed below.') + expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) }) }) @@ -223,7 +238,14 @@ describe('collectPythonDependencies', () => { describe('isPermissive', () => { it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => { expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true) - expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false) + expect([ + 'LGPL-3.0-only', + 'MPL-2.0', + 'GPL-3.0-or-later', + 'SEE LICENSE IN LICENSE', + 'SEE LICENSE IN README.md', + 'SEE LICENSE IN LICENSE.md', + ].some(isPermissive)).toBe(false) }) it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => { @@ -245,6 +267,66 @@ describe('isPermissive', () => { }) }) +describe('official Claude distribution authorization', () => { + it('authorizes only the direct SDK identity without relabeling its license', () => { + expect(isOwnerAuthorizedRuntime(CLAUDE_AGENT_SDK_PACKAGE)).toBe(true) + expect(isOwnerAuthorizedRuntime(`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`)) + .toBe(false) + expect(isOwnerAuthorizedRuntime('@anthropic-ai/unrelated')).toBe(false) + expect(isPermissive('SEE LICENSE IN README.md')).toBe(false) + }) + + it('derives version-independent platform payloads from the official SDK manifest', () => { + expect(claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '9.8.7', + license: 'future declared terms', + claudeCodeVersion: '6.5.4', + optionalDependencies: { + [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '9.8.7', + [`${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`]: '9.8.7', + }, + })).toEqual({ + sdkVersion: '9.8.7', + claudeCodeVersion: '6.5.4', + payloads: [ + { + name: `${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`, + version: '9.8.7', + }, + { + name: `${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`, + version: '9.8.7', + }, + ], + }) + }) + + it('rejects a wrong SDK identity, missing payloads, and unrelated optionals', () => { + expect(() => claudeDistributionFromManifest({ + name: '@anthropic-ai/unrelated', + version: '1.0.0', + claudeCodeVersion: '1.0.0', + optionalDependencies: { + [`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '1.0.0', + }, + })).toThrow(`expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest`) + expect(() => claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '1.0.0', + claudeCodeVersion: '1.0.0', + })).toThrow('declares no optional platform payloads') + expect(() => claudeDistributionFromManifest({ + name: CLAUDE_AGENT_SDK_PACKAGE, + version: '1.0.0', + claudeCodeVersion: '1.0.0', + optionalDependencies: { + '@anthropic-ai/unrelated': '1.0.0', + }, + })).toThrow('outside its authorized platform-payload identity') + }) +}) + describe('manifestPatterns', () => { it('derives globs from the declared members, so a new member area is read', () => { expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([ diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 0d41953e4b..ec88d32a61 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -49,6 +49,21 @@ const FIRST_PARTY = new Set([ 'node-addon-landlock-run-linux-x64', ]) +/** Official SDK identity covered by the project's narrow owner authorization. */ +export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' +const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-` +const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md' + +/** + * Whether a non-permissive runtime declaration has an identity-scoped owner + * authorization. This does not reclassify its terms as permissive. + * @param name - exact npm package identity. + * @returns true only for the official Claude Agent SDK package. + */ +export function isOwnerAuthorizedRuntime(name: string): boolean { + return name === CLAUDE_AGENT_SDK_PACKAGE +} + /** * Metadata overrides where the installed manifest is wrong or unreachable. * Each entry documents why the store cannot answer. @@ -92,6 +107,7 @@ const BUILD_TIME_TOOLS = [ /** The `package.json` fields this generator reads. */ export interface Manifest { name?: string + version?: string private?: boolean license?: string dependencies?: Record @@ -164,7 +180,74 @@ function loadWorkspaceManifests(): { manifests: Map; names: Se return { manifests, names } } -type VirtualManifest = Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string } +type VirtualManifest = Manifest & { + claudeCodeVersion?: string + license?: string + repository?: string | { url?: string } + homepage?: string +} + +/** One platform payload declared by the official Claude Agent SDK. */ +export interface ClaudePlatformPayload { + readonly name: string + readonly version: string +} + +/** Current SDK and CLI distribution facts derived from the installed SDK manifest. */ +export interface ClaudeDistribution { + readonly sdkVersion: string + readonly claudeCodeVersion: string + readonly payloads: ClaudePlatformPayload[] +} + +function requiredManifestString( + value: string | undefined, + field: string, +): string { + if (value === undefined || value.length === 0) { + throw new Error(`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} has no ${field}.`) + } + return value +} + +/** + * Derive the official platform payload set without a version or platform + * allowlist. Only identities in the SDK's own package namespace are covered. + * @param manifest - installed official SDK manifest. + * @returns current SDK, CLI, and optional platform payload facts. + */ +export function claudeDistributionFromManifest( + manifest: VirtualManifest, +): ClaudeDistribution { + if (manifest.name !== CLAUDE_AGENT_SDK_PACKAGE) { + throw new Error( + `gen-third-party-notices: expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`, + ) + } + const sdkVersion = requiredManifestString(manifest.version, 'version') + const claudeCodeVersion = requiredManifestString( + manifest.claudeCodeVersion, + 'claudeCodeVersion', + ) + const entries = Object.entries(manifest.optionalDependencies ?? {}) + if (entries.length === 0) { + throw new Error( + `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} declares no optional platform payloads.`, + ) + } + const payloads = entries.map(([name, version]) => { + if (!name.startsWith(CLAUDE_PLATFORM_PACKAGE_PREFIX)) { + throw new Error( + `gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} optional dependency ${name} is outside its authorized platform-payload identity.`, + ) + } + return { + name, + version: requiredManifestString(version, `${name} optional dependency version`), + } + }).sort((left, right) => left.name.localeCompare(right.name)) + return { sdkVersion, claudeCodeVersion, payloads } +} /** * Resolve one package's manifest inside a pnpm virtual store. The prefix scan @@ -193,9 +276,8 @@ export function virtualManifest(virtual: string, name: string): VirtualManifest return undefined } -/** License and repository URL for an installed external package, from the pnpm store. */ -function installedMetadata(name: string): { license: string; repo: string } { - const override = OVERRIDES[name] +/** Resolve one installed external package manifest from either pnpm store. */ +function installedManifest(name: string): VirtualManifest | undefined { let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined // The nested Landlock workspace installs into its own store, so a package // only that workspace depends on is unreachable from the root one. @@ -210,6 +292,13 @@ function installedMetadata(name: string): { license: string; repo: string } { manifest = virtualManifest(virtual, name) if (manifest !== undefined) break } + return manifest +} + +/** License and repository URL for an installed external package, from the pnpm store. */ +function installedMetadata(name: string): { license: string; repo: string } { + const override = OVERRIDES[name] + const manifest = installedManifest(name) const license = override?.license ?? manifest?.license const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage const repo = override?.repo ?? normalizeRepo(rawRepo) @@ -219,6 +308,37 @@ function installedMetadata(name: string): { license: string; repo: string } { return { license, repo } } +function collectClaudeDistribution(): ClaudeDistribution { + const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE) + if (manifest === undefined) { + throw new Error( + `gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`, + ) + } + const distribution = claudeDistributionFromManifest(manifest) + let installedPayloads = 0 + for (const payload of distribution.payloads) { + const installed = installedManifest(payload.name) + if (installed === undefined) continue + installedPayloads += 1 + if ( + installed.name !== payload.name + || installed.version !== payload.version + || installed.license !== CLAUDE_PLATFORM_DECLARED_LICENSE + ) { + throw new Error( + `gen-third-party-notices: installed ${payload.name} does not match its SDK-declared version and ${CLAUDE_PLATFORM_DECLARED_LICENSE} license field.`, + ) + } + } + if (installedPayloads === 0) { + throw new Error( + 'gen-third-party-notices: no SDK-declared Claude platform payload is installed; install optional dependencies before regenerating.', + ) + } + return distribution +} + /** Normalize a manifest repository/homepage value to a browsable https URL. */ function normalizeRepo(raw: string | undefined): string | undefined { if (raw === undefined || raw === '') return undefined @@ -519,6 +639,26 @@ function renderNpmTable(deps: ExternalDep[]): string { return lines.join('\n') } +function renderClaudeDistribution( + distribution: ClaudeDistribution | undefined, +): string { + if (distribution === undefined) return '' + const rows = distribution.payloads.map(payload => + `| [\`${payload.name}\`](https://www.npmjs.com/package/${payload.name}) | ${payload.version} | ${CLAUDE_PLATFORM_DECLARED_LICENSE} |`, + ) + return ` +## Official Claude Code platform payloads + +The project owner authorizes distribution of every version of the official \`${CLAUDE_AGENT_SDK_PACKAGE}\` package and the official Claude Code CLI/platform payloads that each version declares through \`optionalDependencies\`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. + +The installed SDK ${distribution.sdkVersion} declares the following optional platform packages. Each carries the official Claude Code ${distribution.claudeCodeVersion} executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. + +| Optional platform package | Version | Declared license | +| --- | --- | --- | +${rows.join('\n')} +` +} + /** * Render the complete notices document. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. @@ -531,11 +671,19 @@ export function render(): string { const vendored = collectVendored() const python = collectPython() const patched = collectPatched() + const claudeDistribution = runtimeDeps.some( + dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE, + ) + ? collectClaudeDistribution() + : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) // A copyleft license reaching a shipped surface is a distribution decision, // not a rendering detail; the notices cannot quietly absorb it. - const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license)) + const nonPermissiveRuntime = runtimeDeps.filter(dep => + !isPermissive(dep.license) + && !isOwnerAuthorizedRuntime(dep.name), + ) if (nonPermissiveRuntime.length > 0) { throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) } @@ -546,9 +694,9 @@ export function render(): string { # Third-Party Notices -DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. +DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. +This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml). @@ -569,6 +717,7 @@ ${renderNpmTable(runtimeDeps)} pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: ${patchedLines.join('\n')} +${renderClaudeDistribution(claudeDistribution)} ## Development-only npm dependencies diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index d19b42bc8e..f0eb64548e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -392,7 +392,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/scripts/install.sh b/scripts/install.sh index b70652451e..5c9d892f73 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -46,7 +46,7 @@ # DSH_MASTER master clone directory (default: $DSH_SOURCE/master) # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) -# DSH_HOME Harness home holding the personal config (default: ~/.dsh) +# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d7fb7e1b13..84eeeb10bb 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -189,6 +189,12 @@ describe('Node 24 lane ownership', () => { expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({ DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1', }) + expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual( + expect.arrayContaining([ + 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', + 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', + ]), + ) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 238c513433..f7669eac7e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -599,6 +599,8 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/host/directory-picker-native/tests/built-worker.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', + 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', + 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 0aa189024d..b9d67f4bf6 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Configured runtime\n\nRaw `dsh` requires a patch-list configuration applied over the shipped base:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nThe [CLI contract](apps/cli/README.md#raw-config) describes the base, overlay semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### 自定义运行时\n\n原始 `dsh` 要求传入一份 patch 列表配置,并将其叠加在随附 base 之上:\n\n```sh\ndsh --config ./app.cordis.yml\n```\n\nbase、overlay 语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#raw-config)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)契约](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 73bb8844d0..603ad20d8e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -61,6 +61,16 @@ "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmModelDiscoveryRequest", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmDiscoveredModel", + "source": "packages/llm/llm/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelContext", @@ -131,11 +141,6 @@ "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { - "doc": "docs/core-data-structures/core.md", - "symbol": "PreStepContext", - "source": "packages/core/agent/src/types.ts" - }, { "doc": "docs/core-data-structures/core.md", "symbol": "PreStepDecision", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index bdb020a6a0..4c4d83ead6 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -149,11 +149,33 @@ function validateExampleResolution(): string[] { } function validateAppResolution(): string[] { - const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} + const violations: string[] = [] + // App overlays (and any config left under apps/cli/config) resolve from the + // dsh app's own dependency surface — the profile module fallback mirrors it. + const appDependencies = { + ...readManifest('apps/cli/package.json').dependencies, + // The fallback also links every bundle's own dependencies (healProfilesModuleFallback). + ...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root }) + .flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))), + } const shipped = new Set(globSync('*.cordis.yml', { cwd: resolve(root, 'apps/cli/config') }) .map(file => `apps/cli/config/${file}`)) - const references = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file)) - return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') + const appReferences = pluginReferences.filter(reference => shipped.has(reference.file) || appOverlayFiles.has(reference.file)) + violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest')) + // Each bundle's patch rows must resolve from that bundle's own dependencies: + // per-layer resolution anchors on the bundle package directory. + for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) { + const bundleDir = manifestPath.replace(/\/package\.json$/, '') + const manifest = readManifest(manifestPath) + const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`)) + violations.push(...missingPluginDependencies( + // A bundle may mount its own package (the web-app runtime row). + references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), + manifest.dependencies ?? {}, + manifestPath, + )) + } + return violations } /** diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..316a4233de 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -86,6 +86,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, + 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' }, + 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' }, + 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..cba3a9972d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -93,6 +93,7 @@ "./packages/spill/*/src/invariant.ts", "./packages/timeout/*/src/invariant.ts", "./packages/todo/*/src/invariant.ts", + "./packages/bundle/*/src/invariant.ts", "./packages/cordis/*/src/invariant.ts", "./packages/sandbox/*/src/invariant.ts", "./packages/hooks/*/src/invariant.ts", @@ -191,6 +192,7 @@ "./packages/spill/*/src", "./packages/timeout/*/src", "./packages/todo/*/src", + "./packages/bundle/*/src", "./packages/cordis/*/src", "./packages/sandbox/*/src", "./packages/hooks/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 4fcf71b680..9e63314bc5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -188,6 +188,9 @@ { "path": "./packages/support/agent-loop-testkit" }, { "path": "./packages/acp/acp" }, { "path": "./packages/examples/acp-demo" }, + { "path": "./packages/bundle/base" }, + { "path": "./packages/bundle/headless" }, + { "path": "./packages/bundle/web-app" }, { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, @@ -204,6 +207,8 @@ { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, + { "path": "./packages/subagent/subagent-claude-code" }, + { "path": "./packages/subagent/subagent-codex" }, { "path": "./packages/subagent/subagent-dsh-sdk" }, { "path": "./packages/tasks/tasks" }, { "path": "./packages/tasks/tasks-local" }, @@ -231,6 +236,7 @@ // client aggregate's webserver reference. { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, + { "path": "./packages/host/frontend-static" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, diff --git a/vendor/README.md b/vendor/README.md index c59a86ccca..9fa97413c2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,8 +41,8 @@ Keep this log exhaustive — every divergence from upstream must be listed. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. -13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the raw invalid-provider built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. ## Sync procedure diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 2484d0152a..00864cd865 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -215,7 +215,7 @@ class Hmr extends Service { // the scan-triggered refresh waits on that apply — a teardown deadlock // that strands boot without a diagnostic. Only events after the scan // matter here; `registerConfig` keeps its own initial scan because a - // personal config present at registration must apply once. + // user patch layer present at registration must apply once. ignoreInitial: true, }) diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 4a9fd6be86..26b9305c52 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -85,10 +85,10 @@ export function applyEntryPatches( data.push(...insert) } // Index what this patch added so a LATER patch in the same list can - // target it. Patch lists compose one layer per source (surface overlay, - // then `--config`, then the user's), and a layer must be able to - // configure or disable a row an earlier layer inserted; without this, - // inserted rows were silently unpatchable. + // target it. Patch lists compose one layer per source (each bundle + // layer, then the user's, then `--patch` overlays), and a layer must be + // able to configure or disable a row an earlier layer inserted; without + // this, inserted rows were silently unpatchable. buildMap(insert) continue } diff --git a/website/docs.ts b/website/docs.ts index 8d42ae3209..1a9b20b5be 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -166,6 +166,14 @@ const develop = pairedPages([ section: { root: '基础', en: 'Basics' }, order: 3, }, + { + source: 'docs/user/develop/basic/publish.md', + route: 'develop/basic/publish.md', + label: { root: '打包与安装插件', en: 'Package and install' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, + order: 4, + }, { source: 'docs/user/develop/framework/index.md', route: 'develop/framework/index.md',