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-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 20c1d99991..5376a626e6 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: b306f3b155d9d9208066c3f25ad2c4fb4683b1ee -2026-07-19-gui-web-client-architecture.zh.md: 28632667c45b360eb2bc5f0d06f10b9df910770d +2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570 +2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index b306f3b155..1a91d88818 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 28632667c4..5c0bacde98 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝(conversation 服务在场即保证槽已声明)。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`([决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 40d63fcff3..46ec2eb556 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.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-22-slot-type-chain-implementation.md -2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6 -2026-07-22-slot-type-chain-implementation.zh.md: 8ca6781e42764fc8d7f7de0f9f25ca6c110d4be0 +2026-07-22-slot-type-chain-implementation.md: 2f0ec32766100e492c68c474f8798be3df0a3d15 +2026-07-22-slot-type-chain-implementation.zh.md: 75e89d3f57b96a1699981123e8361c775db2b8a7 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index e88361701f..2f0ec32766 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -36,6 +36,8 @@ There is no separate slot-definition API. The `children` object both **declares Parity rule: **the declaring entry holds the exclusive right to render its child slots**, settled entirely at register time (misconfiguration fails loud at load; the render hot path carries no checks). Loud-at-load cases: a second entry declaring an already-declared slot; registering into an undeclared slot; one store handle mounted under two scopes; a chain registration missing its `select`. +A contributor whose activation order is independent from the declaring entry uses `ctx.slots.inject(key, callback)` and keeps direct `register()` fail-loud. The declaration, contributor, replacement, and failure lifetimes are specified by the [slot declaration injection decision](2026-08-05-slot-declaration-injection.md). + `SlotMap` declaration merging remains the type authority, and an entry declares only its own axes plus the **owner share** — the registrant's injected props never enter the global table ("whoever injects it, owns its type"). ### Component props: four shares, each from its own source of truth diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 8ca6781e42..75e89d3f57 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -36,6 +36,8 @@ ctx.slots.register({ 对等原则:**声明子 slot 的 entry 独占渲染这些子 slot 的权力**,全部在 register 时确定(配置错误会在装载时明确失败;渲染热路径不再校验)。装载即炸的情形:第二个 entry 声明已被声明的 slot;向未声明的 slot register;同一个 store 句柄挂到两个 scope 之下;chain 注册缺 `select`。 +激活顺序独立于声明条目的贡献方使用 `ctx.slots.inject(key, callback)`,并让直接调用 `register()` 继续大声失败。声明、贡献方、替换与失败各自的生命周期由 [slot 声明注入决策](2026-08-05-slot-declaration-injection.md) 规定。 + `SlotMap` 声明合并仍是类型权威,且 entry 只声明自己的轴加 **owner 份额**——注册方注入的 props 永不进入全局表(「谁注入的,类型归谁」)。 ### 组件 props:四份额,各有唯一真源 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index add7df1e61..90d13fcfea 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.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-23-toolview-dissolution.md -2026-07-23-toolview-dissolution.md: 406e5c181aabb635f9d6dcb12d8a9b8b6697368e -2026-07-23-toolview-dissolution.zh.md: f42881c5f2e4c661d7fa40bfca7d0b53c1beef5e +2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32 +2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index 406e5c181a..97d8beb4de 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. +Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. @@ -34,4 +34,4 @@ Four behavioral deltas were accepted deliberately, not overlooked. Cross-view ap ## Consequences -The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override), plus one subtlety the load-order seam carries: registrant plugins must declare `inject: ['conversation']` to sequence after the slot declaration, a convention the seam makes correct by construction but does not statically force on third parties. +The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index f42881c5f2..db93c6252d 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -14,7 +14,7 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `..` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 +落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md))。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 @@ -34,4 +34,4 @@ registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘 ## Consequences -client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖),外加加载序缝携带的一处微妙:注册方插件须声明 `inject: ['conversation']` 才排在槽声明之后,这条约定由序缝构造保证正确、但不对第三方静态强制。 +client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。 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-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-slot-declaration-injection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.i18n.yaml new file mode 100644 index 0000000000..43c5433343 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.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-slot-declaration-injection.md +2026-08-05-slot-declaration-injection.md: cb15125977c060144553d7cf75e3c2c26fb1b23b +2026-08-05-slot-declaration-injection.zh.md: 385cab875bb445ba1ca324fc9b45363b8daf50b6 diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md new file mode 100644 index 0000000000..cb15125977 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md @@ -0,0 +1,45 @@ +# Agent Note: Slot declaration injection and reload lifetimes + +Status: implemented + +English | [中文](2026-08-05-slot-declaration-injection.zh.md) + +## Problem + +Client plugins may contribute to a slot before or after the plugin that declares it. Cordis service injection cannot express this dependency: a service is only an indirect ordering signal, client manifest dependency rows do not sequence activation, and a slot can disappear and return while every related service remains mounted. Registering immediately therefore races an undeclared slot, while waiting on an unrelated service couples independently reloadable features. + +Slot-level hot replacement also requires two independent owners. Removing the declaring plugin must remove every contribution under its child slots; removing a contributing plugin must remove only that plugin's entries. A replacement declaration with the same key is a new lifetime even when disappearance and reappearance batch into one notification. + +## Decision + +`SlotsService.inject(name, callback)` makes the declared slot itself the dependency. The full `SlotMap` key is statically checked; there is no namespace builder, synthetic Cordis service, or slot-specific `Context`. The callback runs immediately when the declaration exists, otherwise waits, and returns either one synchronous disposer or a synchronous iterable of disposers. Iterable effects install transactionally: a later setup failure disposes every earlier yielded effect in reverse order. + +The ledger records a declaration epoch distinct from the slot's ordinary entry version. An epoch changes whenever a child declaration is created or collapsed. Injection remembers the active epoch, disposes its callback effect when that epoch ends, and reruns the callback for a replacement declaration even when the final observed state is continuously declared. Ordinary contribution changes do not restart injection. + +Both sides retain their natural ownership. The injection controller and every contribution run on the contributing plugin's caller `Context`, so disposing that plugin removes its wait and active entries. The slot ledger's existing child-collapse cascade removes entries when the declarer disappears; injection then runs their disposers to release service-layer resources and remains ready for a later declaration. The declaring plugin's `Context` is neither retained as a capability source nor exposed to contributors. + +Dynamic reload code uses an ordinary Cordis plugin fiber as its replacement unit: activate the new module through `ctx.plugin()`, dispose and await the old fiber before mounting its replacement, and let its `slots.inject` and `slots.register` effects leave with that fiber. Renderer subscriptions observe the ledger removal and unmount the component; no slot-owned fiber tree is required. + +## Failure and lifecycle contract + +An injection whose declaration already exists reports callback setup failures synchronously. A callback failure after a delayed declaration first unsubscribes and rolls back its collected effects, then reports the failure outside the slot notification flush so one registrant cannot starve other listeners. Direct `slots.register()` into an undeclared slot continues to throw: injection is explicit and does not weaken load-time validation. + +Disposing an injection is idempotent. It unsubscribes before releasing the active callback effect, preventing teardown-triggered ledger notifications from resurrecting the contribution. Declaration-bound teardown is synchronous with the ledger boundary, so it releases service-layer resources before any subsequent same-tick registration. A waiting injection disposed with its plugin cannot activate later. + +## Alternatives considered + +**Use `ConversationService` or another service as an ordering barrier.** Service presence does not identify the declaration or follow its reload lifetime, and it creates a false package dependency for presentation-only contributors. + +**Bridge each declaration into a `slot:` Cordis service.** This pollutes the service namespace, turns a misspelled dynamic key into a silent service wait, and disguises ledger state as a business capability. Native slot injection provides the same wait without changing Cordis topology. + +**Create a Cordis context or fiber for every slot.** A contributor needs the intersection of its own plugin lifetime and the declaration lifetime, not the declarer's capabilities. A slot-owned context introduces capability inheritance and dual-parent teardown problems without improving ledger ownership. + +**Make `register()` wait implicitly.** Immediate failure on an undeclared target is a valuable configuration check. Explicit injection distinguishes an intentional independently ordered contribution from a broken composition. + +**Judge replacement from `spec(name) !== undefined` alone.** Collapse and redeclaration can batch into one continuously present final state while the old contributions have already been removed. The declaration epoch preserves that boundary. + +## Consequences + +Slot dependencies become auditable at the registration site and follow declaration replacement without package-specific ordering conventions. Dynamic plugin disposal removes rendered entries through existing Cordis effects, while declaration replacement has a stable hook for later slot-level HMR. + +The runtime carries one additional monotonic epoch per touched slot and injection callbacks must return their cleanup. Multi-registration callbacks use iterable effects so setup and teardown remain atomic. The flat dotted-key ledger and the single `register()` composition authority remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md new file mode 100644 index 0000000000..385cab875b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.zh.md @@ -0,0 +1,45 @@ +# Agent Note(agent 决策记录):slot 声明注入与重载生命周期 + +Status: implemented + +[English](2026-08-05-slot-declaration-injection.md) | 中文 + +## 问题 + +客户端插件可能在声明某个 slot 的插件之前或之后向该 slot 贡献内容。Cordis 服务注入无法表达这种依赖:服务只能作为间接的顺序信号;客户端 manifest(元数据清单)的依赖项不会规定激活顺序;即使所有相关服务始终挂载,slot 仍可能消失后重新出现。因此,立即注册会与尚未声明的 slot 形成竞态,而等待无关服务则会耦合本可独立重载的功能。 + +slot 级热替换还要求两个相互独立的所有者。移除声明方插件必须移除其子 slot 下的所有贡献;移除贡献方插件只能移除该插件自己的条目。即使消失与重新出现合并在同一次通知中,同一个 key 的替换声明也属于新的生命周期。 + +## 决策 + +`SlotsService.inject(name, callback)` 以已声明的 slot 本身作为依赖。完整的 `SlotMap` key 会经过静态检查;系统不引入命名空间构建器、合成的 Cordis 服务或 slot 专属 `Context`。声明存在时回调同步执行,否则等待;回调返回一个同步 disposer,或由多个 disposer 构成的同步 iterable。iterable effect 的安装具有事务性:后续 setup 失败时,系统会按逆序 dispose(资源释放)之前 yield 的所有 effect。 + +该账本记录独立于 slot 普通条目版本的 declaration epoch(声明代次)。每当子声明创建或折叠时,epoch 都会变化。注入会记住活跃 epoch;该 epoch 结束时,注入会 dispose 其回调 effect;即使最终观测到的状态始终为已声明,也会为替换声明重新执行回调。普通贡献变更不会重启注入。 + +声明方与贡献方各自保留其自然所有权。注入控制器和每项贡献都运行在贡献方插件调用时的 `Context` 上,因此 dispose 该插件会同时移除其等待与活跃条目。slot 账本现有的子项折叠级联会在声明方消失时移除条目;随后,注入会运行其 disposer 以释放服务层资源,并继续等待后续声明。系统既不会将声明方插件的 `Context` 保留为 capability 来源,也不会向贡献方公开它。 + +动态重载代码使用普通 Cordis 插件 fiber 作为替换单元:通过 `ctx.plugin()` 激活新模块;挂载替换模块之前,先 dispose 并等待旧 fiber;该 fiber 的 `slots.inject` 与 `slots.register` effect 会随之退出。renderer 订阅会观察到账本移除并卸载组件;无需建立 slot 自有的 fiber 树。 + +## 失败与生命周期契约 + +如果注入创建时声明已经存在,回调 setup 失败会同步上报。延迟声明出现后发生的回调失败,会先取消订阅并回滚已收集的 effect,再在 slot 通知刷新之外上报,避免一个注册方使其他 listener 得不到执行机会。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常:注入是显式机制,不会削弱加载时验证。 + +对注入执行 dispose 具有幂等性。它会先取消订阅,再释放活跃的回调 effect,避免拆卸触发的账本通知复活该项贡献。声明绑定的 teardown 与账本边界同步,因此会在同一 tick 的任何后续注册之前释放服务层资源。随插件一同 dispose 的待命注入无法在之后激活。 + +## 备选方案 + +**将 `ConversationService` 或其他服务用作顺序屏障。** 服务存在并不能标识相应声明,也不会跟随声明的重载生命周期;只负责呈现的贡献方还会因此产生虚假的包(package)依赖。 + +**将每项声明桥接为 `slot:` Cordis 服务。** 这会污染服务命名空间,使拼错的动态 key 变成静默的服务等待,并把账本状态伪装成业务 capability。原生 slot 注入无需改变 Cordis 拓扑,即可提供同样的等待能力。 + +**为每个 slot 创建 Cordis 上下文或 fiber。** 贡献方需要的是自身插件生命周期与声明生命周期的交集,而不是声明方的 capability。slot 所有的上下文会引入 capability 继承和双父级拆卸问题,却无法改善账本所有权。 + +**让 `register()` 隐式等待。** 对未声明目标立即失败是一项有价值的配置检查。显式注入能够区分有意独立排序的贡献与错误组合。 + +**只根据 `spec(name) !== undefined` 判断替换。** 折叠与重新声明可以合并成一个最终状态始终存在的通知,而旧贡献此时已经被移除。declaration epoch 保留了这条生命周期边界。 + +## 影响 + +slot 依赖可以在注册点审计,并且无需特定于包的顺序约定即可跟随声明替换。动态插件 dispose 会通过既有 Cordis effect 移除已渲染条目,而声明替换则为后续 slot 级 HMR(热模块替换)提供稳定钩子。 + +运行时为每个被访问的 slot 多维护一个单调 epoch,且注入回调必须返回清理操作。多注册回调使用 iterable effect,使 setup 与 teardown 保持原子性。扁平的点分 key 账本和唯一的 `register()` 组合权威保持不变。 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-markdown-incremental-ast-renderer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml new file mode 100644 index 0000000000..20d21565b5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.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-markdown-incremental-ast-renderer.md +2026-08-06-web-markdown-incremental-ast-renderer.md: 3599bfcc78dc4eefe5e82f461a469bdba15f3aae +2026-08-06-web-markdown-incremental-ast-renderer.zh.md: 2e00977da58ef29a77c45abcecf0f3bb62737929 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md new file mode 100644 index 0000000000..3599bfcc78 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md @@ -0,0 +1,33 @@ +# Agent Note: Incremental streaming markdown through a direct mdast renderer + +Status: implemented + +English | [中文](2026-08-06-web-markdown-incremental-ast-renderer.zh.md) + +## Problem + +`MarkdownText` re-parsed the whole accumulated reply on every streaming publish: react-markdown's string-only API builds a fresh unified processor per render and runs micromark → mdast → hast → React over the full text, so per-chunk main-thread work grew linearly with the reply and the stream's cumulative cost grew quadratically. The existing mitigations (frame batching, the isolated streaming tail, the plain fence arm) bounded how often and how widely that work ran, never how much text each run re-parsed. Fixing it needs AST-level input — freezing settled blocks and re-parsing only the source tail — which the string-only wrapper structurally cannot express. + +## Decision + +`MarkdownText` renders mdast directly and parses incrementally while streaming: + +- **Grammars** ([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)): `parseGfm` (streaming arm and `extractMarkdownPlainText`) and `parseGfmWithMath` (settled arm) call `mdast-util-from-markdown` with the same micromark extensions the replaced remark plugins wrapped, so block boundaries are identical everywhere. `mathCompatibility` (ex `remarkMathCompatibility`) now exports its micromark extension directly. +- **Incremental parsing** ([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)): CommonMark block parsing is line-based, so appended text reshapes only the parse frontier. `IncrementalMarkdownParser` keeps the trailing two blocks unstable (the last block is the frontier; the second-to-last is safety margin), freezes everything before them, and re-parses only the source tail from the last frozen block's `position.end.offset` — the parser's own offsets, no bespoke source scanning. Each source region parses O(1) times per stream instead of once per chunk; a single giant block (an unclosed fence) degrades to the old full-reparse cost and no worse. Non-append input resets the state under a bumped generation. +- **Rendering** ([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx), [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)): one switch over mdast node types replaces remark-rehype + react-markdown, reproducing the replaced pipeline's DOM byte-for-byte — table alignment as `text-align` styles, tight-list paragraph unwrapping, task-list classes and checkbox spacing, the footnote section (whose in-page anchors the protocol allowlist already reduced to plain text), literal raw HTML, the separator newlines that surface next to literal HTML text, and rehype-katex's three-arm error chain with KaTeX HTML mapped to React through the browser's own `DOMParser` (no wrapper element, so first/last-child margin rules still reach `.katex-display`; React 18 puts the `.katex-mathml` subtree in the HTML namespace exactly as the replaced pipeline did — a pre-existing limitation outside this parity contract, invisible to the visual `.katex-html` arm). Frozen blocks cache their React elements and keep source-offset keys, so crossing the freeze boundary reconciles instead of remounting; `MarkdownText` is memoized. + +The DOM is pinned by `tests/fixtures/markdown-dom`: fixtures recorded from the react-markdown implementation before the swap, which the new renderer must reproduce under a whitespace-normalizing serializer. A fixture diff is a user-visible markdown style change to review, never to re-record for a refactor. `tests/markdown-incremental.spec.tsx` holds the equivalence property — at every appended prefix, chunked at 1/3/7/16 bytes, the live component's DOM equals a fresh mount's — plus freeze-boundary DOM-node identity and reset behavior. + +This reverses the [assistant-markdown note](../feature/2026-07-23-web-assistant-markdown.md)'s rejected alternative ("maintain a custom React walker"): the incremental requirement is new evidence, the walker's security-sensitive branches (URL allowlist, image policy, inert HTML) were already product-owned functions, and the dependency no longer deleted owned code — it blocked the architecture. That note's untrusted-output policy and renderer selection are unchanged. + +## Alternatives considered + +**Keep react-markdown and split the source into per-segment `` instances.** Zero renderer ownership, but each frame parses the tail twice (boundary detection + render), settled math still re-parses everything, hast construction and the per-render processor remain, and blocks remount when crossing the freeze boundary because element trees cannot be cached across instances. + +**Render cached mdast through `mdast-util-to-hast` + `hast-util-to-jsx-runtime`.** Keeps upstream's node mappings for free, but retains the hast intermediate per frame and two new direct dependencies for a pipeline whose mapping surface is small, closed, and now pinned by fixtures. + +**Parse KaTeX output with `hast-util-from-html-isomorphic` (as rehype-katex does).** Pulls a parse5-based HTML parser into the bundle to parse trusted, vocabulary-constrained KaTeX output the browser's `DOMParser` (with the spec's SVG/MathML attribute adjustments) already parses identically. + +## Consequences + +Streaming per-chunk work now tracks the unstable tail instead of the whole reply, and react-markdown, remark-gfm, remark-math, rehype-katex, unified, and the hast chain left the browser bundle (`mdast-util-math` and `micromark-util-sanitize-uri` became direct dependencies; both were already transitive). The package owns ~25 node mappings, their tests, and the KaTeX DOM conversion — priced against the fixture contract that freezes their output. Two behavioral deviations, both healed by the settled full parse at finalize: a reference-style link or footnote whose definition lands on the other side of a freeze boundary renders literally while streaming, and a footnote reference can flash back to literal text when its definition freezes while the referencing block is still unstable. This module and KaTeX conversion assume a browser DOM (`DOMParser`), which the client-only package already did. diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md new file mode 100644 index 0000000000..2e00977da5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 经由直接 mdast 渲染器的增量流式 Markdown + +Status: implemented + +[English](2026-08-06-web-markdown-incremental-ast-renderer.md) | 中文 + +## Problem + +`MarkdownText` 在每次流式发布时都重新解析整个已累积的回复:react-markdown 的纯字符串 API 每次渲染都新建 unified processor,并对全文跑完 micromark → mdast → hast → React,因此每个 chunk 的主线程工作量随回复长度线性增长,整个流的累计成本随之二次增长。既有缓解手段(帧级合并、隔离的流式尾部、围栏 plain 臂)约束的是这份工作跑多频繁、波及多广,从未约束每次重新解析多少文本。修复它需要 AST 级输入——冻结已定型的块、只重新解析源文本尾部——这是纯字符串封装在结构上无法表达的。 + +## Decision + +`MarkdownText` 直接渲染 mdast,并在流式期间增量解析: + +- **语法**([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)):`parseGfm`(流式臂与 `extractMarkdownPlainText`)和 `parseGfmWithMath`(定稿臂)以被替换的 remark 插件所包装的同一组 micromark 扩展调用 `mdast-util-from-markdown`,因此各处块边界完全一致。`mathCompatibility`(原 `remarkMathCompatibility`)现在直接导出其 micromark 扩展。 +- **增量解析**([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)):CommonMark 块解析按行推进,追加文本只会重塑解析前沿。`IncrementalMarkdownParser` 保留末尾两个块不稳定(最后一块是前沿;倒数第二块是安全裕量),冻结其前的所有块,只从最后一个冻结块的 `position.end.offset` 起重新解析源尾部——用的是解析器自己的偏移量,没有任何自制源扫描。每个源区间在整个流中解析 O(1) 次而非每 chunk 一次;单个巨型块(未闭合围栏)退化为旧的全量重解析成本,不会更差。非追加输入在递增的 generation 下重置状态。 +- **渲染**([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx)、[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)):一个对 mdast 节点类型的 switch 取代 remark-rehype + react-markdown,逐字节复刻被替换管线的 DOM——表格对齐渲染为 `text-align` 样式、紧凑列表段落解包、任务列表类名与复选框空格、脚注区(其页内锚点本就被协议白名单降为纯文本)、字面 raw HTML、会与字面 HTML 文本相邻显形的分隔换行,以及 rehype-katex 的三臂容错链,KaTeX HTML 经浏览器自带的 `DOMParser` 映射为 React(无包裹元素,首/末子元素的 margin 规则仍能作用于 `.katex-display`;React 18 会把 `.katex-mathml` 子树放进 HTML 命名空间,与被替换管线完全一致——既有限制,不在本对等性契约范围内,对承担视觉渲染的 `.katex-html` 臂不可见)。冻结块缓存其 React 元素并保持源偏移 key,跨过冻结边界时走 reconcile 而非重挂载;`MarkdownText` 已 memo 化。 + +DOM 由 `tests/fixtures/markdown-dom` 钉死:fixture 录制自替换前的 react-markdown 实现,新渲染器必须在空白规整序列化器下复现。fixture 差异即用户可见的 markdown 样式变更,必须按此评审,绝不能为重构而重录。`tests/markdown-incremental.spec.tsx` 承载等价性性质——以 1/3/7/16 字节分块,在每个追加前缀处,常驻组件的 DOM 都等于全新挂载——外加冻结边界的 DOM 节点同一性与重置行为。 + +这推翻了[助手 Markdown Note](../feature/2026-07-23-web-assistant-markdown.md) 中被否决的备选("维护一个自定义 React walker"):增量需求是当时不存在的新证据,walker 的安全敏感分支(URL 白名单、图片策略、惰性 HTML)本就是产品自有函数,而该依赖不再删减自有代码——它阻塞了架构。该 Note 的不可信输出策略与渲染器选型不变。 + +## Alternatives considered + +**保留 react-markdown,把源文本切成逐段 `` 实例。** 渲染器零自有成本,但每帧对尾部解析两次(边界检测 + 渲染),定稿数学仍要全量重解析,hast 构建与逐渲染 processor 依旧存在,且块跨过冻结边界时会重挂载——元素树无法跨实例缓存。 + +**用 `mdast-util-to-hast` + `hast-util-to-jsx-runtime` 渲染缓存的 mdast。** 白拿上游节点映射,但每帧保留 hast 中间层,并为一个映射面小、封闭、且已被 fixture 钉死的管线引入两个新直接依赖。 + +**用 `hast-util-from-html-isomorphic` 解析 KaTeX 输出(rehype-katex 的做法)。** 为解析可信、词汇受限的 KaTeX 输出把基于 parse5 的 HTML 解析器拉进 bundle,而浏览器自带的 `DOMParser`(带规范的 SVG/MathML 属性调整)解析结果完全相同。 + +## Consequences + +流式的每 chunk 工作量现在跟随不稳定尾部而非整个回复,react-markdown、remark-gfm、remark-math、rehype-katex、unified 及 hast 链退出浏览器 bundle(`mdast-util-math` 与 `micromark-util-sanitize-uri` 成为直接依赖;两者原本就是传递依赖)。包自有约 25 个节点映射、其测试以及 KaTeX DOM 转换——代价由冻结其输出的 fixture 契约对冲。两个行为偏差,均在定稿的全量解析处自愈:定义落在冻结边界另一侧的引用式链接或脚注在流式期间渲染为字面文本;当脚注定义先冻结而引用块仍不稳定时,脚注引用可能闪回字面文本。本模块与 KaTeX 转换假定浏览器 DOM(`DOMParser`),这个 client-only 包本就如此。 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 `\n" +

+ #text "Paragraph with inline html and bold tag kept literal?" + #text "\n

\nhtml block content\n
\n" +

+ #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt new file mode 100644 index 0000000000..8d8344f72b --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt @@ -0,0 +1,7 @@ +

+ #text "\n" +

+ #text "Paragraph with inline html and bold tag kept literal?" + #text "\n

\nhtml block content\n
\n" +

+ #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt new file mode 100644 index 0000000000..5cc4257ab7 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt @@ -0,0 +1,16 @@ +

+

+ #text "A " + + #text "full" + #text " reference, a " + + #text "collapsed" + #text " one, and a " + + #text "shortcut" + #text " one." +

+ #text "[missing full][nope], [missing collapsed][], ![missing image][gone]." +

+ ref image diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt new file mode 100644 index 0000000000..5cc4257ab7 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt @@ -0,0 +1,16 @@ +

+

+ #text "A " + + #text "full" + #text " reference, a " + + #text "collapsed" + #text " one, and a " + + #text "shortcut" + #text " one." +

+ #text "[missing full][nope], [missing collapsed][], ![missing image][gone]." +

+ ref image diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt new file mode 100644 index 0000000000..95a943cb4a --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt @@ -0,0 +1,8 @@ +

+

+ #text "Streaming" +
    +
  • + #text "first" +
  • + #text "**unfinished" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt new file mode 100644 index 0000000000..95a943cb4a --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt @@ -0,0 +1,8 @@ +
    +

    + #text "Streaming" +
      +
    • + #text "first" +
    • + #text "**unfinished" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt new file mode 100644 index 0000000000..2c2d9d7e0f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt @@ -0,0 +1,11 @@ +
      +
      + + + +
      + #text "a" + + #text "b" +

      + #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt new file mode 100644 index 0000000000..2c2d9d7e0f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt @@ -0,0 +1,11 @@ +

      +
      + + + +
      + #text "a" + + #text "b" +

      + #text "after" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt new file mode 100644 index 0000000000..6a669ffe2f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt @@ -0,0 +1,35 @@ +

      +
      + + + + + + +
      + #text "Left" + + #text "Center" + + #text "Right" + + #text "None" +
      + #text "a" + + #text "b" + + #text "c" + + + #text "code" +
      + + #text "link" + + + #text "em" + + #text "1" + + #text "2" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt new file mode 100644 index 0000000000..6a669ffe2f --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt @@ -0,0 +1,35 @@ +
      +
      + + + + + + +
      + #text "Left" + + #text "Center" + + #text "Right" + + #text "None" +
      + #text "a" + + #text "b" + + #text "c" + + + #text "code" +
      + + #text "link" + + + #text "em" + + #text "1" + + #text "2" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt new file mode 100644 index 0000000000..b467974cb4 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt @@ -0,0 +1,19 @@ +
      +
        +
      • + + #text " done with " + + #text "strong" +
      • + + #text " pending" +
      • + #text "plain sibling" +
          +
        1. + + #text " ordered done" +
        2. + + #text " ordered pending" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt new file mode 100644 index 0000000000..b467974cb4 --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt @@ -0,0 +1,19 @@ +
          +
            +
          • + + #text " done with " + + #text "strong" +
          • + + #text " pending" +
          • + #text "plain sibling" +
              +
            1. + + #text " ordered done" +
            2. + + #text " ordered pending" diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx new file mode 100644 index 0000000000..c1b4eb2f2f --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx @@ -0,0 +1,265 @@ +// @vitest-environment jsdom +// DOM-parity contract for MarkdownText: every corpus document's rendered DOM +// is pinned as a file snapshot. The fixtures were recorded from the +// react-markdown implementation this renderer replaced; the custom mdast +// renderer must reproduce them byte-for-byte (after whitespace +// normalization), so a fixture diff means a user-visible markdown style +// change and must be reviewed as such — never re-record to silence a +// refactor. +// +// Provenance is reproducible: the replaced pipeline last lived at commit +// 9e8101b800 (origin/master before the renderer swap merged). Checking out +// that ref in a worktree, copying this spec, and running it records all +// fixtures from react-markdown byte-identical to the ones committed here: +// git worktree add /tmp/parity origin/master --detach && cd /tmp/parity +// pnpm install && cp packages/client/ui-primitives/tests/ +// npx vitest run packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx +// diff -r # byte-identical +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +/** + * Serialize rendered DOM deterministically: adjacent text nodes coalesced + * (React renders adjacent string children as separate DOM text nodes while + * hast merges them — invisible either way), whitespace-only runs dropped + * outside `pre` (the markdown pipeline injects cosmetic newlines between + * blocks that HTML rendering collapses), attributes sorted by name, children + * indented for reviewable diffs. + */ +function serialize(node: Node, indent: string, inPre: boolean): string { + if (node.nodeType !== Node.ELEMENT_NODE) return '' + const element = node as Element + const attrs = [...element.attributes] + .map(attr => `${attr.name}=${JSON.stringify(attr.value)}`) + .sort() + .join(' ') + const open = attrs === '' ? element.tagName.toLowerCase() : `${element.tagName.toLowerCase()} ${attrs}` + const nowInPre = inPre || element.tagName === 'PRE' + return `${indent}<${open}>\n${serializeChildren(element, `${indent} `, nowInPre)}` +} + +function serializeChildren(element: Element, indent: string, inPre: boolean): string { + let out = '' + let textRun = '' + const flush = (): void => { + if (textRun !== '' && (inPre || textRun.trim() !== '')) { + out += `${indent}#text ${JSON.stringify(textRun)}\n` + } + textRun = '' + } + for (const child of element.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + textRun += child.textContent ?? '' + continue + } + flush() + out += serialize(child, indent, inPre) + } + flush() + return out +} + +/** Render one markdown source through MarkdownText and serialize the DOM. */ +function renderCase(text: string, streaming: boolean): string { + const { container, unmount } = render() + const out = [...container.childNodes].map(child => serialize(child, '', false)).join('') + unmount() + return out +} + +const CORPUS: Record = { + 'headings-and-paragraphs': [ + '# H1 with `code`', + '', + '## H2', + '', + '### H3', + '', + '#### H4', + '', + '##### H5', + '', + '###### H6', + '', + 'Paragraph one with **strong**, *emphasis*, ~~strike~~, and `inline`.', + '', + 'Setext title', + '=========', + '', + 'Second setext', + '---------', + ].join('\n'), + 'heading-tight-against-list': '#### Small heading\n\n- one\n- two\n\n##### Next\n\n1. a\n2. b', + 'hard-breaks-and-hr': 'two-space break \nafter break\n\nbackslash break\\\nafter backslash\n\n---\n\ntail', + 'blockquote-nested': '> level one\n> still one\n>\n> > nested\n>\n> - quoted list\n\nafter', + 'lists-tight-loose-nested': [ + '- tight one', + '- tight two', + ' - child', + '', + '1. first', + '2. second', + '', + '3. ordered with start', + '4. next', + '', + '- loose item one', + '', + '- loose item two', + '', + ' second paragraph of loose item', + '', + '- item with nested blocks', + '', + ' ```', + ' fenced inside list', + ' ```', + ].join('\n'), + 'task-lists': '- [x] done with **strong**\n- [ ] pending\n- plain sibling\n\n1. [x] ordered done\n2. [ ] ordered pending', + 'table-with-alignment': [ + '| Left | Center | Right | None |', + '| :--- | :---: | ---: | --- |', + '| a | b | c | `code` |', + '| [link](https://example.com) | *em* | 1 | 2 |', + ].join('\n'), + 'code-fences': [ + '```ts', + 'const answer: number = 42', + '```', + '', + '```', + 'no language', + '```', + '', + '```unknown-lang', + 'plain fallback', + '```', + '', + '```ts some=meta', + 'const withMeta = true', + '```', + '', + '```', + '```', + '', + ' indented code block', + ' second line', + ].join('\n'), + 'fence-trailing-blank-lines': [ + '```', + 'kept blank line follows', + '', + '```', + '', + '```ts', + 'const doubled = true', + '', + '', + '```', + '', + 'after', + ].join('\n'), + 'table-header-only': '| a | b |\n| --- | --- |\n\nafter', + 'inline-code-with-newline': 'Spans `a\nb` across a line.', + 'links-and-autolinks': [ + '[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).', + '', + '[relative dropped](/settings) and [js dropped](javascript:alert(1)) and [upper kept](HTTPS://example.com).', + '', + ' and bare autolink https://autolink.example.com literal.', + '', + '[spaces encoded](https://example.com/a b)', + ].join('\n'), + 'images': [ + '![https image](https://example.com/secure.png "img title")', + '', + '![http image](http://example.com/plain.png)', + '', + '![relative dropped](private.png) and inline ![bad scheme](javascript:alert(1)) end.', + '', + '![](https://example.com/empty-alt.png)', + ].join('\n'), + 'reference-links-and-images': [ + 'A [full][ref] reference, a [collapsed][] one, and a [shortcut] one.', + '', + '[missing full][nope], [missing collapsed][], ![missing image][gone].', + '', + '![ref image][imgref]', + '', + '[ref]: https://example.com/ref "ref title"', + '[collapsed]: https://example.com/collapsed', + '[shortcut]: https://example.com/shortcut', + '[imgref]: https://example.com/ref.png', + ].join('\n'), + 'footnotes': [ + 'First use[^a] and reuse[^a] and another[^b].', + '', + '[^a]: Footnote a body with [link](https://example.com).', + '', + '[^b]: Footnote b first paragraph.', + '', + ' Second paragraph of b.', + ].join('\n'), + 'raw-html-dropped': [ + '', + '', + 'Paragraph with inline html and bold tag kept literal?', + '', + '
              ', + 'html block content', + '
              ', + '', + 'after', + ].join('\n'), + 'entities-and-escapes': 'AT&T, 3 < 4, \\*not em\\*, backslash \\\\ literal, © entity.', + 'math-inline-and-display': [ + 'Einstein wrote $E = mc^2$ inline.', + '', + '$$', + '\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p', + '$$', + '', + 'Backslash inline \\(\\frac{1}{5}\\) and display:', + '', + '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]', + ].join('\n'), + 'math-edge-cases': [ + 'Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$.', + '', + 'Unbalanced errors render the error arm: $\\frac{$', + '', + '| Symbol | Value |', + '| --- | --- |', + '| $\\theta$ | \\(\\frac{1}{5}\\) |', + '', + '```math', + '\\sqrt{2}', + '```', + ].join('\n'), + 'gfm-strikethrough-and-literals': 'Mixed ~~gone~~ text with www.example.com literal and user@example.com email.', + 'cjk-strong-and-inline-code-url': [ + '**注意:**内容在标点后直接闭合。', + '', + '**Notice:**text keeps upstream parsing.', + '', + '*提醒!*单星号也保持上游行为。', + '', + '`https://example.com/preview?q=one%20two#result` 与 `curl http://127.0.0.1:3199/` 以及 `javascript:alert(1)`。', + ].join('\n'), + 'definition-only': '[unused]: https://example.com/unused', + 'streaming-typical-partial': '## Streaming\n\n- first\n- **unfinished', +} + +describe('MarkdownText DOM parity fixtures', () => { + for (const [name, text] of Object.entries(CORPUS)) { + it(`settled: ${name}`, async () => { + await expect(renderCase(text, false)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.settled.txt`) + }) + it(`streaming: ${name}`, async () => { + await expect(renderCase(text, true)).toMatchFileSnapshot(`./fixtures/markdown-dom/${name}.streaming.txt`) + }) + } +}) diff --git a/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx new file mode 100644 index 0000000000..b36ce8c674 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-incremental.spec.tsx @@ -0,0 +1,427 @@ +// @vitest-environment jsdom +// Incremental streaming behavior: a MarkdownText kept mounted across +// append-only rerenders must show, at every step, exactly the DOM a fresh +// mount of the same prefix shows, while reusing the frozen blocks' DOM nodes +// instead of remounting them. +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { Root, RootContent } from 'mdast' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { IncrementalMarkdownParser } from '../src/markdown/incremental.ts' +import { parseGfm } from '../src/markdown/parse.ts' + +afterEach(cleanup) + +/** + * A many-block document exercising every freeze-sensitive construct. The + * prefix-equivalence property below holds only while no reference or + * footnote definition lands on the far side of a freeze boundary from its + * use: a fresh mount parses everything in one tree while the live stream's + * frozen blocks are already baked (the fingerprint test demonstrates the + * documented deviation). Keep definitions adjacent to their references when + * extending this corpus. + */ +const STREAM_DOC = [ + '# Title', + '', + 'First paragraph with **strong** and `code`.', + '', + '- list item one', + '- list item two', + '', + ' continuation of item two', + '', + 'Setext heading', + '===', + '', + '| a | b |', + '| --- | --- |', + '| 1 | 2 |', + '', + '```ts', + 'const x = 1', + '', + 'still inside the fence', + '```', + '', + '> quote with lazy', + 'continuation line', + '', + 'Uses a footnote[^n] twice[^n].', + '', + '[^n]: The footnote body.', + '', + 'Closing paragraph after enough blocks to freeze everything above.', + '', + 'One more tail block.', +].join('\n') + +describe('incremental streaming rendering', () => { + for (const chunkSize of [1, 3, 7, 16]) { + it(`matches a fresh render at every prefix (chunk=${chunkSize})`, () => { + const live = render() + for (let end = chunkSize; end < STREAM_DOC.length + chunkSize; end += chunkSize) { + const prefix = STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length)) + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + } + live.unmount() + }) + } + + it('keeps frozen block DOM nodes across freezes instead of remounting', () => { + const paragraphs = Array.from({ length: 8 }, (_, i) => `Paragraph number ${i}.`) + const first = `${paragraphs[0]}\n\n` + const live = render() + const firstBlock = live.container.querySelector('p') + expect(firstBlock?.textContent).toBe(paragraphs[0]) + live.rerender() + // Same DOM node instance: the block kept its key across the freeze boundary. + expect(live.container.querySelector('p')).toBe(firstBlock) + expect(live.container.querySelectorAll('p')).toHaveLength(paragraphs.length) + live.unmount() + }) + + it('recovers when the text diverges instead of appending', () => { + const live = render() + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + live.unmount() + fresh.unmount() + }) + + it('drops the streaming cache when the copy labels change identity', () => { + const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n') + const live = render() + expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy']) + live.rerender() + expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren']) + live.unmount() + }) + + it('settles into the full math-enabled render after streaming', () => { + const doc = 'Value $E = mc^2$ inline.\n\nSecond.\n\nThird.\n\nFourth.' + const live = render() + expect(live.container.querySelector('.katex')).toBeNull() + live.rerender() + const settled = render() + expect(live.container.innerHTML).toBe(settled.container.innerHTML) + expect(live.container.querySelector('.katex')).not.toBeNull() + live.unmount() + settled.unmount() + }) +}) + +describe('incremental parsing is actually in effect', () => { + it('hands the grammar only the source tail once blocks freeze', () => { + const calls: string[] = [] + const recording = (text: string): Root => { + calls.push(text) + return parseGfm(text) + } + const parser = new IncrementalMarkdownParser(recording) + const paragraphs = Array.from({ length: 40 }, (_, i) => `Paragraph number ${i} with some words.`) + let text = '' + for (const paragraph of paragraphs) { + text += `${paragraph}\n\n` + parser.update(text) + } + expect(text.length).toBeGreaterThan(1500) + // Warm-up aside, every parse sees only the unstable tail: bounded by a + // few paragraphs, not the growing document. + const steady = calls.slice(5) + expect(Math.max(...steady.map(call => call.length))).toBeLessThan(200) + expect(steady.every(call => !call.includes('Paragraph number 0 '))).toBe(true) + // Cumulative parsed bytes stay linear in the document; full re-parsing + // would have accumulated ~40/2 times the document length here. + const totalParsed = calls.reduce((sum, call) => sum + call.length, 0) + expect(totalParsed).toBeLessThan(text.length * 5) + }) + + it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => { + const doc = [ + '[ref]: https://example.com/target', + '', + 'Paragraph one keeps the definition company.', + '', + 'Paragraph two pushes the freeze boundary.', + '', + 'Paragraph three freezes the definition out.', + '', + 'See [the link][ref] for details.', + ].join('\n') + const head = doc.slice(0, doc.indexOf('See')) + const live = render() + live.rerender() + // The tail re-parse cannot see the frozen definition, so the reference + // stays literal — the direct observable that the whole text was NOT + // re-parsed (a one-shot mount of the same text resolves it). + expect(live.container.querySelector('a')).toBeNull() + expect(live.container.textContent).toContain('[the link][ref]') + const fresh = render() + expect(fresh.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target') + fresh.unmount() + // The settled swap re-parses everything and heals the deviation. + live.rerender() + expect(live.container.querySelector('a')?.getAttribute('href')).toBe('https://example.com/target') + live.unmount() + }) +}) + +describe('freeze dynamics around frontier-sensitive constructs', () => { + it('an unclosed fence pins the tail: nothing freezes until it closes', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let text = 'p1.\n\np2.\n\np3.\n\n```ts\n' + const opened = parser.update(text) + const frozenAtOpen = opened.frozen.length + expect(opened.tail[opened.tail.length - 1]?.node.type).toBe('code') + for (const line of ['const a = 1\n', '\n', 'looks like a paragraph\n', '- looks like a list\n']) { + text += line + const grown = parser.update(text) + // The fence swallows everything appended, so the block census cannot + // grow and the freeze boundary must hold still. + expect(grown.frozen.length).toBe(frozenAtOpen) + expect(grown.tail[grown.tail.length - 1]?.node.type).toBe('code') + } + text += '```\n\nafter one.\n\nafter two.\n' + const closed = parser.update(text) + expect(closed.frozen.length).toBeGreaterThan(frozenAtOpen) + const frozenCode = closed.frozen.find(block => block.node.type === 'code')?.node + expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list') + }) + + it('a list can keep extending across blank lines until it freezes whole', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n' + const before = parser.update(text) + const frozenBefore = before.frozen.length + text += '\n- item c\n' + const extended = parser.update(text) + expect(extended.frozen.length).toBe(frozenBefore) + const tailList = extended.tail[extended.tail.length - 1]?.node + expect(tailList?.type === 'list' && tailList.children).toHaveLength(3) + text += '\nafter.\n\nmore.\n\nend.\n' + const after = parser.update(text) + const frozenList = after.frozen.find(block => block.node.type === 'list')?.node + expect(frozenList?.type === 'list' && frozenList.children).toHaveLength(3) + }) + + it('keeps every previously frozen key as a stable prefix across the stream', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let previous: readonly number[] = [] + for (let end = 7; end < STREAM_DOC.length + 7; end += 7) { + const { frozen } = parser.update(STREAM_DOC.slice(0, Math.min(end, STREAM_DOC.length))) + const keys = frozen.map(block => block.key) + expect(keys.slice(0, previous.length)).toEqual(previous) + previous = keys + } + expect(previous.length).toBeGreaterThan(4) + }) +}) + +describe('multibyte content', () => { + const CJK_DOC = [ + '# 标题 🎉', + '', + '这是一段包含 **加粗**、`行内代码` 与表情 😀🚀 的中文段落。', + '', + '- 列表项一 ✅', + '- 列表项二', + '', + '> 引用一行,带表情 🐟', + '', + '```', + '中文代码 🎯', + '```', + '', + '| 键 | 值 |', + '| --- | --- |', + '| 甲 | 乙 |', + '', + '结尾段落,足够多的块让前面全部冻结。🌊', + ].join('\n') + + it('code-unit chunking (splitting surrogate pairs mid-stream) matches fresh renders', () => { + const live = render() + for (let end = 1; end < CJK_DOC.length + 1; end += 1) { + const prefix = CJK_DOC.slice(0, Math.min(end, CJK_DOC.length)) + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + } + live.unmount() + }) + + it('freeze-cut offsets agree with one-shot parse offsets on astral content', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let result = parser.update(CJK_DOC.slice(0, 3)) + for (let end = 6; end < CJK_DOC.length + 3; end += 3) { + result = parser.update(CJK_DOC.slice(0, Math.min(end, CJK_DOC.length))) + } + const oneShot = parseGfm(CJK_DOC).children.map(node => node.position?.start.offset) + expect([...result.frozen, ...result.tail].map(block => block.key)).toEqual(oneShot) + expect(result.frozen.length).toBeGreaterThan(3) + }) +}) + +describe('streaming composition across freezes', () => { + it('continues footnote numbering from frozen references and lists all definitions', () => { + const doc = [ + 'Alpha uses a footnote[^a].', + '', + '[^a]: First note body.', + '', + 'Filler one.', + '', + 'Filler two.', + '', + 'Filler three.', + '', + 'Beta uses another[^b].', + '', + '[^b]: Second note body.', + ].join('\n') + const head = doc.slice(0, doc.indexOf('Beta')) + const live = render() + live.rerender() + expect([...live.container.querySelectorAll('p sup')].map(sup => sup.textContent)).toEqual(['1', '2']) + expect([...live.container.querySelectorAll('section.footnotes li')].map(li => li.id)) + .toEqual(['user-content-fn-a', 'user-content-fn-b']) + expect(live.container.querySelector('section.footnotes')?.textContent).toContain('First note body. ↩') + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + live.unmount() + }) + + it('keeps every frozen block DOM node through the rest of the stream', () => { + const paragraphs = Array.from({ length: 12 }, (_, i) => `Stable paragraph ${i}.`) + const half = `${paragraphs.slice(0, 6).join('\n\n')}\n\n` + const live = render() + const captured = [...live.container.querySelectorAll('p')] + expect(captured.length).toBe(6) + let text = half + for (const paragraph of paragraphs.slice(6)) { + text += `${paragraph}\n\n` + live.rerender() + } + const finalNodes = [...live.container.querySelectorAll('p')] + expect(finalNodes.slice(0, 6)).toEqual(captured) + expect(finalNodes).toHaveLength(12) + live.unmount() + }) + + it('renders an empty document for definition-only streams, including trailing blank lines', () => { + const doc = '[a]: https://example.com/1\n\n[b]: https://example.com/2\n\n[c]: https://example.com/3\n\n[d]: https://example.com/4' + const live = render() + live.rerender() + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + expect(live.container.querySelector('div')?.childNodes).toHaveLength(0) + fresh.unmount() + live.unmount() + }) + + it('survives streaming → settled → streaming prop flips with a fresh incremental state', () => { + const live = render() + live.rerender() + const settled = render() + expect(live.container.innerHTML).toBe(settled.container.innerHTML) + settled.unmount() + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + live.unmount() + }) + + it('matches fresh renders under irregular deterministic chunk sizes', () => { + let seed = 42 + const nextSize = (): number => { + seed = (seed * 1103515245 + 12345) % 2147483648 + return 1 + (seed % 13) + } + const live = render() + let end = 0 + while (end < STREAM_DOC.length) { + end = Math.min(end + nextSize(), STREAM_DOC.length) + const prefix = STREAM_DOC.slice(0, end) + live.rerender() + const fresh = render() + expect(live.container.innerHTML).toBe(fresh.container.innerHTML) + fresh.unmount() + } + live.unmount() + }) +}) + +describe('IncrementalMarkdownParser', () => { + it('freezes all but the trailing two blocks and keeps freezing as blocks appear', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const first = parser.update('a\n\nb\n\nc\n\nd\n\ne') + expect(first.frozen.map(b => b.node.type)).toEqual(['paragraph', 'paragraph', 'paragraph']) + expect(first.tail).toHaveLength(2) + const second = parser.update('a\n\nb\n\nc\n\nd\n\ne\n\nf\n\ng') + expect(second.frozen).toHaveLength(5) + expect(second.tail).toHaveLength(2) + // Previously returned frozen entries keep their identity and keys. + expect(second.frozen.slice(0, 3)).toEqual(first.frozen) + expect(second.generation).toBe(first.generation) + }) + + it('holds every block in the tail until more than two exist', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const result = parser.update('only\n\ntwo blocks') + expect(result.frozen).toHaveLength(0) + expect(result.tail).toHaveLength(2) + }) + + it('returns the cached result for identical input', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const first = parser.update('a\n\nb\n\nc') + expect(parser.update('a\n\nb\n\nc')).toBe(first) + }) + + it('bumps the generation and discards frozen blocks on non-append input', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const before = parser.update('a\n\nb\n\nc\n\nd') + expect(before.frozen.length).toBeGreaterThan(0) + const after = parser.update('different') + expect(after.generation).toBe(before.generation + 1) + expect(after.frozen).toHaveLength(0) + expect(after.tail.map(b => b.node.type)).toEqual(['paragraph']) + }) + + it('keys blocks by absolute source offset across freezes', () => { + const doc = 'aaa\n\nbbb\n\nccc\n\nddd\n\neee' + const parser = new IncrementalMarkdownParser(parseGfm) + const grown = parser.update(doc) + const oneShotKeys = parseGfm(doc).children.map(node => node.position?.start.offset) + expect([...grown.frozen, ...grown.tail].map(b => b.key)).toEqual(oneShotKeys) + }) + + it('never freezes under a grammar that omits positions', () => { + const bare = (text: string): Root => { + const root = parseGfm(text) + const strip = (nodes: RootContent[]): void => { + for (const node of nodes) { + delete node.position + if ('children' in node) strip(node.children) + } + } + strip(root.children) + return root + } + const parser = new IncrementalMarkdownParser(bare) + const result = parser.update('a\n\nb\n\nc\n\nd\n\ne') + expect(result.frozen).toHaveLength(0) + expect(result.tail).toHaveLength(5) + // Fallback keys stay unique per sibling. + expect(new Set(result.tail.map(b => b.key)).size).toBe(5) + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx new file mode 100644 index 0000000000..48dd03f5c5 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +// Branch coverage for the mdast renderer that real parses cannot reach: the +// grammar only emits references whose definitions exist, always stamps +// positions and align arrays, and never emits bare list items — but the +// renderer is a pure function over mdast, so hand-built trees exercise its +// defensive arms directly. +import { StrictMode } from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type * as Md from 'mdast' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, +} from '../src/markdown/render.tsx' +import type { MarkdownRenderContext } from '../src/markdown/render.tsx' + +afterEach(cleanup) + +function makeContext(): MarkdownRenderContext { + return { + streaming: false, + codeLabels: undefined, + targets: createReferenceTargets(), + footnoteOrder: [], + footnoteCounts: new Map(), + } +} + +function renderNodes(nodes: Md.RootContent[], context = makeContext()): HTMLElement { + const { container } = render( +
              {renderBlocks(nodes.map((node, key) => ({ node, key })), context)}
              , + ) + return container +} + +const text = (value: string): Md.Text => ({ type: 'text', value }) + +describe('renderBlocks over hand-built trees', () => { + it('reverts unresolved references to their bracketed source', () => { + const container = renderNodes([ + { + type: 'paragraph', + children: [ + { type: 'linkReference', identifier: 'a', referenceType: 'shortcut', children: [text('one')] }, + { type: 'linkReference', identifier: 'b', referenceType: 'collapsed', children: [text('two')] }, + { type: 'linkReference', identifier: 'c', label: 'C', referenceType: 'full', children: [text('three')] }, + { type: 'imageReference', identifier: 'd', referenceType: 'full', alt: 'pic' }, + { type: 'imageReference', identifier: 'e', referenceType: 'shortcut', alt: null }, + ], + }, + ]) + expect(container.textContent).toBe('[one][two][][three][C]![pic][d]![]') + expect(container.querySelector('a')).toBeNull() + }) + + it('keeps the first definition when identifiers repeat', () => { + const targets = createReferenceTargets() + collectReferenceTargets([ + { type: 'definition', identifier: 'dup', url: 'https://example.com/first' }, + { type: 'definition', identifier: 'dup', url: 'https://example.com/second' }, + { type: 'footnoteDefinition', identifier: 'fn', children: [] }, + { type: 'footnoteDefinition', identifier: 'fn', children: [{ type: 'paragraph', children: [text('late')] }] }, + ], targets) + expect(targets.definitions.get('DUP')?.url).toBe('https://example.com/first') + expect(targets.footnotes.get('FN')?.children).toEqual([]) + }) + + it('renders a bare list item, computing looseness from the item itself', () => { + const item: Md.ListItem = { + type: 'listItem', + spread: null, + children: [ + { type: 'paragraph', children: [text('alpha')] }, + { type: 'paragraph', children: [text('beta')] }, + ], + } + const container = renderNodes([item]) + // Two block children make the parentless item loose: paragraphs stay wrapped. + expect([...container.querySelectorAll('li > p')].map(p => p.textContent)).toEqual(['alpha', 'beta']) + }) + + it('renders spread-null lists and align-less tables', () => { + const container = renderNodes([ + { + type: 'list', + ordered: false, + spread: null, + children: [{ type: 'listItem', spread: null, children: [{ type: 'paragraph', children: [text('solo')] }] }], + }, + { + type: 'table', + children: [ + { type: 'tableRow', children: [{ type: 'tableCell', children: [text('h')] }] }, + { type: 'tableRow', children: [{ type: 'tableCell', children: [text('short')] }] }, + ], + }, + ]) + expect(container.querySelector('li')?.textContent).toBe('solo') + expect(container.querySelector('th')?.getAttribute('style')).toBeNull() + expect(container.querySelector('td')?.textContent).toBe('short') + }) + + it('pads rows against the alignment width with empty cells', () => { + const container = renderNodes([ + { + type: 'table', + align: ['left', 'right'], + children: [ + { type: 'tableRow', children: [{ type: 'tableCell', children: [text('only')] }] }, + ], + }, + ]) + const cells = [...container.querySelectorAll('th')] + expect(cells).toHaveLength(2) + expect(cells[1]?.textContent).toBe('') + }) + + it('renders a checked item without any content as a bare checkbox', () => { + const container = renderNodes([ + { + type: 'list', + ordered: false, + children: [ + { type: 'listItem', checked: true, children: [] }, + { type: 'listItem', checked: false, children: [{ type: 'paragraph', children: [] }] }, + ], + }, + ]) + const items = [...container.querySelectorAll('li.task-list-item')] + expect(items).toHaveLength(2) + for (const item of items) { + expect(item.querySelector('input[type="checkbox"]')).not.toBeNull() + expect(item.textContent?.trim()).toBe('') + } + }) + + it('renders images with a null alt as an empty alt attribute', () => { + const targets = createReferenceTargets() + targets.definitions.set('R', { type: 'definition', identifier: 'r', url: 'https://example.com/r.png' }) + const container = renderNodes([ + { type: 'paragraph', children: [{ type: 'image', url: 'https://example.com/x.png', alt: null }] }, + { type: 'paragraph', children: [{ type: 'imageReference', identifier: 'r', referenceType: 'full', alt: null }] }, + ], { ...makeContext(), targets }) + const images = [...container.querySelectorAll('img')] + expect(images.map(image => image.getAttribute('alt'))).toEqual(['', '']) + }) + + it('drops a definition nested in a list item without leaving a separator behind', () => { + const container = renderNodes([ + { + type: 'list', + ordered: true, + start: 3, + children: [{ + type: 'listItem', + children: [ + { type: 'paragraph', children: [text('body')] }, + { type: 'definition', identifier: 'x', url: 'https://example.com' }, + ], + }], + }, + ]) + expect(container.querySelector('ol')?.getAttribute('start')).toBe('3') + // The two mdast children make the item loose (wrap newlines around the + // paragraph); the dropped definition contributes nothing else. + expect(container.querySelector('li')?.textContent).toBe('\nbody\n') + }) + + it('renders nothing for node types without a mapping', () => { + const container = renderNodes([ + { type: 'yaml', value: 'front: matter' }, + { type: 'tableRow', children: [] }, + { type: 'paragraph', children: [text('after')] }, + ]) + expect(container.textContent).toBe('after') + }) +}) + +describe('renderFootnoteSection edge shapes', () => { + it('skips referenced footnotes without definitions and returns null when none remain', () => { + const context = makeContext() + context.footnoteOrder.push('GHOST') + context.footnoteCounts.set('GHOST', 1) + expect(renderFootnoteSection(context)).toBeNull() + }) + + it('renders no back-reference markers for an uncounted footnote', () => { + const context = makeContext() + context.targets.footnotes.set('Q', { + type: 'footnoteDefinition', + identifier: 'q', + children: [{ type: 'paragraph', children: [text('quiet')] }], + }) + context.footnoteOrder.push('Q') + const { container } = render(
              {renderFootnoteSection(context)}
              ) + expect(container.querySelector('li')?.textContent).toBe('\nquiet \n') + }) + + it('appends back-references after a non-paragraph body', () => { + const context = makeContext() + context.targets.footnotes.set('N', { + type: 'footnoteDefinition', + identifier: 'n', + children: [{ type: 'code', value: 'code body', lang: null }], + }) + context.footnoteOrder.push('N') + context.footnoteCounts.set('N', 1) + const { container } = render(
              {renderFootnoteSection(context)}
              ) + const item = container.querySelector('li') + expect(item?.querySelector('.md-code-block')).not.toBeNull() + expect(item?.textContent).toContain('↩') + }) +}) + +describe('MarkdownText under StrictMode', () => { + it('streams identically when React double-invokes render work', () => { + const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive' + const strict = render() + strict.rerender() + const plain = render() + expect(strict.container.innerHTML).toBe(plain.container.innerHTML) + strict.unmount() + plain.unmount() + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 7a858c1199..5066e32cd2 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import type { Extension } from 'micromark-util-types' import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' -import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts' +import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' +import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' afterEach(cleanup) @@ -68,6 +68,100 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) + it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => { + const cases = [ + ['**注意:**内容', '注意:'], + ['**Notice:**内容', 'Notice:'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], + ['**句号。**后续', '句号。'], + ['**Period.**后续', 'Period.'], + ['**提醒!**继续', '提醒!'], + ['**Warning!**继续', 'Warning!'], + ] as const + const source = cases.map(([markdown]) => markdown).join('\n\n') + + for (const streaming of [false, true]) { + const rendered = render() + expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent)) + .toEqual(cases.map(([, strong]) => strong)) + rendered.unmount() + } + }) + + it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => { + const source = [ + String.raw`\**注意:**内容`, + '`**注意:**内容`', + '**Notice:**text', + '*提醒!*继续', + '$**注意:**内容$', + '```md', + '**注意:**内容', + '```', + '**普通**内容', + '*普通*内容', + ].join('\n\n') + const { container } = render() + + expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通']) + expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通']) + expect(container.querySelector('code')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容') + expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容') + expect(container.textContent).toContain('**Notice:**text') + expect(container.textContent).toContain('*提醒!*继续') + expect(container.textContent).toContain('**注意:**内容') + }) + + it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => { + const localUrl = 'http://127.0.0.1:3199/?demo=1' + const remoteUrl = 'https://example.com/preview?q=one%20two#result' + const source = [ + `\`${localUrl}\``, + `\`${remoteUrl}\``, + '`curl http://127.0.0.1:3199/?demo=1`', + '`javascript:alert(1)`', + '`mailto:dev@example.com`', + `\` ${localUrl} \``, + '```', + localUrl, + '```', + ].join('\n\n') + const { container } = render() + + const links = screen.getAllByRole('link') + expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl]) + for (const link of links) { + expect(link.closest('code')).not.toBeNull() + expect(link.getAttribute('target')).toBe('_blank') + expect(link.getAttribute('rel')).toBe('noopener noreferrer') + } + links[0]?.focus() + expect(document.activeElement).toBe(links[0]) + expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull() + expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull() + expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull() + const paddedCode = [...container.querySelectorAll('code')] + .find(code => code.textContent === ` ${localUrl} `) + expect(paddedCode?.querySelector('a')).toBeNull() + expect(container.querySelector('pre code a')).toBeNull() + }) + + it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => { + const extension = cjkFriendlyStrong() + expect(cjkFriendlyStrong()).toBe(extension) + const construct = extension.text?.[42] + const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize + expect(tokenizer).toBeTypeOf('function') + expect(() => tokenizer?.call({ + parser: { constructs: { attentionMarkers: {} } }, + previous: null, + } as never, {} as never, () => undefined, () => undefined)).toThrow( + 'micromark CommonMark attention markers are unavailable', + ) + }) + it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => { for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) { const { container, unmount } = render() @@ -321,11 +415,11 @@ describe('MarkdownText', () => { expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$') }) - it('registers the compatibility extension on a bare remark processor', () => { - const data: { micromarkExtensions?: Extension[] } = {} - remarkMathCompatibility.call({ data: () => data }) + it('exposes the compatibility syntax as a micromark extension', () => { + const extension = mathCompatibility() - expect(data.micromarkExtensions).toHaveLength(1) + expect(Object.keys(extension)).toEqual(['flow', 'text']) + expect(mathCompatibility()).toBe(extension) }) it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => { diff --git a/packages/client/ui-question/src/client/index.ts b/packages/client/ui-question/src/client/index.ts index 8cc25aeb88..73d2d656b1 100644 --- a/packages/client/ui-question/src/client/index.ts +++ b/packages/client/ui-question/src/client/index.ts @@ -36,13 +36,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'question' -/** - * Required services (cordis fiber inject). 'conversation' is an ordering - * edge, not a call dependency: the 'conversation.composer' chain slot is - * declared by ui-conversation's apply, and register() into an undeclared - * slot throws — service waiting orders this apply after the declaring one. - */ -export const inject = ['slots', 'conversation', 'locale'] +/** Required services: the slot registry and the question composer's copy. */ +export const inject = ['slots', 'locale'] /** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null { @@ -58,11 +53,8 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries') - ctx.effect( - () => ctx.slots.register( - { name: 'conversation.composer', select: selectQuestion, locale: NS }, - QuestionComposer, - ), - 'ui-question: composer chain registration', - ) + ctx.slots.inject('conversation.composer', () => ctx.slots.register( + { name: 'conversation.composer', select: selectQuestion, locale: NS }, + QuestionComposer, + )) } diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 0acc7fac82..01b077a29a 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -2,7 +2,7 @@ * apply wiring on a real cordis Context + SlotsService: QuestionComposer * registered as the `question` entry of the conversation-declared composer * slot with ZERO business face (data and verbs ride the dispatched carrier), - * load-order fail-loud, and fiber-teardown unregistration. Component and + * declaration-aware activation, and fiber-teardown unregistration. Component and * domain-face behavior is covered props-direct in question-composer.spec.tsx; * no renderer machinery here. */ @@ -22,27 +22,28 @@ async function bench() { { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, () => null, ) - // 'conversation' inject is an ordering edge (the declaring plugin provides - // it after declaring the chain); the bench declares the chain itself. - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) return { ctx, slots } } describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slots', 'conversation', 'locale']) + expect(inject).toEqual(['slots', 'locale']) }) - it('fails loud when no live entry has declared the composer slot', async () => { + it('waits until a live entry declares the composer slot', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() - // Satisfy the ordering inject without declaring the chain: apply must - // then hit the undeclared-slot throw, not sit waiting on the service. - ctx.provide('conversation', {}) ctx.provide('locale', new LocaleService(ctx)) - await expect(ctx.plugin({ inject: [...inject], apply })) - .rejects.toThrow(/slot "conversation.composer" is not declared/) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.composer')).toHaveLength(0) + ctx.slots.register( + { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, + () => null, + ) + await Promise.resolve() + expect(ctx.slots.entries('conversation.composer')).toHaveLength(1) }) it('registers the question entry: routing selector, no inject face', async () => { diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 888b30e21e..9d57a0eba9 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -6,7 +6,6 @@ * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). @@ -50,7 +49,7 @@ const NS = 'settings' /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT - * constrained; registration goes through declaration-aware deferral. + * constrained; registrations depend on their slots through `slots.inject()`. */ export const inject = ['slots', 'locale', 'connection'] @@ -97,47 +96,34 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-settings-general: metadata invalidations') - ctx.effect(() => { - const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () => - ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) - const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => - ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) - const action = documentInjected === undefined - ? undefined - : deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () => - ctx.slots.register({ - name: 'settings.action', - id: 'open-document', - order: 0, - locale: NS, - inject: documentInjected, - }, SettingsDocumentAction)) - const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => - ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) - const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => - ctx.slots.register({ - name: 'settings.section', - id: 'general', - order: 0, - label: () => t('general.nav'), - locale: NS, - children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, - }, GeneralSection)) - const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () => - ctx.slots.register({ - name: 'settings.onboarding', - id: 'welcome-notice', - order: -100, - locale: NS, - inject: welcomeInjected, - }, WelcomeNotice)) - return () => { - trigger.dispose() - header.dispose() - action?.dispose() - close.dispose() - general.dispose() - welcome.dispose() - } - }, 'ui-settings-general: chrome, action, section, and onboarding registrations') + ctx.slots.inject('settings.trigger', () => + ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) + ctx.slots.inject('settings.header', () => + ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) + if (documentInjected !== undefined) { + ctx.slots.inject('settings.action', () => ctx.slots.register({ + name: 'settings.action', + id: 'open-document', + order: 0, + locale: NS, + inject: documentInjected, + }, SettingsDocumentAction)) + } + ctx.slots.inject('settings.close', () => + ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: () => t('general.nav'), + locale: NS, + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + }, GeneralSection)) + ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ + name: 'settings.onboarding', + id: 'welcome-notice', + order: -100, + locale: NS, + inject: welcomeInjected, + }, WelcomeNotice)) } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 78c331f4f9..815a62f24c 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -12,7 +12,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // read (nav labels may be locale-following thunks; the shell still ships no // copy of its own and takes no hard locale dependency). import type {} from '@deepseek-ai/dsh-client-locale/client' -import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, } from './contract/slots.ts' @@ -27,8 +27,8 @@ export type { /** * Required services (cordis fiber inject). The target slot is declared by * ui-sidebar's apply, whose activation order relative to this one is NOT - * constrained (dshClient.inject edges are informational); registration goes - * through declaration-aware deferral. + * constrained (dshClient.inject edges are informational); registration + * depends on the slot through `slots.inject()`. */ export const inject = ['slots'] @@ -96,20 +96,16 @@ export function apply(ctx: ClientContext): void { }, }, }) - ctx.effect(() => { - const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => - ctx.slots.register({ - name: 'sidebar.settings', - children: { - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - inject: injected, - }, SettingsRoot)) - return () => { deferred.dispose() } - }, 'ui-settings: shell registration') + ctx.slots.inject('sidebar.settings', () => ctx.slots.register({ + name: 'sidebar.settings', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + 'settings.onboarding': { kind: 'list', scope: 'root' }, + }, + inject: injected, + }, SettingsRoot)) } diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index 79335a12ac..c46a5bf25c 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -1,4 +1,4 @@ -/** Settings shell registration: declaration-aware deferral, the ledger projections, and HMR recovery. */ +/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index b7829ba95c..bcc20a8679 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -55,14 +55,10 @@ export const inject = ['sessions', 'locale'] export function apply(ctx: ClientContext): void { ctx.plugin(SlashService) ctx.effect(() => ctx.locale.register(MENU_NS, { zh, en }), 'ui-slash: menu dictionaries') - // Conditional mount: 'conversation.input.overlay' is declared by the - // conversation composer entry, and the conversation service is mounted - // after that declaration lands on the ledger — its presence is the - // registration-safe signal (same seam as toolview registrants). - ctx.inject(['slots', 'conversation', 'slash', 'sessions'], (scope: ClientContext) => { + ctx.inject(['slots', 'slash', 'sessions'], (scope: ClientContext) => { const slash = scope.slash const sessions = scope.sessions - scope.effect(() => scope.slots.register({ + scope.slots.inject('conversation.input.overlay', () => scope.slots.register({ name: 'conversation.input.overlay', id: 'slash-menu', order: 0, @@ -79,6 +75,6 @@ export function apply(ctx: ClientContext): void { onDismiss: () => { controller.dismiss() }, } }, - }, MenuView), 'ui-slash: MenuView overlay registration') + }, MenuView)) }) } diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index 7121f79441..c8d65f10c8 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -1,12 +1,11 @@ /** * apply wiring on a real cordis Context + SlotsService: SlashService mounts * as ctx.slash once its sessions dependency is up; the MenuView overlay - * registration waits on the conversation seam (ctx.inject scope), lands once - * the declarer is up, resolves the per-session controller from the slot's + * registration follows the slot declaration, resolves the per-session controller from the slot's * sessionId, and unregisters on fiber teardown. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -25,8 +24,8 @@ async function bench() { await ctx.plugin(SlotsService).await() const slots = ctx.get('slots') as SlotsService // Stand-in for the ui-conversation composer entry: declare the overlay - // slot, then provide the conversation service (declaration precedes the - // service exactly as the real apply orders them). + // slot without providing ConversationService, which is not its lifecycle + // signal. slots.register( { name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never, () => null, @@ -67,11 +66,7 @@ describe('apply', () => { it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => { const { ctx, slots } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() - expect(slots.entries('conversation.input.overlay')).toHaveLength(0) - - ctx.provide('conversation', {}) - // The inject scope activates asynchronously on the service arrival. - await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) }) + expect(slots.entries('conversation.input.overlay')).toHaveLength(1) const entries = slots.entries('conversation.input.overlay') expect(entries[0]!.options.id).toBe('slash-menu') // Copy rides the standard locale seat, not the business face. @@ -100,8 +95,7 @@ describe('apply', () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - ctx.provide('conversation', {}) - await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) }) + expect(slots.entries('conversation.input.overlay')).toHaveLength(1) await fiber.dispose() expect(slots.entries('conversation.input.overlay')).toHaveLength(0) diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index 07fe225ccd..dd103f395a 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md -README.md: ed6f052b3a47e08d693928b6763e32427b829467 -README.zh.md: e12e4bdad738c70657927b62d4a7bbd46d4b1519 +README.md: bb489dea0c3848cf3d501dcf095a65fe1cef9ef6 +README.zh.md: b4a2915b9d85c45ef7c6dccf27761794e21aa65f diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index ed6f052b3a..bb489dea0c 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -19,7 +19,7 @@ The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are The store family (`defineStore` spec in / `StoreHandle` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here. -`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. +`SlotCore` seeds the a-priori `'root'` slot at construction and enforces load-time validation (undeclared-slot registration, duplicate child declaration, one shared handle under two scopes, a chain registration without `select` — all throw at register). An entry's disposer collapses its declared child slots recursively: ledger rows, contributions, and store mounts die on one lifecycle axis. Each key also carries a declaration epoch that advances only on declaration and collapse; the runtime uses it for [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection), independently from ordinary entry versions. `renderer.ts` carries the install seam (`SlotRenderer`, `SlotRendererHost`) plus `StaleAuthorizationError`/`SlotOwnershipError`; the implementation lives in web-react, the installation in the shell boot. ## Model Experience diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index e12e4bdad7..b4a2915b9d 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -19,7 +19,7 @@ chain-kind slot 会反转键控路由:条目自行提名,而不是由分发 store 家族(输入 `defineStore` 规范/输出 `StoreHandle`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React hook;hook 绑定属于渲染机制这一侧的 seam,只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。 -`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。 +`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。每个 key 还携带一个 declaration epoch(声明代次),它只在声明与折叠时递增;运行时将其用于 [`ctx.slots.inject`](../runtime/README.md#slot-declaration-injection),且与普通条目版本相互独立。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。 ## 模型体验 diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts deleted file mode 100644 index 68f509b916..0000000000 --- a/packages/client/ui-slots/src/deferred.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Declaration-aware registration deferral: the shared timing machinery for - * registering into a slot whose declaring entry activates in unconstrained - * order (dshClient.inject edges never sequence apply). Presence is judged on - * the LEDGER, not a local flag — after an HMR collapse re-declares the slot, - * the cascade has already removed the entry while the local disposer went - * stale, and a flag guard would block the re-registration. - */ - -/** Minimal registry face the deferral reads (SlotsService satisfies it). */ -export interface DeferralRegistry { - /** Declared spec lookup (undefined = not declared yet). */ - spec(name: string): unknown - /** Current entries of the slot (component identity is the presence judge). */ - entries(name: string): readonly { component: unknown }[] - /** Subscribe to the slot's ledger changes; returns the unsubscriber. */ - subscribe(name: string, listener: () => void): () => void -} - -/** Handle over one deferred registration. */ -export interface DeferredRegistration { - /** - * Drop the current registration (stale disposers are harmless no-ops) and - * immediately re-attempt — the refresh path for registrants whose options - * carry localized text. - */ - refresh(): void - /** Unsubscribe and unregister (idempotent through the slot core). */ - dispose(): void -} - -/** - * Register into `name` as soon as its declaration is on the ledger, and - * re-register whenever the declaration reappears after a collapse. - * @param registry - the slot registry face. - * @param name - target slot name. - * @param component - the component whose ledger presence marks "registered". - * @param register - performs the actual registration; returns its disposer. - * @param onFailure - owns a registration failure that fires from a LATER - * ledger flush (a declaration landing after two providers deferred, say): - * the deferral first removes its own subscription, then hands the error - * over instead of throwing through the flush — the callback's chance to - * roll back sibling deferrals and surface the conflict on a loud channel. - * Absent, a late failure rethrows out of the flush. - * @returns the deferral handle (dispose in the owning effect's disposer). - * @throws the immediate registration's failure, after removing the - * just-installed subscription — a throwing construction leaves nothing live. - */ -export function deferRegistration( - registry: DeferralRegistry, - name: string, - component: unknown, - register: () => () => void, - onFailure?: (error: unknown) => void, -): DeferredRegistration { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (registry.spec(name) === undefined) return - if (registry.entries(name).some(e => e.component === component)) return - dispose = register() - } - const unsubscribe = registry.subscribe(name, () => { - try { - tryRegister() - } catch (error) { - unsubscribe() - if (onFailure === undefined) throw error - onFailure(error) - } - }) - try { - tryRegister() - } catch (error) { - // A synchronous registration failure (the declared slot is already - // occupied) must not leave the just-installed subscription behind: the - // caller receives no handle to dispose it through. - unsubscribe() - throw error - } - return { - refresh() { - dispose?.() - dispose = undefined - tryRegister() - }, - dispose() { - unsubscribe() - dispose?.() - }, - } -} - -/** - * Defer ONE occupant into several holes as a unit. Construction that throws - * partway (a declared hole already occupied registers synchronously) rolls - * every earlier deferral back before rethrowing; a failure surfacing from a - * LATER ledger flush (holes declared after rival providers activated) rolls - * the whole group back the same way and re-raises the wrapped error on the - * global channel the boot's fail-loud handler owns — never a throw through - * the slot flush, never partial occupancy from the group's owner. - * @param registry - the slot registry face. - * @param names - the target holes (one registration per name). - * @param component - the occupant whose ledger presence marks "registered". - * @param register - performs one hole's registration; returns its disposer. - * @returns the group handle (dispose in the owning effect's disposer). - * @throws the immediate registration's failure, after rolling the group back. - */ -export function deferGroupRegistration( - registry: DeferralRegistry, - names: readonly K[], - component: unknown, - register: (name: K) => () => void, -): { dispose: () => void } { - const deferred: DeferredRegistration[] = [] - const lateFailure = (error: unknown): void => { - for (const entry of deferred) entry.dispose() - queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) - } - try { - for (const name of names) { - deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure)) - } - } catch (error) { - for (const entry of deferred) entry.dispose() - throw error - } - return { dispose: () => { for (const entry of deferred) entry.dispose() } } -} diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index f7f31a8ed1..6a20a8b916 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -19,7 +19,6 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec export * from './store.ts' export * from './renderer.ts' -export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} @@ -457,9 +456,12 @@ interface SlotRecord { spec: SlotSpec | undefined /** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */ declaredBy: string | undefined + /** Monotonic declaration lifetime, distinct from ordinary entry mutations. */ + declarationEpoch: number entries: readonly StoredEntry[] version: number listeners: Set<() => void> + declarationListeners: Set<() => void> } const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]) @@ -473,8 +475,10 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([]) * * Change propagation contract: versions bump and {@link SlotCore.onMutate} * fires synchronously per mutation (registry state is consistent when they - * fire); {@link SlotCore.subscribe} notifications batch per microtask, so N - * same-tick mutations produce one notification per touched key. + * fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each + * declaration lifetime boundary; {@link SlotCore.subscribe} notifications + * batch per microtask, so N same-tick mutations produce one notification per + * touched key. */ export class SlotCore { private records = new Map() @@ -491,6 +495,7 @@ export class SlotCore { const root = this.record('root') root.spec = { kind: 'single', scope: 'root' } root.declaredBy = '(built-in)' + root.declarationEpoch = 1 } /** @@ -631,12 +636,22 @@ export class SlotCore { rec.entries = next this.markDirty(options.name, rec) if (options.children) { + const declarations: [key: string, record: SlotRecord][] = [] for (const [childKey, childSpec] of Object.entries(options.children)) { const childRec = this.record(childKey) childRec.spec = childSpec childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}` + childRec.declarationEpoch += 1 + declarations.push([childKey, childRec]) + } + // Synchronous listeners may register into or try to redeclare a sibling; + // publish only after the whole children table owns its declarations. + for (const [childKey, childRec] of declarations) { this.markDirty(childKey, childRec) } + for (const [, childRec] of declarations) { + this.notifyDeclaration(childRec) + } } return () => { if (!rec.entries.includes(entry)) return @@ -692,6 +707,16 @@ export class SlotCore { return this.records.get(key)?.spec } + /** + * Read the declaration lifetime of a key. Entry additions and removals do + * not change it; declaration creation and collapse each advance it. + * @param key - slot key. + * @returns monotonic epoch (0 before the first declaration). + */ + declarationEpoch(key: string): number { + return this.records.get(key)?.declarationEpoch ?? 0 + } + /** * Subscribe to registration changes for a key (microtask-batched). * Subscribing ahead of declaration is allowed; the declaration notifies. @@ -705,6 +730,22 @@ export class SlotCore { return () => { rec.listeners.delete(fn) } } + /** + * Subscribe to declaration lifetime boundaries for a key. Notifications + * are synchronous so declaration teardown finishes before a subsequent + * same-tick registration can observe stale resources. Ordinary entry + * mutations do not notify this surface. A children table commits every + * sibling declaration before its first notification. + * @param key - slot key. + * @param fn - declaration or collapse callback. + * @returns unsubscribe. + */ + subscribeDeclaration(key: string, fn: () => void): () => void { + const rec = this.record(key) + rec.declarationListeners.add(fn) + return () => { rec.declarationListeners.delete(fn) } + } + /** * Monotonic version for a key, bumped synchronously per mutation so a * uSES getSnapshot read is never stale when its batched notification lands. @@ -746,8 +787,10 @@ export class SlotCore { const doomed = childRec.entries childRec.spec = undefined childRec.declaredBy = undefined + childRec.declarationEpoch += 1 childRec.entries = NO_ENTRIES this.markDirty(childKey, childRec) + this.notifyDeclaration(childRec) for (const dead of doomed) this.releaseEntry(dead) } } @@ -755,7 +798,15 @@ export class SlotCore { private record(key: string): SlotRecord { let rec = this.records.get(key) if (!rec) { - rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() } + rec = { + spec: undefined, + declaredBy: undefined, + declarationEpoch: 0, + entries: NO_ENTRIES, + version: 0, + listeners: new Set(), + declarationListeners: new Set(), + } this.records.set(key, rec) } return rec @@ -771,6 +822,10 @@ export class SlotCore { } } + private notifyDeclaration(rec: SlotRecord): void { + for (const fn of [...rec.declarationListeners]) fn() + } + private flush(): void { // Reset before iterating so a mutation from inside a listener re-schedules. this.flushScheduled = false diff --git a/packages/client/ui-slots/tests/core.spec.ts b/packages/client/ui-slots/tests/core.spec.ts index 8ed6b56498..bd0170d223 100644 --- a/packages/client/ui-slots/tests/core.spec.ts +++ b/packages/client/ui-slots/tests/core.spec.ts @@ -231,6 +231,22 @@ describe('store scope pinning', () => { }) describe('subscription surface', () => { + it('tracks declaration epochs separately from ordinary entry mutations', () => { + const core = new SlotCore() + expect(core.declarationEpoch('root')).toBe(1) + expect(core.declarationEpoch('test.list')).toBe(0) + const disposeFrame = mountFrame(core) + const declared = core.declarationEpoch('test.list') + expect(declared).toBe(1) + const disposeEntry = core.register({ name: 'test.list', id: 'a' }, Comp) + disposeEntry() + expect(core.declarationEpoch('test.list')).toBe(declared) + disposeFrame() + expect(core.declarationEpoch('test.list')).toBe(declared + 1) + mountFrame(core) + expect(core.declarationEpoch('test.list')).toBe(declared + 2) + }) + it('entries() returns a stable cached reference between mutations', () => { const core = new SlotCore() mountFrame(core) @@ -267,6 +283,44 @@ describe('subscription surface', () => { expect(fn).toHaveBeenCalledTimes(1) }) + it('notifies declaration subscribers synchronously, excluding entries, until unsubscribe', () => { + const core = new SlotCore() + const fn = vi.fn() + const unsubscribe = core.subscribeDeclaration('test.list', fn) + const disposeFrame = mountFrame(core) + expect(fn).toHaveBeenCalledTimes(1) + core.register({ name: 'test.list', id: 'ordinary' }, Comp) + expect(fn).toHaveBeenCalledTimes(1) + disposeFrame() + expect(fn).toHaveBeenCalledTimes(2) + unsubscribe() + mountFrame(core) + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('commits sibling declarations before notifying declaration subscribers', () => { + const core = new SlotCore() + let duplicateDeclaration: unknown + const unsubscribe = core.subscribeDeclaration('test.single', () => { + core.register({ name: 'test.list', id: 'from-listener' }, Comp) + try { + core.register({ + name: 'test.single', + children: { 'test.list': { kind: 'list', scope: 'root' } }, + }, Comp as never) + } catch (error) { + duplicateDeclaration = error + } + }) + + const disposeFrame = mountFrame(core) + expect(core.entries('test.list')).toHaveLength(1) + expect(String(duplicateDeclaration)).toContain('already declared') + unsubscribe() + disposeFrame() + expect(core.specDynamic('test.list')).toBeUndefined() + }) + it('notifies only subscribers of the touched key; unsubscribe stops delivery', async () => { const core = new SlotCore() mountFrame(core) diff --git a/packages/client/ui-slots/tests/deferred.spec.ts b/packages/client/ui-slots/tests/deferred.spec.ts deleted file mode 100644 index 6e7b983852..0000000000 --- a/packages/client/ui-slots/tests/deferred.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -// deferRegistration lifecycle: declaration-aware registration, HMR -// re-registration, and — the failure contract — no subscription survives a -// construction that throws synchronously (an already-occupied single slot). -import { describe, expect, it, vi } from 'vitest' -import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' - -// Shares the merges declared by core.spec.ts (same program); reuse its keys. -const HOLE = 'test.single' as const - -function declared(): SlotCore { - const core = new SlotCore() - core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) - return core -} - -describe('deferRegistration', () => { - it('registers immediately under an existing declaration and disposes cleanly', () => { - const core = declared() - const component = (): null => null - const handle = deferRegistration(core, HOLE, component, () => - core.register({ name: HOLE } as never, component as never)) - expect(core.entries(HOLE)).toHaveLength(1) - handle.dispose() - expect(core.entries(HOLE)).toHaveLength(0) - }) - - it('hands a late registration failure to onFailure after unsubscribing itself', async () => { - const core = new SlotCore() - const component = (): null => null - const foreign = (): null => null - const failures: unknown[] = [] - // Nothing is declared yet: the deferral just subscribes and waits. - const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) - deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) }) - // The declaration lands with a foreign occupant racing in first: the - // deferral's flush-time attempt fails, unsubscribes itself, and reports - // through onFailure instead of throwing out of the flush. - core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) - const disposeForeign = core.register({ name: HOLE } as never, foreign as never) - await Promise.resolve() - expect(failures.map(String).join('')).toContain('already has a registration') - // Unsubscribed: freeing the hole must not resurrect the loser. - disposeForeign() - await Promise.resolve() - expect(core.entries(HOLE)).toHaveLength(0) - }) - - it('drops its subscription when the immediate registration throws', async () => { - const core = declared() - const foreign = (): null => null - const disposeForeign = core.register({ name: HOLE } as never, foreign as never) - const component = (): null => null - const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) - // The single hole is occupied: the immediate attempt throws out of the - // constructor, and the caller never receives a handle to dispose. - expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/) - expect(register).toHaveBeenCalledOnce() - // The subscription rolled back with it: freeing the hole flushes a - // notification that must not resurrect the failed registration. - disposeForeign() - await Promise.resolve() - expect(register).toHaveBeenCalledOnce() - expect(core.entries(HOLE)).toHaveLength(0) - }) -}) - -describe('deferGroupRegistration', () => { - const HOLES = ['test.single', 'test.grandchild'] as const - - function declaredPair(): SlotCore { - const core = new SlotCore() - core.register({ - name: 'root', - children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), - } as never, (() => null) as never) - return core - } - - it('registers the whole group and disposes it as a unit', () => { - const core = declaredPair() - const component = (): null => null - const group = deferGroupRegistration(core, HOLES, component, name => - core.register({ name } as never, component as never)) - for (const name of HOLES) expect(core.entries(name)).toHaveLength(1) - group.dispose() - for (const name of HOLES) expect(core.entries(name)).toHaveLength(0) - }) - - it('rolls the group back when construction fails partway', () => { - const core = declaredPair() - const component = (): null => null - core.register({ name: HOLES[1] } as never, (() => null) as never) - expect(() => deferGroupRegistration(core, HOLES, component, name => - core.register({ name } as never, component as never))).toThrow(/already has a registration/) - // The first hole's registration and subscription rolled back with it. - expect(core.entries(HOLES[0])).toHaveLength(0) - }) - - it('rolls the group back and re-raises loudly on a late conflict', async () => { - const core = new SlotCore() - const component = (): null => null - const failures: unknown[] = [] - const onLoud = (reason: unknown): void => { failures.push(reason) } - process.on('uncaughtException', onLoud) - try { - const group = deferGroupRegistration(core, HOLES, component, name => - core.register({ name } as never, component as never)) - // Declaration lands with a rival racing in ahead of the flush. - core.register({ - name: 'root', - children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), - } as never, (() => null) as never) - core.register({ name: HOLES[0] } as never, (() => null) as never) - core.register({ name: HOLES[1] } as never, (() => null) as never) - await new Promise(resolve => setTimeout(resolve, 20)) - expect(failures.map(String).join('')).toContain('already has a registration') - // No partial occupancy from the group's owner survives. - for (const name of HOLES) { - expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0) - } - group.dispose() - } finally { - process.off('uncaughtException', onLoud) - } - }) -}) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 31579dc258..95626cbaf1 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -36,7 +36,7 @@ export type { } from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ -export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale'] +export const inject = ['slash', 'sessions', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -103,7 +103,8 @@ export function apply(ctx: ClientContext): void { sessions.setSubagentCatalogOpen(parentSessionId, open) }, }) - ctx.effect( + ctx.slots.inject( + 'conversation.session.header.actions', () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', @@ -111,15 +112,14 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: catalogActions, }, SubagentCatalogAction), - 'ui-subagent: lazy descendant catalog action', ) - ctx.effect( + ctx.slots.inject( + 'conversation.composer', () => ctx.slots.register({ name: 'conversation.composer', priority: -10, locale: NS, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), - 'ui-subagent: read-only addressed composer', ) } diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index d2332cb3af..09221e1e94 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -75,7 +75,6 @@ async function provideSlotFaces(ctx: Context): Promise { 'conversation.composer': { kind: 'chain', scope: 'session' }, }, } as never, () => null) - ctx.provide('conversation', {}) } /** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */ @@ -113,7 +112,7 @@ const req = (query: string) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale']) + expect(inject).toEqual(['slash', 'sessions', 'slots', 'locale']) }) it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 133436c693..eb096412f5 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,7 +7,7 @@ * section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' -import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -254,16 +254,12 @@ export function apply(ctx: ClientContext): void { setTheme: (id) => { theme.setTheme(id) }, } } - ctx.effect(() => { - const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'appearance', - order: 10, - store, - locale: SETTINGS_NS, - inject: injected, - }, AppearanceRow)) - return () => { deferred.dispose() } - }, 'ui-theme: appearance settings row registration') + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'appearance', + order: 10, + store, + locale: SETTINGS_NS, + inject: injected, + }, AppearanceRow)) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 1abb2016ce..a6b5a4282d 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -10,14 +10,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' -/** - * Required services (cordis fiber inject). 'conversation' is an ordering - * edge, not a call dependency: the 'conversation.view' slot is declared by - * ui-conversation's apply (which then provides the service), and register() - * into an undeclared slot throws — service waiting is what orders this - * apply after the declaring one. - */ -export const inject = ['slots', 'conversation', 'sessionHistory'] +/** Required services: the conversation view slot and independent history source. */ +export const inject = ['slots', 'sessionHistory'] /** * Client plugin body: register the trajectory view tab. The registration @@ -26,7 +20,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory'] */ export function apply(ctx: Context): void { const duration = createTrajectoryDurationStore() - ctx.slots.register({ + ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', order: 10, @@ -40,5 +34,5 @@ export function apply(ctx: Context): void { setActualDuration: (value) => { duration.set(value) }, } }, - }, TrajectoryView) + }, TrajectoryView)) } diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 8de9e5378b..579a97337a 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -61,7 +61,7 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory']) + expect(surface.inject).toEqual(['slots', 'sessionHistory']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -73,10 +73,8 @@ describe('tsdown client artifact', () => { name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin injects 'conversation' as an ordering edge and - // 'sessionHistory' for its per-session history source; this bench - // supplies both. - ctx.provide('conversation', {}) + // The plugin reads sessionHistory for its per-session history source; + // slot availability is tracked by slots.inject. ctx.provide('sessionHistory', {}) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index cce16df0a9..e608b9fd68 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -13,6 +13,8 @@ const checkpoint = { time: 100, content: [], source: { kind: 'plugin', plugin: 'compact' }, + provenance: { role: 'inject', label: 'compact' }, + form: null, } as ConversationNode const abandoned = { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 22270c1294..56a9ee90cf 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -21,7 +21,10 @@ import type { SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' +import { + ConversationSession, ConversationSessionHeader, + type ConversationSessionHeaderProps, type ConversationSessionProps, +} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' @@ -35,12 +38,9 @@ import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId - -// Stub of the conversation package's standard locale seat (this spec mounts -// its ConversationSession chrome); answers from the zh dictionary and falls -// back to the key like the real chain. -const tConversation: ConversationSessionProps['t'] = +const tConversation: ConversationSessionHeaderProps['t'] = key => (conversationZh as Record)[key] ?? key + afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. @@ -168,9 +168,6 @@ async function bench(snapshot = historySnapshot(NODES)) { const chatBody = vi.fn(() =>
              ) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) - // 'conversation' inject is an ordering edge; the bench declares the ring - // itself, so a stub satisfies the wait. - ctx.provide('conversation', {}) ctx.provide('sessionHistory', { source: () => history }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -183,7 +180,7 @@ function tabsOf(slots: SlotsService): ViewTab[] { .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! })) } -/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */ +/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, @@ -193,6 +190,13 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() + const views = { + list: () => tabsOf(slots), + subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), + version: () => slots.getVersion('conversation.view'), + } + const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never + const inputActions = { setDraft: vi.fn(), submit: vi.fn() } // Minimal outlet twin: resolve the ring entry by the `only` filter and // render it with the session standard kit (what SlotOutlet does for a // list-kind session slot, minus machinery). @@ -225,27 +229,39 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES ) }) as unknown as ConversationSessionProps['renderSlot'] return render( - children(SID)} - useSession={useSession} - useSessions={emptySessions()} - useWorkspaces={emptyWorkspaces()} - useProjection={(() => undefined)} - useStore={bindSnapshotSelector(chat)} - actions={chat.actions} - renderSlot={renderSlot} - views={{ - list: () => tabsOf(slots), - subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), - version: () => slots.getVersion('conversation.view'), - }} - useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never} - inputActions={{ setDraft: vi.fn(), submit: vi.fn() }} - bindDraftMirror={() => () => {}} - open={vi.fn()} - />, + <> + children(SID)} + useSession={useSession} + useSessions={emptySessions()} + useWorkspaces={emptyWorkspaces()} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + renderSlot={() => null} + views={views} + useInput={useInput} + inputActions={inputActions} + open={vi.fn()} + t={tConversation} + /> + children(SID)} + useSession={useSession} + useSessions={emptySessions()} + useWorkspaces={emptyWorkspaces()} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + renderSlot={renderSlot} + views={views} + useInput={useInput} + inputActions={inputActions} + bindDraftMirror={() => () => {}} + /> + , ) } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 108c3e8127..fee956b683 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 3107793233d18c65b12a9a91a5be85d22acc733a -README.zh.md: 98912ac079a13fa62d5829d7d2469ff63c3da5bb +README.md: bd7313b560e76378e4fff274c99bb976819aebae +README.zh.md: 734a897b9cb9c3469d8f402b13bff4b62753f9b2 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 3107793233..bd7313b560 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -14,7 +14,7 @@ The Session row's Fork action forks at the source's last completed turn, increme Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator; ordinary rows repeat the localized status in their hover card, and both ordinary and search-result rows carry the same text as a visually hidden label for assistive technology. Running uses the blue indicator and its hidden label; an idle row leaves the reserved status slot empty. -Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. +Both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 98912ac079..734a897b9c 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -14,7 +14,7 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器;普通行的悬浮卡片重复显示本地化状态,普通行和搜索结果行则都以相同文本提供面向辅助技术的视觉隐藏标签。运行状态使用蓝色指示器及其隐藏标签;空闲行会保留空的状态槽位。 -两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 +两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。 共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 59574beed3..3af6a26629 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,7 +8,6 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -40,8 +39,8 @@ const NS = 'workspace' * the ui-sidebar / ui-conversation applies, whose activation order relative * to this one is NOT constrained: dshClient.inject edges are informational * (loading/prefetch metadata, never apply sequencing) and neither owner - * provides a waitable service. apply therefore registers via - * declaration-aware deferral instead of assuming order. + * provides a waitable service. apply therefore depends on each slot + * declaration through `slots.inject()` instead of assuming order. */ export const inject = ['slots', 'sessions', 'workspaces', 'locale'] @@ -103,36 +102,25 @@ export function apply(ctx: ClientContext): void { createWorkspace: input => ctx.workspaces.create(input), hooks: { directoryFlow: pickerFlowSource }, }) - // Declaration-aware registration (deferRegistration): each owner's - // declaring apply may activate after this one, and a register into an - // undeclared slot throws; the deferral also re-registers after an HMR - // collapse re-declares the slot. Each registration declares its own - // directory-flow child hole in the same call (declaration = render - // authorization, one table). - ctx.effect(() => { - const deferred = [ - deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () => - ctx.slots.register( - { - name: 'sidebar.workspaces', - children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } }, - store: createWorkspaceViewStore(), - inject: browserInjected, - locale: NS, - }, - WorkspaceBrowser, - )), - deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () => - ctx.slots.register( - { - name: 'conversation.hero.workspace', - children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } }, - inject: pickerInjected, - locale: NS, - }, - WorkspacePicker, - )), - ] - return () => { for (const entry of deferred) entry.dispose() } - }, 'ui-workspace: browser + picker registrations') + // Each registration declares its directory-flow child in the same call; + // slot injection follows both the owner and declaration HMR lifetimes. + ctx.slots.inject('sidebar.workspaces', () => ctx.slots.register( + { + name: 'sidebar.workspaces', + children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } }, + store: createWorkspaceViewStore(), + inject: browserInjected, + locale: NS, + }, + WorkspaceBrowser, + )) + ctx.slots.inject('conversation.hero.workspace', () => ctx.slots.register( + { + name: 'conversation.hero.workspace', + children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } }, + inject: pickerInjected, + locale: NS, + }, + WorkspacePicker, + )) } diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index b9e06a6ae2..d9c70de729 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -49,6 +49,7 @@ export const zh = { 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', 'status.waitingAnswer': '等待回答', + 'status.completed': '已完成', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -109,6 +110,7 @@ export const en = { 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Plan awaiting review', 'status.waitingAnswer': 'Waiting for answer', + 'status.completed': 'Completed', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 836325076b..fb64a0be42 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -173,7 +173,7 @@ function assertNever(value: never): never { /** Session status presentation; pending user interaction outranks the running state. */ function sessionStatus( - node: Pick, + node: Pick, t: RowTranslate, ): { state: StateDotState; label: string } { switch (node.pendingInteraction) { @@ -185,10 +185,11 @@ function sessionStatus( default: return assertNever(node.pendingInteraction) } if (node.running) return { state: 'ongoing', label: t('status.running') } + if (node.completed) return { state: 'done', label: t('status.completed') } return { state: 'done', label: t('status.idle') } } -/** Hover-card body: full title, relative time, and interaction/running/idle status. */ +/** Hover-card body: full title, relative time, and interaction/running/completed/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { const status = sessionStatus(node, t) return ( @@ -251,7 +252,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { > - {status.state !== 'done' && ( + {(status.state !== 'done' || result.completed) && ( <> {status.label} @@ -351,8 +352,11 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork drag.drop(rowHalf(e)) }} > + {/* Pending interactions and running outrank the idle state; a + finished-but-unviewed session shows the green done reminder dot + (cleared by opening the session). */} - {status.state !== 'done' && ( + {(status.state !== 'done' || row.completed) && ( <> {status.label} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 1a9f42504c..90153211ea 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -24,6 +24,8 @@ export interface SessionNode { /** The runtime Session list reports an interaction awaiting this user. */ pendingInteraction?: PendingInteractionStatus running: boolean + /** Finished running while not selected and not yet opened (the green "done" reminder dot). */ + completed: boolean updatedAt: number } @@ -54,6 +56,8 @@ export interface SearchResultNode { /** The runtime Session list reports an interaction awaiting this user. */ pendingInteraction?: PendingInteractionStatus running: boolean + /** Finished running while not selected and not yet opened (the green "done" reminder dot). */ + completed: boolean snippet?: string } @@ -175,6 +179,7 @@ function sessionNode(s: SessionSummary): SessionNode { title: sessionTitle(s), blank: s.blank, running: s.running, + completed: s.completed === true, updatedAt: s.updatedAt, ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), } @@ -330,6 +335,7 @@ export function deriveSearchResults( ...(summary.pendingInteraction === undefined ? {} : { pendingInteraction: summary.pendingInteraction }), + completed: summary.completed === true, ...match === undefined ? {} : { snippet: match.snippet }, } }), diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 1f5387cf43..1c8fd1f703 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -64,6 +64,7 @@ describe('workspace browser rows', () => { title: 'Result title', workspace: 'Workspace context', running: true, + completed: false, snippet: 'matching message excerpt', } render() @@ -85,7 +86,7 @@ describe('workspace browser rows', () => { ] as const)('shows %s ahead of running in search results', (pendingInteraction, label) => { const result: SearchResultNode = { id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project', - pendingInteraction, running: true, + pendingInteraction, running: true, completed: false, } render() const row = screen.getByRole('treeitem') @@ -114,7 +115,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { - id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0, + id: sid('session'), title: 'Session', blank: false, running: true, completed: false, updatedAt: 0, } const onOpen = vi.fn() render( @@ -130,6 +131,38 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) + it('shows the green done dot only on a finished, unviewed session (running wins the slot)', () => { + const renderRow = (over: Partial) => render( + , + ) + const stateDot = (view: ReturnType) => + view.container.querySelector('[data-state]') + // No completion reminder, not running: no state dot at all. + const plain = renderRow({}) + expect(stateDot(plain)).toBeNull() + plain.unmount() + // Completed while unviewed: the green done dot. + const done = renderRow({ completed: true }) + expect(done.container.querySelector('[data-state="done"]')).not.toBeNull() + done.unmount() + // Running wins the slot: the animated ongoing dot, no done dot. + const running = renderRow({ completed: true, running: true }) + expect(running.container.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(running.container.querySelector('[data-state="done"]')).toBeNull() + }) + + it('shows the green done dot on a finished search result row', () => { + render() + expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull() + }) + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { const onRename = vi.fn() const onDelete = vi.fn() @@ -198,7 +231,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + id: sid('s-blank'), title: 'ignored', blank: true, running: false, completed: false, updatedAt: 0, } render() @@ -224,7 +257,7 @@ describe('workspace browser rows', () => { const onFork = vi.fn() const onArchive = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', blank: false, running: false, completed: false, updatedAt: 0, } render() @@ -257,7 +290,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, + id: sid('s1'), title: 'Hovered', blank: false, running: true, completed: false, updatedAt: 0, } render() @@ -288,7 +321,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, - pendingInteraction, running: true, updatedAt: 0, + pendingInteraction, running: true, completed: false, updatedAt: 0, } const view = render() @@ -314,7 +347,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', blank: false, running: false, completed: false, updatedAt: 0, } render() @@ -327,9 +360,26 @@ describe('workspace browser rows', () => { } }) + it('completed hover card shows the Completed status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Done', blank: false, running: false, completed: true, updatedAt: 0, + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + // Row's visually-hidden reminder label plus the hover card's status line. + expect(screen.getAllByText('已完成')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { - id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Drag me', blank: false, running: false, completed: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index a15fffa3d8..fed1c03eec 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -77,6 +77,22 @@ describe('deriveGroups', () => { expect(strayGroups.map(group => group.key)).toEqual(['first']) }) + it('projects the completion reminder into session and search rows (absent = false)', () => { + const done = { ...summary('done', 3), completed: true } + const plain = summary('plain', 2) + const sessions = list(done, plain) + const groups = deriveGroups( + sessions, [workspace('first', ['done', 'plain'])], noArchive, view(['first']), + ) + const doneNode = groups[0]!.sessions.find(session => session.id === done.id)! + const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)! + expect(doneNode.completed).toBe(true) + expect(plainNode.completed).toBe(false) + expect(deriveFlat(sessions, noArchive).find(node => node.id === done.id)!.completed).toBe(true) + const search = deriveSearchResults(sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive, { items: [], hasMore: false }, 10) + expect(search.items[0]?.completed).toBe(true) + }) + it('hides subagent-origin sessions without hiding ordinary forks', () => { const parent = summary('parent', 1) const fork = { ...summary('fork', 2), parentId: parent.id } @@ -259,6 +275,7 @@ describe('deriveSearchResults', () => { workspace: 'Alpha', running: false, pendingInteraction: 'plan-review', + completed: false, snippet: 'title session body excerpt', }, { @@ -266,12 +283,14 @@ describe('deriveSearchResults', () => { title: 'Ordinary title', workspace: 'Needle Workspace', running: false, + completed: false, }, { id: contentHit.id, title: 'content-hit', workspace: 'c', running: false, + completed: false, snippet: 'body needle excerpt', }, ], diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 0bf76975ba..211ac8a920 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -144,9 +144,7 @@ export class BasicCompactService extends CompactService { } ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { signal }, + { agent, signal }, next, ): Promise => { if (!signal.aborted) { @@ -165,7 +163,7 @@ export class BasicCompactService extends CompactService { return next() }) - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (status === 'idle') this.overflowRetries.delete(agent) }) @@ -178,12 +176,9 @@ export class BasicCompactService extends CompactService { }) ctx.on('agent/request-error', async ( - agent, - context, - signal, + { agent, failure, signal }, next, ) => { - const { failure } = context if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() this.overflowAgents.set(agent.session, agent) const target = routedTarget(agent.session) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index fddbaceb80..a8efad741b 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1372,7 +1372,7 @@ describe('default one-shot summarizer', () => { describe('automatic listener and loader composition', () => { function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { return agentEvents(ctx, owner).waterfall( - 'agent/pre-step', [], { turn: 1, step: 1, signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) } @@ -1388,8 +1388,7 @@ describe('automatic listener and loader composition', () => { const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', - { turn, step: 1, provider: 'test', failure, retryPolicy: undefined }, - signal, + { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal }, next, ).then(action => action?.kind === 'retry') } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 132135bd48..471207aa97 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -175,7 +175,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr 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() @@ -217,7 +217,7 @@ function overflowHistorySeed(): SessionEvent[] { describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { it('uses the model actually routed by agent/request for post-step pressure', async () => { const { ctx } = await harness(8) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) try { @@ -315,7 +315,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), provider: 'mock', model: 'mock', })) await ctx.plugin(BasicCompactService, { diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index f3b0945460..bc38eccdb0 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -25,9 +25,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -36,9 +38,11 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts index 0fbf7bcac0..f783cdd59d 100644 --- a/packages/compact/compact-tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -9,6 +9,10 @@ import z from 'schemastery' import { freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session' +// Type-only: the `compact/*` SessionEventMap merges (the shadow-price event). +import type {} from '@deepseek-ai/dsh-compact' +// Type-only: the `ctx.tokenMeter` Context merge for the declared injection. +import type {} from '@deepseek-ai/dsh-token-meter' import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts' import type { PrunedEntry, @@ -38,6 +42,10 @@ interface SnapshotCandidate { /** Deterministic head/middle/tail pruning for current tool-result surface nodes. */ export class ToolResultPruneService extends Service { + // The token meter prices each shadowed node for its logged shadow-price + // event, so pruning genuinely requires the pricing capability. + static inject = ['tokenMeter'] + static Config: z = z.object({ thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars), headChars: z.number().step(1).min(0).default(DEFAULTS.headChars), @@ -116,7 +124,10 @@ export class ToolResultPruneService extends Service { /** * Prune every over-budget tool result from one stable current-surface snapshot. * Each replacement preserves the complete event data except for `content`, - * and points at the shadowed node for durable provenance and replay. + * points at the shadowed node for durable provenance and replay, and is + * immediately preceded by a `compact/prune` shadow-price event pricing the + * shadowed node through the injected token meter, so pure consumers can + * subtract it without per-node state. * @param session - session whose current surface is rewritten. * @returns landed replacements and aggregate Unicode-code-point savings. * @throws when the session rejects a replacement; replacements committed @@ -145,6 +156,14 @@ export class ToolResultPruneService extends Service { content, }] as [typeof result], }) + // Shadow-price protocol: the metering event and its replacement are + // appended synchronously adjacent, so pure consumers subtract the + // shadowed node's heuristic price without retaining per-node state. + session.append('compact/prune', { + shadowedRange: { start: seq, end: seq }, + shadowedSeqs: [seq], + shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message), + }) const replacement = session.append('tool/result', { ...event.data, message, diff --git a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts index db4c29ebdb..fbc4b840c9 100644 --- a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' let root: string | undefined @@ -23,6 +24,7 @@ describe('compact-tool-result-prune real Loader composition', () => { root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', ' thresholdChars: 100', @@ -38,10 +40,9 @@ describe('compact-tool-result-prune real Loader composition', () => { context.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') { - throw new Error(`unexpected Loader import: ${specifier}`) - } - return ToolResultPruneService + if (specifier === '@deepseek-ai/dsh-token-meter') return TokenMeterService + if (specifier === '@deepseek-ai/dsh-compact-tool-result-prune') return ToolResultPruneService + throw new Error(`unexpected Loader import: ${specifier}`) }, } as unknown as NonNullable await context.loader.create({ @@ -60,6 +61,9 @@ describe('compact-tool-result-prune real Loader composition', () => { it('rejects stale config after plugin schema normalization', async () => { context = new Context() + // Satisfy the declared injection first: config normalization runs in the + // service constructor, which a pending fiber never reaches. + await context.plugin(TokenMeterService) await expect(context.plugin(ToolResultPruneService, { maxChars: 100, } as never)).rejects.toThrow(/unknown key "maxChars"/) diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index 9eadf57a66..c5c7171c08 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -9,6 +9,7 @@ import SessionStore, { import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService, { codePointLength, DEFAULTS, @@ -25,9 +26,16 @@ const SMALL: ToolResultPruneConfig = { } function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService { - return new ToolResultPruneService(new Context(), config) + const ctx = new Context() + // Service constructors self-register, so `ctx.tokenMeter` resolves for the + // shadow-price pricing without a full plugin boot. + void new TokenMeterService(ctx) + return new ToolResultPruneService(ctx, config) } +/** Pricing oracle mirroring the service's estimator for expectations. */ +const METER = new TokenMeterService(new Context()) + function appendToolStep( session: Session, turn: number, @@ -203,6 +211,18 @@ describe('ToolResultPruneService session transaction', () => { sourceEventSeqs: [originalSeq], }) expect(session.surface.nodes).not.toContain(originalSeq) + + // Shadow-price protocol: the metering event sits directly before the + // replacement and prices the shadowed node with the shared estimator. + if (original.type !== 'tool/result') throw new Error('original is not a tool/result') + expect(session.events[entry.replacementSeq - 1]).toMatchObject({ + type: 'compact/prune', + data: { + shadowedRange: { start: originalSeq, end: originalSeq }, + shadowedSeqs: [originalSeq], + shadowedTokenCount: METER.estimateMessage(original.data.message), + }, + }) }) it('prunes multiple results, skips short ones, and converges in one pass', () => { @@ -240,6 +260,7 @@ describe('ToolResultPruneService session transaction', () => { await ctx.plugin(SessionStore) await ctx.plugin(InvariantService) await ctx.plugin(SessionInvariant) + await ctx.plugin(TokenMeterService) const prune = new ToolResultPruneService(ctx, SMALL) const session = ctx.sessions.create(SessionId('invariants')) appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) diff --git a/packages/compact/compact-tool-result-prune/tsconfig.json b/packages/compact/compact-tool-result-prune/tsconfig.json index a6c2e5124b..be5cb5b66c 100644 --- a/packages/compact/compact-tool-result-prune/tsconfig.json +++ b/packages/compact/compact-tool-result-prune/tsconfig.json @@ -10,7 +10,9 @@ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, + { "path": "../../llm/token-meter" }, { "path": "../../core/session" }, + { "path": "../compact" }, { "path": "../../support/invariants" } ] } diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 173366bc4b..4a856a6848 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -20,8 +20,11 @@ declare module '@deepseek-ai/dsh-session' { /** * Provenance record of a completed summarization — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement - * is performed by a subsequent `user/message` event that shadows the - * compacted range. + * is performed by the immediately following `user/message` event that + * shadows the compacted range. That adjacency is contractual — the + * shadowed pricing fields are the replacement's shadow price, so a + * consumer may pair a replacement with the metering event directly + * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { summary: ContentBlock[] @@ -49,6 +52,23 @@ declare module '@deepseek-ai/dsh-session' { * matches `compact/start`; `error` records an unsuccessful attempt. */ 'compact/end': { turn: number | null; error?: string } + /** + * Shadow price of one model-free prune replacement — log-only, no + * surfaceOp. The shared shadow-price protocol: a surface `replace` event + * is priced by the metering event immediately before it (`compact/summary` + * for a summarizing compaction, this event for a prune), which states the + * heuristic token price of the exact replaced range so a pure consumer + * can subtract it without retaining per-node prices. The replacement MUST + * be appended synchronously right after this event. + */ + 'compact/prune': { + /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */ + shadowedRange: { start: number; end: number } + /** The seqs of all shadowed surface nodes, in surface order. */ + shadowedSeqs: number[] + /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */ + shadowedTokenCount: number + } } } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index d368c2e1e5..01a6965027 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -199,6 +199,7 @@ export class SessionReferenceService extends Service { const prompt = renderPrompt(rendered.map(source => source.data)) const source: SessionReferenceSource = { kind: 'session-reference', + form: 'recall', version: 1, references: rendered.map((source, index) => ({ sessionId: source.data.sessionId, diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 78df17058d..0693e0d212 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -6,6 +6,8 @@ import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' /** Durable provenance for one prepared cross-session context. */ export interface SessionReferenceSource { kind: 'session-reference' + /** Material lifted out of another session's log (`recall` context form). */ + form: 'recall' version: 1 references: { sessionId: string diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index ff939219aa..5e95beb2b2 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -157,9 +157,7 @@ export function apply(ctx: Context, config: Config): void { const resolvedTimeZone = formatter.resolvedOptions().timeZone ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { turn, step, signal }, + { agent, turn, step, signal }, next, ): Promise => { const decision = await next() @@ -174,13 +172,14 @@ export function apply(ctx: Context, config: Config): void { const previous = step === 1 ? precedingMessageTime(agent) : precedingStepContextTime(agent, turn) + const text = renderText(now, turn, step, previous, formatter, resolvedTimeZone) return { kind: 'enter', messages: [ ...decision.messages, createUserMessage({ - content: [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], - source: { kind: 'plugin', plugin: name }, + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, }), ], } diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index a4aa6bc3a2..33d63cf843 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -56,7 +56,14 @@ describe('time-context through a real headless cordis.yml', () => { for (let index = 0; index < contexts.length; index += 1) { expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq) expect(contexts[index]!.surfaceOp).toBe('append') - expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + // `snapshot` form: one named contribution whose text is exactly what the + // model read, so a consumer attributes it without re-splitting prose. + expect(contexts[index]!.data.source).toMatchObject({ + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: [{ name: 'time-context' }], + }) } const contextText = contexts.map(event => event.data.content .filter(block => block.type === 'text') diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 1b85595bb9..a0ffb9e619 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -82,8 +82,7 @@ async function fire( ): Promise { 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') { @@ -161,7 +160,19 @@ describe('durable step context', () => { const event = session.events.at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') - expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + // The reading is a `snapshot`-form context: one named contribution whose + // text is exactly what the model read, so a consumer attributes it without + // re-splitting prose. + expect(event.data.source).toEqual({ + kind: 'plugin', + plugin: 'time-context', + form: 'snapshot', + sections: [{ + name: 'time-context', + text: 'Time sampled while preparing turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' + + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', + }], + }) expect(event.surfaceOp).toBe('append') }) @@ -366,7 +377,7 @@ describe('real agent-loop request history', () => { ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => { const adapter = new ScriptedAdapter([textResponse('unused')]) const ctx = await loopHarness(adapter) - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel({ kind: 'user' }) return next() diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 130efb919b..e79ad89b6b 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -216,9 +216,7 @@ export function apply(ctx: Context, config: Config): void { validateRefreshInterval(refreshIntervalMs) ctx.on('agent/pre-step', async ( - agent: Agent, - _messages, - { turn, step, signal }, + { agent, turn, step, signal }, next, ): Promise => { const decision = await next() @@ -234,12 +232,13 @@ export function apply(ctx: Context, config: Config): void { if (location === undefined) return decision const state = renderState(location) if (previous !== undefined && previous.state === state) return decision + const text = renderReading(location, turn) return { kind: 'enter', messages: [ createUserMessage({ - content: [{ type: 'text', text: renderReading(location, turn) }], - source: { kind: 'plugin', plugin: name }, + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name, text }] }, }), ...decision.messages, ], diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index e7b501d462..12bc37b9ce 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -138,8 +138,7 @@ async function fire( ): Promise { 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') { @@ -170,7 +169,14 @@ describe('tmux-context injection', () => { ]) const event = session.events.at(-1) if (event?.type !== 'user/message') throw new Error('missing tmux context') - expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' }) + // `snapshot` form: one named contribution carrying exactly the reading the + // model saw, so a consumer attributes it without re-splitting prose. + expect(event.data.source).toMatchObject({ + kind: 'plugin', + plugin: 'tmux-context', + form: 'snapshot', + sections: [{ name: 'tmux-context' }], + }) expect(event.surfaceOp).toBe('append') }) diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index be9e2aa806..f07ccf46f8 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -147,6 +147,7 @@ export function apply(ctx: Context, config: Config): void { content, source: { kind: 'workspace-instructions', + form: 'instructions', ...desiredBaseline ? { baseline: true } : {}, changes, }, @@ -212,9 +213,7 @@ export function apply(ctx: Context, config: Config): void { } ctx.on('agent/pre-step', async ( - agent: Agent, - messages, - { step, signal }, + { agent, messages, step, signal }, next, ): Promise => { const decision = await next() diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index 960ee48ccb..d0176eef8f 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -36,6 +36,8 @@ export const name = 'workspace-context' /** Durable provenance and reconciliation facts for one workspace context. */ export interface WorkspaceInstructionSource { kind: 'workspace-instructions' + /** Every workspace context carries instructions read out of a file (the `instructions` context form). */ + form: 'instructions' /** Marks the complete startup/resume baseline rather than a later delta. */ baseline?: true changes: WorkspaceInstructionChange[] @@ -77,7 +79,7 @@ export interface ReconciledInstructionContext { function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage { return createUserMessage({ content: [{ type: 'text', text }], - source: { kind: 'workspace-instructions', changes }, + source: { kind: 'workspace-instructions', form: 'instructions', changes }, }) } diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 6a8095da0e..c1151428e2 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -57,7 +57,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { 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/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index f584b44404..24c7680a7c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -209,8 +209,7 @@ async function workspaceContextOf(agent: Agent): Promise { async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], - { turn: 1, step: 1, signal: testToolSignal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal }, async () => ({ kind: 'enter' as const, messages: [] }), ) } @@ -245,15 +244,13 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const claimed = agent.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 2, signal }, + { messages: claimed, turn: 1, step: 2, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) const entered = decision.kind === 'enter' ? decision.messages : [] @@ -916,6 +913,7 @@ describe('workspace context request injection', () => { role: 'user', source: { kind: 'workspace-instructions', + form: 'instructions', baseline: true, changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }, @@ -968,8 +966,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const inserted = original.inbox.nextStep[0] @@ -978,12 +975,11 @@ describe('workspace context request injection', () => { await fiber.dispose() await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') @@ -1015,8 +1011,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1026,12 +1021,11 @@ describe('workspace context request injection', () => { await fiber.dispose() await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', - staleClaim, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), ) @@ -1070,8 +1064,7 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await agentEvents(originalCtx, original).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) const stale = original.inbox.nextStep[0] @@ -1081,12 +1074,11 @@ describe('workspace context request injection', () => { if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) - agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume') + agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( 'agent/pre-step', - claimed, - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: claimed }), ) @@ -1113,6 +1105,7 @@ describe('workspace context request injection', () => { content: [{ type: 'text', text: 'stale nested instructions' }], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }], }, }), { @@ -1146,6 +1139,7 @@ describe('workspace context request injection', () => { content: [{ type: 'text', text: 'stale nested instructions' }], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }], }, }), { @@ -1188,8 +1182,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [prompt], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1246,8 +1239,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1358,8 +1350,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - [prompt], - { turn: 2, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }), ) @@ -1397,7 +1388,7 @@ describe('workspace context request injection', () => { const resumed = stubAgent(root, [...original.session.events]) // Resume announces its lifecycle start before the first step. - agentEvents(ctx, resumed).emit('agent/session-start', 'resume') + agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) await composeBaselinePrefix(ctx, resumed) const baselines = baselineEvents(resumed) @@ -1445,7 +1436,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - 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 { @@ -1719,8 +1710,7 @@ describe('workspace context request injection', () => { const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( 'agent/pre-step', - [], - { turn: 1, step: 1, signal: controller.signal }, + { messages: [], turn: 1, step: 1, signal: controller.signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) @@ -2262,7 +2252,7 @@ describe('dynamic nested workspace context injection', () => { expect(result.isError).toBe(false) expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' }) const queuedSource = ((await syncedWorkspaceContext(ctx, agent))).source - expect(queuedSource).toMatchObject({ kind: 'workspace-instructions' }) + expect(queuedSource).toMatchObject({ kind: 'workspace-instructions', form: 'instructions' }) expect(queuedSource.kind === 'workspace-instructions' && queuedSource.changes.some(change => change.action === 'set' && change.scope === sk('pkg', 'AGENTS.md') @@ -2605,6 +2595,7 @@ describe('dynamic nested workspace context injection', () => { expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([ @@ -2714,7 +2705,7 @@ describe('dynamic nested workspace context injection', () => { if (previous === undefined) throw new Error('missing AGENTS.md baseline state') const authoritative = createUserMessage({ content: [{ type: 'text', text: 'nested rule' }], - source: { kind: 'workspace-instructions', changes: [previous] }, + source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] }, }) if (authority === 'visible') { agent.session.append('user/message', authoritative, { surfaceOp: 'append' }) @@ -2740,6 +2731,7 @@ describe('dynamic nested workspace context injection', () => { content: [{ type: 'text', text: 'pending baseline duplicate' }], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], }, })], @@ -2783,7 +2775,7 @@ describe('dynamic nested workspace context injection', () => { if (previous === undefined) throw new Error('missing AGENTS.md baseline state') const authoritative = createUserMessage({ content: [{ type: 'text', text: 'repo rule' }], - source: { kind: 'workspace-instructions', changes: [previous] }, + source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] }, }) agent.session.append('user/message', authoritative, { surfaceOp: 'append' }) const resolved = resolveConfig({ dshHome: home, maxBytes: 65536, localInstructionFileCandidates: [] }) @@ -2907,6 +2899,7 @@ describe('dynamic nested workspace context injection', () => { expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toBe([ @@ -3309,6 +3302,7 @@ describe('dynamic nested workspace context injection', () => { ], source: { kind: 'workspace-instructions', + form: 'instructions', changes: [ null, { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') }, @@ -3462,6 +3456,7 @@ describe('dynamic nested workspace context injection', () => { expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' }) expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions', + form: 'instructions', changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) expect(blocksText(((await syncedWorkspaceContext(ctx, agent))).content)).toContain('nested package rule') @@ -3904,8 +3899,7 @@ describe('workspace context inbox synchronization', () => { controller.abort(new Error('abort pre-step reconciliation')) await expect(agentEvents(ctx, agent).waterfall( - 'agent/pre-step', [], - { turn: 1, step: 1, signal: controller.signal }, + 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal }, async () => ({ kind: 'enter' as const, messages: [] }), )).rejects.toThrow('abort pre-step reconciliation') @@ -4015,8 +4009,7 @@ describe('workspace context inbox synchronization', () => { const downstream = { kind: 'enter' as const, messages: claimed } const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', claimed, - { turn: 1, step: 1, signal: testToolSignal }, + 'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal }, async () => downstream, ) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 40b354fce3..32ac294f8b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -425,13 +425,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Describe provider routes with a registered adapter.\n * @returns detached provider metadata in registration order.\n */', }, { - signature: 'registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void', - jsDoc: '/**\n * Declare provider routes an adapter plugin can activate through\n * configuration. Registration is all-or-nothing: an empty list, invalid\n * entry, or a provider already declared by any registration throws\n * `LlmError` without registering the rest. Disposed with the fiber.\n * @param entries - every configurable provider this plugin owns.\n * @returns the disposer that withdraws all of them.\n */', + signature: 'registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle', + jsDoc: '/**\n * Declare provider routes an adapter plugin can activate through\n * configuration. Registration is all-or-nothing: an empty list, invalid\n * entry, or a provider already declared by any registration throws\n * `LlmError` without registering the rest. Disposed with the fiber.\n * @param entries - every configurable provider this plugin owns.\n * @returns a handle that withdraws all of them, and can atomically replace them.\n */', }, { signature: 'listConfigurableProviders(): LlmConfigurableProvider[]', jsDoc: '/**\n * List every declared configurable provider, registered or dormant.\n * @returns detached directory entries in declaration order.\n */', }, + { + signature: 'registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void', + jsDoc: '/**\n * Offer to interrogate provider endpoints on behalf of the settings\n * namespace this plugin owns. The namespace is the key because that is what\n * a configuration surface already holds from the configurable-provider\n * directory, and because a provider being *added* has no route to name yet.\n * Disposed with the fiber.\n * @param settingsNs - the namespace whose profiles this discovery serves.\n * @param discover - interrogates one endpoint; must honor `request.signal`.\n * @returns the disposer that withdraws the offer.\n */', + }, + { + signature: 'async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise', + jsDoc: '/**\n * Interrogate one provider endpoint for the models it advertises. The\n * request describes a draft, not a stored route, so nothing here reads or\n * writes settings or credentials — the caller owns both, and the reply is\n * candidate metadata a surface may offer for adoption.\n * @param settingsNs - namespace whose registered discovery serves this draft.\n * @param request - the endpoint, protocol, and one-shot credential to use.\n * @returns the advertised models, deduplicated in endpoint order.\n */', + }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', jsDoc: '/**\n * Resolve the retry policy captured when one provider route was registered.\n * @param provider - registered provider route to inspect.\n * @returns the provider-owned policy, with normal defaults already resolved.\n */', @@ -1044,7 +1052,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'estimateMessage(message: Message): number', - jsDoc: '/**\n * Heuristically price one model-visible message.\n * @param message - message to price without mutation.\n * @returns content and role-framing tokens under the fixed service heuristic.\n */', + jsDoc: '/**\n * Heuristically price one model-visible message (instance face of the pure\n * `estimateMessage` export from `estimate.ts`).\n * @param message - message to price without mutation.\n * @returns content and role-framing tokens under the fixed service heuristic.\n */', }, ], }, @@ -1062,7 +1070,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'pruneSession(session: Session): PruneResult', - jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */', + jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * points at the shadowed node for durable provenance and replay, and is\n * immediately preceded by a `compact/prune` shadow-price event pricing the\n * shadowed node through the injected token meter, so pure consumers can\n * subtract it without per-node state.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */', }, ], }, @@ -1217,92 +1225,92 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent-loop/config-start-failed', mode: 'emit', - signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', - jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', + signature: '\'agent-loop/config-start-failed\'(payload: { sessionId: SessionId; error: unknown }): void', + jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param payload.sessionId - exact shared agent/session identity that failed startup.\n * @param payload.error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, { name: 'agent/created', mode: 'emit', - signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/created\'(this: Scoped, payload: { agent: Agent }): void', + jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param payload.agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A fully configured agent and live session were published.', }, { name: 'agent/disposed', mode: 'emit', - signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/disposed\'(this: Scoped, payload: { agent: Agent }): void', + jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param payload.agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.', }, { name: 'agent/error', mode: 'emit', - signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void', - jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/error\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void', + jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param payload.agent - the agent whose turn errored.\n * @param payload.turn - the turn in which the failure surfaced.\n * @param payload.step - the step at which the failure surfaced.\n * @param payload.error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, { name: 'agent/inbox/claimed', mode: 'emit', - signature: '\'agent/inbox/claimed\'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void', - jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param agent - the agent whose inbox changed.\n * @param event - the claimed message and owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/claimed\'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void', + jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the claimed message.\n * @param payload.turn - the owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message left the inbox inside its open turn.', }, { name: 'agent/inbox/discarded', mode: 'emit', - signature: '\'agent/inbox/discarded\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', - jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discarded\'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void', + jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message was discarded from the live inbox.', }, { name: 'agent/inbox/inserted', mode: 'emit', - signature: '\'agent/inbox/inserted\'(this: Scoped, agent: Agent, event: { message: UserMessage }): void', - jsDoc: '/**\n * One message entered the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/inserted\'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void', + jsDoc: '/**\n * One message entered the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'One message entered the live inbox.', }, { name: 'agent/pre-step', mode: 'waterfall', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise', - jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param agent - the agent proposing the step.\n * @param messages - messages removed from the inbox for this step.\n * @param context - proposed turn and step coordinates plus cancellation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/pre-step\'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param payload.agent - the agent proposing the step.\n * @param payload.messages - messages removed from the inbox for this step.\n * @param payload.turn - the turn that will own the step.\n * @param payload.step - the step proposed by the loop.\n * @param payload.signal - the current turn\'s cancellation signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Reject a proposed step or replace the messages that enter it.', }, { name: 'agent/request', mode: 'waterfall', - signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', + signature: '\'agent/request\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param payload.agent - the agent making the model call.\n * @param payload.turn - the open turn number.\n * @param payload.step - the step whose request this is.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', summary: 'Replace the frozen call configuration.', }, { name: 'agent/request-error', mode: 'waterfall', - signature: '\'agent/request-error\'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param context - request coordinates, provider, normalized failure, and serving policy.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request-error\'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise', + jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param payload.agent - the agent whose request failed.\n * @param payload.turn - the turn containing the failed request.\n * @param payload.step - the step containing the failed request attempt.\n * @param payload.provider - the provider selected for the failed request.\n * @param payload.failure - serializable facts normalized at the final adapter boundary.\n * @param payload.retryPolicy - the policy of the adapter registration that served the failed request.\n * @param payload.signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Handle one failed model-request attempt before the loop retries or closes its step.', }, { name: 'agent/session-start', mode: 'emit', - signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', - jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/session-start\'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void', + jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param payload.agent - the agent whose session lifecycle began.\n * @param payload.source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The session lifecycle began, once before the first turn.', }, { name: 'agent/status', mode: 'emit', - signature: '\'agent/status\'(this: Scoped, agent: Agent, status: AgentStatus): void', - jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/status\'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param payload.agent - the agent whose status flipped.\n * @param payload.status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`).', }, { name: 'agent/turn-stopping', mode: 'serial', - signature: '\'agent/turn-stopping\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void', - jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + signature: '\'agent/turn-stopping\'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void', + jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param payload.agent - the agent whose turn is at its stop boundary.\n * @param payload.turn - the turn about to close.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', }, { @@ -1357,8 +1365,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'goal/changed', mode: 'emit', - signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, agent: Agent, change: GoalChanged): void', - jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', + signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped, payload: { agent: Agent; change: GoalChanged }): void', + jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param payload.agent - agent whose session owns the goal.\n * @param payload.change - fresh current projection or clear tombstone.\n * @mode emit\n */', summary: 'Goal mutation accepted by one live agent.', }, { @@ -1793,6 +1801,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ContentBlockType', declaration: 'export type ContentBlockType = keyof ContentBlockMap;', }, + { + name: 'ContextFormed', + declaration: 'export type ContextFormed = {\n readonly form?: never;\n} | {\n readonly form: \'instructions\';\n} | {\n readonly form: \'catalog\';\n} | {\n readonly form: \'snapshot\';\n readonly sections: readonly ContextSnapshotSection[];\n} | {\n readonly form: \'notice\';\n readonly summary: string;\n} | {\n readonly form: \'relay\';\n} | {\n readonly form: \'recall\';\n};', + }, + { + name: 'ContextSnapshotSection', + declaration: 'export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n}', + }, { name: 'ContinuableCreateRequest', declaration: 'export interface ContinuableCreateRequest {\n readonly sessionId: SessionId;\n readonly parent: Agent;\n readonly signal: AbortSignal;\n}', @@ -1869,6 +1885,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryPickerNativeCapability', declaration: 'export interface DirectoryPickerNativeCapability {\n kind: \'native\';\n pick(signal: AbortSignal): Promise;\n}', }, + { + name: 'DirectoryRegistrationHandle', + declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', + }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', @@ -2073,6 +2093,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmConfigurableProvider', declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n}', }, + { + name: 'LlmDiscoveredModel', + declaration: 'export interface LlmDiscoveredModel {\n id: string;\n name?: string;\n contextWindow?: number;\n maxTokens?: number;\n}', + }, { name: 'LlmFailure', declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}', @@ -2081,6 +2105,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmModelContext', declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}', }, + { + name: 'LlmModelDiscoveryRequest', + declaration: 'export interface LlmModelDiscoveryRequest {\n provider?: string;\n baseURL?: string;\n api?: string;\n apiKey?: string;\n signal?: AbortSignal;\n}', + }, { name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', @@ -2119,7 +2147,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'MessageSourceMap', - declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', + declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', }, { name: 'ModelMessageSource', @@ -2627,7 +2655,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SettingsRegisterOptions', - declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n}', + declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', }, { name: 'SettingsScope', diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 01a748a284..488ab996b3 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -28,7 +28,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/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1931d6efb0..cfac8262c2 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,13 +7,15 @@ import type { Agent, AgentCancelCause, + AgentEventDispatch, AgentOptions, AgentStatus, CancelOptions, InboxTarget, + PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, @@ -27,7 +29,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' -import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type { Context } from 'cordis' import { RuntimeContextProjection } from './runtime-context.ts' @@ -68,6 +70,9 @@ export class ReactLoopAgent implements Agent { readonly scope: Scope readonly ctx: Context + /** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */ + private readonly dispatch: AgentEventDispatch + /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false private readonly runtimeContext: RuntimeContextProjection @@ -78,10 +83,11 @@ export class ReactLoopAgent implements Agent { public readonly options: AgentOptions, public readonly session: Session, ) { + this.dispatch = agentEvents(loopCtx, this) this.inbox = new Inbox(session, { - inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) }, - discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) }, - claimed: (message, turn) => { emitAgentEvent(loopCtx, this, 'agent/inbox/claimed', { message, turn }) }, + inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) }, + discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, + claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, }) const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.phase = { kind: 'idle', lastTurn } @@ -100,7 +106,7 @@ export class ReactLoopAgent implements Agent { this.phase = next const status = this.status if (status !== previousStatus) { - emitAgentEvent(this.loopCtx, this, 'agent/status', status) + this.dispatch.emit('agent/status', { status }) } } @@ -178,7 +184,7 @@ export class ReactLoopAgent implements Agent { private throwError(error: unknown): never { const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn const step = this.phase.kind === 'running' ? this.phase.step : 0 - emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) + this.dispatch.emit('agent/error', { turn, step, error }) throw error } @@ -202,10 +208,11 @@ export class ReactLoopAgent implements Agent { const claimed = this.inbox.claim(target, position.turn) const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() - const context = this.runtimeContext.project(renderContextSnapshot(assembly)) - const decision = await agentEvents(this.loopCtx, this).waterfall( - 'agent/pre-step', claimed, { ...position, signal }, - () => Promise.resolve({ + const sections = renderContextSections(assembly) + const context = this.runtimeContext.project(joinContextSections(sections), sections) + const decision = await this.dispatch.waterfall( + 'agent/pre-step', { messages: claimed, ...position, signal }, + (): Promise => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context], }), @@ -265,7 +272,7 @@ export class ReactLoopAgent implements Agent { } signal.throwIfAborted() if (turnEnds && this.inbox.nextStep.length === 0) { - await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) + await this.dispatch.serial('agent/turn-stopping', { turn, signal }) signal.throwIfAborted() } if (turnEnds && this.inbox.nextStep.length === 0) break @@ -322,14 +329,15 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { - const action = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request-error', this, { + const action = await this.dispatch.waterfall( + 'agent/request-error', { turn, step, provider: request.provider, failure: finish.failure, retryPolicy: preparedCall?.retryPolicy, - }, signal, + signal, + }, () => Promise.resolve(undefined), ) signal.throwIfAborted() @@ -404,8 +412,8 @@ export class ReactLoopAgent implements Agent { ...maxTokens === undefined ? {} : { maxTokens }, }, )) - const proposedConfig = await this.loopCtx.waterfall( - agentCarrier(this), 'agent/request', this, turn, step, signal, + const proposedConfig = await this.dispatch.waterfall( + 'agent/request', { turn, step, signal }, () => Promise.resolve(seedConfig), ) signal.throwIfAborted() diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3f77973d92..a589f3c131 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -175,11 +175,11 @@ declare module 'cordis' { * Consumers that buffer work for the configured identity use this * transient signal to reject that work instead of waiting forever. Normal * factory teardown suppresses failures from the cancelled startup attempt. - * @param sessionId - exact shared agent/session identity that failed startup. - * @param error - persistence, setup, or publication failure. + * @param payload.sessionId - exact shared agent/session identity that failed startup. + * @param payload.error - persistence, setup, or publication failure. * @mode emit */ - 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void + 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void } } @@ -351,7 +351,7 @@ export class AgentLoop extends Service implements AgentFactory { ): void { if (!this.ownership.isActive()) return this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`) - const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] + const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) @@ -400,7 +400,7 @@ export class AgentLoop extends Service implements AgentFactory { released.resolve() } } - const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) + const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() }) const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) try { checkReleased() @@ -525,7 +525,7 @@ export class AgentLoop extends Service implements AgentFactory { // A synchronous announce/session-start listener may have started // teardown; the machine is already live (delivery works from the // session-start seam), so only the liveness recheck is owed. - emitAgentEvent(loopCtx, agent, 'agent/session-start', source) + emitAgentEvent(loopCtx, agent, 'agent/session-start', { source }) assertLive() return { agent, dispose } }, diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index 26c33a18c5..8cf4a41403 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -4,6 +4,7 @@ */ import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' import type { Context } from 'cordis' @@ -57,15 +58,19 @@ export class RuntimeContextProjection { /** * Create an uncommitted snapshot only when the retained value differs. * @param current - fully rendered dynamic context. + * @param sections - named contributions that formed the current snapshot. * @returns a candidate user message, or `undefined` when no update is needed. */ - project(current: string): UserMessage | undefined { + project(current: string, sections: readonly ContextSnapshotSection[]): UserMessage | undefined { if (this.retained === undefined && current.length === 0) return const snapshot = current.length === 0 ? CLEARED : current if (this.retained?.text === snapshot) return return createUserMessage({ content: [{ type: 'text', text: snapshot }], - source: { kind: 'plugin', plugin: SOURCE }, + // The cleared marker has no contributions left to attribute. + source: sections.length === 0 + ? { kind: 'plugin', plugin: SOURCE } + : { kind: 'plugin', plugin: SOURCE, form: 'snapshot', sections }, }) } } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index bbc3e548e2..5af6e70fe0 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: LlmAdapter): 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() @@ -164,18 +164,18 @@ describe('AgentLoop initiator scope', () => { if (context.agent === agent) capture(context.signal) return next() }) - ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => { if (subject === agent) { expect(ctx.agents.requireInitiator()).toBe(agent) preStepSignals.push(signal) } return next() }) - ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { + ctx.on('agent/request', async ({ agent: subject, signal }, next) => { if (subject === agent) capture(signal) return next() }) - ctx.on('agent/turn-stopping', (subject, _turn, signal) => { + ctx.on('agent/turn-stopping', ({ agent: subject, signal }) => { if (subject === agent) capture(signal) }) ctx.tools.register(defineContentToolFixture({ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 1692f19291..7ac8a0dd64 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -60,17 +60,17 @@ describe('Agent', () => { ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start') }) - ctx.on('agent/inbox/inserted', (subject, event) => { - if (subject === agent) inserted.push(event) + ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { + if (subject === agent) inserted.push({ message }) }) - ctx.on('agent/inbox/claimed', (subject, event) => { + ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => { if (subject === agent) { lifecycle.push('agent/inbox/claimed') - claimed.push(event) + claimed.push({ message, turn }) } }) - ctx.on('agent/inbox/discarded', (subject, event) => { - if (subject === agent) discarded.push(event) + ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => { + if (subject === agent) discarded.push({ message }) }) const context = createUserMessage({ content: [{ type: 'text', text: 'discard me' }], @@ -114,7 +114,7 @@ describe('Agent', () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) @@ -152,7 +152,7 @@ describe('Agent', () => { const ctx = await harness(new MockAdapter([textResponse('ok')])) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/status', (_subject, status) => { + ctx.on('agent/status', ({ status }) => { throw new Error(`bad ${status} listener`) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 87f2d0991d..5c0deed621 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -40,7 +40,7 @@ function send(agent: Agent, text: string) { /** Resolve on the agent's next idle transition (event-based, not status poll). */ 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() } }) }) @@ -156,7 +156,7 @@ describe('Agent.cancel()', () => { const running = Promise.withResolvers() let disposalDone: Promise | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'running') return disposalDone = handle.dispose() running.resolve(undefined) @@ -200,7 +200,7 @@ describe('Agent.cancel()', () => { const replacementRegistered = Promise.withResolvers() let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return send(agent, 'cancelled replacement') replacementObservation = agent.whenIdle().then(() => ({ @@ -239,7 +239,7 @@ describe('Agent.cancel()', () => { const replacementRegistered = Promise.withResolvers() let replacementIdle: Promise | undefined - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return send(agent, 'cancelled replacement') agent.cancel({ kind: 'user' }) @@ -440,7 +440,7 @@ describe('Agent.cancel()', () => { }) let cancelled = false - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (subject === agent && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) @@ -465,7 +465,7 @@ describe('Agent.cancel()', () => { // durable turn-start commit and must drop the reserved work. let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'running') agent.cancel({ kind: 'user' }) }) @@ -485,7 +485,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let replaced = false - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'running' || replaced) return replaced = true agent.cancel({ kind: 'user' }) @@ -664,7 +664,7 @@ describe('Agent.cancel()', () => { switch (stage) { case 'pre-step': - ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) @@ -679,13 +679,13 @@ describe('Agent.cancel()', () => { }) break case 'request': - ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { + ctx.on('agent/request', async ({ agent: subject, signal }, next) => { if (subject === agent) await blockUntilAbort(signal) return next() }) break case 'stopping': - ctx.on('agent/turn-stopping', async (subject, _turn, signal) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, signal }) => { if (subject === agent) await blockUntilAbort(signal) }) break diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index c0be39e41b..74608e5f1a 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -19,7 +19,7 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: 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() } }) }) @@ -170,7 +170,7 @@ describe('config-driven session id', () => { await cleanupStarted.promise expect(first.status).toBe('idle') const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const secondLoop = await ctx.plugin(AgentLoop, config) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.agents.get(sessionId)).toBe(first) @@ -234,7 +234,7 @@ describe('config-driven session id', () => { const failures: { sessionId: SessionId; error: unknown }[] = [] ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never) - ctx.on('agent-loop/config-start-failed', (sessionId, error) => { + ctx.on('agent-loop/config-start-failed', ({ sessionId, error }) => { failures.push({ sessionId, error }) }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) @@ -274,7 +274,7 @@ describe('config-driven session id', () => { // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. // oxlint-disable-next-line typescript/prefer-promise-reject-errors ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) - ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) @@ -307,7 +307,7 @@ describe('config-driven session id', () => { const released = vi.fn() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const loop = await ctx.plugin(AgentLoop, { agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], @@ -479,7 +479,7 @@ describe('startup reporting after factory teardown', () => { gate.promise.catch(() => undefined) vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise) const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const loop = await ctx.plugin(AgentLoop, { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ad3a3ef507..b6540d9912 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -40,7 +40,7 @@ async function harness(adapter: MockAdapter) { 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() @@ -191,7 +191,7 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([textResponse('must not run')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent) return next() return Promise.resolve({ kind: 'enter', messages: [] }) }) @@ -288,7 +288,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'leave an unmatched historical call') await waitForIdle(ctx, agent) - const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => { + const disposeInjection = ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => { const decision = await next() if (subject === agent && turn === 2 && decision.kind === 'enter') { disposeInjection() @@ -382,7 +382,7 @@ describe('disposal leaves the two-state status contract balanced', () => { const statuses: string[] = [] const reasons: TurnEndReason[] = [] - ctx.on('agent/status', (_agent, status) => void statuses.push(status)) + ctx.on('agent/status', ({ status }) => void statuses.push(status)) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) send(agent, 'go') @@ -411,7 +411,7 @@ describe('disposal leaves the two-state status contract balanced', () => { agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - ctx.on('agent/status', (_agent, status) => { + ctx.on('agent/status', ({ status }) => { if (status === 'idle') throw new Error('broken status listener') }) @@ -457,7 +457,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { return { ...await next(), provider: 'mock', model: 'mock' } }) @@ -540,7 +540,7 @@ describe('turn numbering continues across seeded sessions', () => { ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) await new Promise((resolve) => { - ctx2.on('agent/status', (subject, status) => { + ctx2.on('agent/status', ({ agent: subject, status }) => { if (subject === forked && status === 'idle') resolve() }) }) @@ -586,7 +586,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () const reasons: TurnEndReason[] = [] const errors: unknown[] = [] - ctx.on('agent/error', (_agent, turn, step, error) => { + ctx.on('agent/error', ({ turn, step, error }) => { expect({ turn, step }).toEqual({ turn: 1, step: 1 }) errors.push(error) }) @@ -710,7 +710,7 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -743,7 +743,7 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -800,7 +800,7 @@ describe('turn and step boundary recovery', () => { } }) const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -893,14 +893,14 @@ describe('turn and step boundary recovery', () => { }, { inject: ['agentLoop'] })) let threw = false - ctx.on('agent/pre-step', (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', (_payload, next) => { if (threw) return next() threw = true void fiber.dispose() throw new Error('boom pre-step during disposal') }) const errorEmits: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errorEmits.push(error) }) @@ -926,7 +926,7 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -959,7 +959,7 @@ describe('turn and step boundary recovery', () => { if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -1000,7 +1000,7 @@ describe('turn and step boundary recovery', () => { if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') } }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -1215,7 +1215,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { await blocker return next() }) @@ -1261,7 +1261,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await mountInvariants(ctx) ctx.llm.registerAdapter(['mock'], adapter) - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { await blocker return next() }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 273ef022fd..617c305071 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter) { 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() @@ -120,7 +120,7 @@ describe('thrown-value propagation', () => { }) const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + ctx.on('agent/error', ({ error }) => void errors.push(error)) send(agent, 'fails before turn start') send(agent, 'survives as the next item') @@ -143,7 +143,7 @@ describe('thrown-value propagation', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!threwOnce) { threwOnce = true throw { code: 500 } @@ -167,7 +167,7 @@ describe('durable error rendering', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!threwOnce) { threwOnce = true throw new LlmError('server overloaded', 'RATE_LIMIT') @@ -250,7 +250,7 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject) => { + ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } }) @@ -271,7 +271,7 @@ describe('request-error action edges', () => { ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject, _context, signal, next) => { + ctx.on('agent/request-error', async ({ agent: subject, signal }, next) => { await next() subject.cancel({ kind: 'user' }) expect(signal.aborted).toBe(true) @@ -350,7 +350,7 @@ describe('persistent step-close rejection', () => { if (event.type === 'step/end') throw new Error('step close permanently rejected') }) const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) send(agent, 'go') await agent.whenIdle() @@ -406,7 +406,7 @@ describe('turn close failure containment', () => { } }) const errors: unknown[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) + ctx.on('agent/error', ({ error }) => { errors.push(error) }) send(agent, 'go') await agent.whenIdle() @@ -484,11 +484,11 @@ describe('driver bookkeeping edges', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) let proposals = 0 - ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { + ctx.on('agent/pre-step', async (_payload, next) => { proposals += 1 return proposals === 2 ? { kind: 'reject' } : next() }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'do not enter the next step' }], source: { kind: 'plugin', plugin: 'test' }, diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 86a3d664c5..a26f463b0d 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -41,7 +41,7 @@ async function harness(adapter: MockAdapter) { 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() @@ -65,7 +65,7 @@ describe('agent/pre-step', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] - ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ messages }, next) => { seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join('')) return next() }) @@ -92,8 +92,8 @@ describe('agent/pre-step', () => { })) const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) const seen: Array<{ turn: number; step: number; messages: number }> = [] - ctx.on('agent/pre-step', async (_agent, messages, context, next) => { - seen.push({ turn: context.turn, step: context.step, messages: messages.length }) + ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => { + seen.push({ turn, step, messages: messages.length }) return next() }) @@ -113,7 +113,7 @@ describe('agent/pre-step', () => { const entered = Promise.withResolvers() const decision = Promise.withResolvers() const observed: UserMessage[] = [] - ctx.on('agent/pre-step', async (subject, messages) => { + ctx.on('agent/pre-step', async ({ agent: subject, messages }) => { if (subject !== agent) return { kind: 'enter', messages } const message = messages[0]! expect(Object.isFrozen(message)).toBe(true) @@ -161,7 +161,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], @@ -182,7 +182,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages): Promise => + ctx.on('agent/pre-step', async ({ messages }): Promise => ({ kind: 'enter', messages: [...messages, createUserMessage({ @@ -211,15 +211,15 @@ describe('agent/pre-step', () => { provider: 'mock', model: 'mock', }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { subject.inject(createUserMessage({ content: [{ type: 'text', text: 'pending context' }], source: { kind: 'plugin', plugin: 'test' }, })) }) - ctx.on('agent/pre-step', async (_subject, _messages, context, next) => { + ctx.on('agent/pre-step', async ({ step }, next) => { const decision = await next() - return context.step === 1 || decision.kind === 'reject' + return step === 1 || decision.kind === 'reject' ? decision : { kind: 'enter', messages: [] } }) @@ -262,7 +262,7 @@ describe('agent/pre-step', () => { const decision = Promise.withResolvers() let claimed: UserMessage[] = [] let firstProposal = true - ctx.on('agent/pre-step', async (_agent, messages) => { + ctx.on('agent/pre-step', async ({ messages }) => { if (!firstProposal) return { kind: 'enter', messages } firstProposal = false claimed = messages @@ -372,14 +372,14 @@ describe('agent/pre-step', () => { provider: 'mock', model: 'mock', }) - ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ messages }, next) => { const decision = await next() return messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) ? { kind: 'reject' as const } : decision }) - ctx.on('agent/pre-step', async (subject, messages, _signal, next) => { + ctx.on('agent/pre-step', async ({ agent: subject, messages }, next) => { if (messages.some(message => message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { subject.inject(createUserMessage({ @@ -482,7 +482,7 @@ describe('agent/pre-step', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { + ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') return text === 'secret' @@ -519,17 +519,17 @@ describe('agent/pre-step', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false - ctx.on('agent/pre-step', async (_agent, messages) => { + ctx.on('agent/pre-step', async ({ messages }) => { if (!threw) { threw = true; throw new Error('prompt hook broke') } return { kind: 'enter' as const, messages } }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] const statuses: string[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) - ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -559,7 +559,7 @@ describe('agent/session-start', () => { const ctx = await harness(adapter) const sources: SessionStartSource[] = [] - ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) + ctx.on('agent/session-start', ({ source }) => void sources.push(source)) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires synchronously at create, before any turn @@ -576,7 +576,7 @@ describe('agent/session-start', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })) }) @@ -724,11 +724,11 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'native-guard', apply(ctx: Context) { // 1. SessionStart: seed a standing instruction. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })) }) // 2. PreStep: reject a forbidden prompt, annotate the rest. - ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise => { + ctx.on('agent/pre-step', async ({ messages }, next): Promise => { const text = messages.flatMap(message => message.content) .map(b => (b.type === 'text' ? b.text : '')).join('') if (text.includes('rm -rf')) { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 94e39a08a2..c8d048ca47 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter, persona = '') { /** Wait for the agent's next transition to idle after a waking send. */ 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() @@ -216,7 +216,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok after rescue')]) const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -263,7 +263,7 @@ describe('agent loop', () => { assembly.variables['model'] = 'mock' return next() }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() return { ...config, provider: 'mock', model: 'mock' } }) @@ -553,7 +553,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) let fail = true - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject !== agent || !fail) return next() fail = false subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })) @@ -713,7 +713,7 @@ describe('agent loop', () => { let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (steps < 3) { subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })) } @@ -785,7 +785,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() // The seed is frozen — config is not a mutable per-call knob; a switch // is proposed by returning a replacement, and the loop logs it. @@ -816,7 +816,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const fires: { turn: number; step: number; signal: AbortSignal }[] = [] - ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => { + ctx.on('agent/pre-step', ({ agent: subject, turn, step, signal }, next) => { if (subject === agent) fires.push({ turn, step, signal }) return next() }) @@ -837,7 +837,7 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let boundaryOpen = true - ctx.on('agent/pre-step', (subject, _messages, _context, next) => { + ctx.on('agent/pre-step', ({ agent: subject }, next) => { if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' return next() }) @@ -855,13 +855,13 @@ describe('agent loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true - ctx.on('agent/pre-step', (_agent, _messages, _context, next) => { + ctx.on('agent/pre-step', (_payload, next) => { if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } return next() }) const errors: Error[] = [] - ctx.on('agent/error', (_a, _t, _s, error) => { + ctx.on('agent/error', ({ error }) => { if (error instanceof Error) errors.push(error) }) @@ -933,7 +933,7 @@ describe('agent loop', () => { ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) // Force exactly one continuation (step 1 → step 2), then defer to default // (step 2 is a plain stop with no tool calls → stops). - ctx.on('agent/turn-stopping', (subject) => { + ctx.on('agent/turn-stopping', ({ agent: subject }) => { if (steps < 2) { subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })) } @@ -1296,7 +1296,7 @@ describe('agent loop', () => { const errors: unknown[] = [] const reasons: TurnEndReason[] = [] - ctx.on('agent/error', (_agent, _turn, _step, error) => { + ctx.on('agent/error', ({ error }) => { errors.push(error) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index c5dec8c142..0add31bd1f 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -50,7 +50,7 @@ async function harness() { /** Resolve on the agent's next transition to idle (event-based, not polled). */ function nextIdle(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() @@ -63,7 +63,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise { * the seen list plus a disposer for the listener (per the registry convention). */ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { const seen: string[] = [] - const dispose = ctx.on('agent/status', (subject, status) => { + const dispose = ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) seen.push(status) }) return { seen, dispose } diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 287badfc15..0c7c65e483 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -59,7 +59,7 @@ async function loopHarness(): Promise { function waitForIdle(context: Context, agent: Agent): Promise { return new Promise((resolve) => { - const dispose = context.on('agent/status', (subject, status) => { + const dispose = context.on('agent/status', ({ agent: subject, status }) => { if (subject === agent && status === 'idle') { dispose() resolve() diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 96b6bfc045..d143bd79ae 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -62,12 +62,12 @@ describe('agent/request-error', () => { retryPolicy: ResolvedRetryPolicy | undefined }[] = [] const statuses: string[] = [] - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) }) - ctx.on('agent/request-error', async (subject, context) => { + ctx.on('agent/request-error', async ({ agent: subject, turn, step, failure, retryPolicy }) => { expect(subject).toBe(agent) - seen.push(context) + seen.push({ turn, step, failure, retryPolicy }) return { kind: 'retry' } }) @@ -102,7 +102,7 @@ describe('agent/request-error', () => { const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request-error', async (subject) => { + ctx.on('agent/request-error', async ({ agent: subject }) => { subject.cancel({ kind: 'user' }) return { kind: 'retry' } }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 565bb73f63..62a7ae5e71 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -38,7 +38,7 @@ async function harnessRoutes( 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() @@ -122,7 +122,7 @@ describe('request stability across the loop', () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config }) @@ -198,7 +198,7 @@ describe('request stability across the loop', () => { provider: 'deepseek', model: 'deepseek-model', }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, provider: 'other', model: 'other-model' } @@ -232,7 +232,7 @@ describe('request stability across the loop', () => { model: 'deepseek-model', maxTokens: 4_096, }) - ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { + ctx.on('agent/request', async ({ turn }, next) => { const config = await next() return turn === 2 ? { ...config, provider: 'other', model: 'other-model' } @@ -460,7 +460,7 @@ describe('request stability across the loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { if (!injected) { injected = true agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })) @@ -539,7 +539,7 @@ describe('request stability across the loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { + ctx.on('agent/request', async (_payload, next) => { const config = await next() // next() resolves the SAME frozen seed — in-place shaping after // delegation is unrepresentable, so a "mutate what next() returned" @@ -576,7 +576,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' }) - ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ + ctx.on('agent/request', async (_payload, next) => ({ ...await next(), temperature: 0.5, maxTokens: 99, stop: [''], })) send(agent, 'again') @@ -658,7 +658,7 @@ describe('request/context capacity records', () => { send(agent, 'first') await waitForIdle(ctx, agent) - ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent + ctx.on('agent/request', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model: 'large' }) : next()) send(agent, 'second') @@ -686,7 +686,7 @@ describe('request/context capacity records', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) let model = 'known' - ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent + ctx.on('agent/request', ({ agent: subject }, next) => subject === agent ? Promise.resolve({ provider: 'mock', model }) : next()) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 62f9e9059e..964ef82aa6 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -66,7 +66,7 @@ function preparationFromSnapshot( 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() } }) }) @@ -260,7 +260,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] - ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) + ctx1.on('agent/session-start', ({ source }) => void sources1.push(source)) const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) @@ -279,7 +279,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) const sources2: string[] = [] - ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) + ctx2.on('agent/session-start', ({ source }) => void sources2.push(source)) await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) expect(sources2).toEqual(['resume']) await ctx2.fiber.dispose() @@ -298,11 +298,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(ctx.agents.get(sessionId)?.session).toBe(session) order.push('session/created') }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { expect(agent.status).toBe('idle') order.push('agent/created') }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow() order.push('agent/session-start') }) @@ -882,7 +882,7 @@ describe('configured-start failure edges', () => { configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) const configFailures: unknown[] = [] - configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) }) + configured.on('agent-loop/config-start-failed', ({ error }) => { configFailures.push(error) }) const configWarnings: string[] = [] const configWarn = configured.logger.warn.bind(configured.logger) configured.logger.warn = ((...args: unknown[]) => { @@ -915,7 +915,7 @@ describe('configured-start failure edges', () => { return gate.promise } const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const configured = new Context() await configured.plugin(LlmService) @@ -926,7 +926,7 @@ describe('configured-start failure edges', () => { await configured.plugin(SessionPersistenceJsonl, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) - configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + configured.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) }) const loop = await configured.plugin(AgentLoop, { agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], }) diff --git a/packages/core/agent-loop/tests/runtime-context.spec.ts b/packages/core/agent-loop/tests/runtime-context.spec.ts index ed524a9e4b..463515a61b 100644 --- a/packages/core/agent-loop/tests/runtime-context.spec.ts +++ b/packages/core/agent-loop/tests/runtime-context.spec.ts @@ -30,10 +30,16 @@ describe('RuntimeContextProjection', () => { const projection = new RuntimeContextProjection(ctx, session) expect(session.surface.nodes).toContain(retained.seq) - expect(projection.project('retained')).toBeUndefined() + expect(projection.project('retained', [])).toBeUndefined() + expect(projection.project('next', [{ name: 'sandbox:policy', text: 'policy' }])?.source).toEqual({ + kind: 'plugin', + plugin: SOURCE, + form: 'snapshot', + sections: [{ name: 'sandbox:policy', text: 'policy' }], + }) const other = ctx.sessions.create(SessionId('runtime-context-other')) other.append('user/message', contextMessage('other'), { surfaceOp: 'append' }) - expect(projection.project('retained')).toBeUndefined() + expect(projection.project('retained', [])).toBeUndefined() }) }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index a618a130cf..3f3e0a43d8 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok' 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() @@ -199,7 +199,7 @@ describe('agent scope lifecycle', () => { const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] - a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) + a.ctx.on('agent/status', ({ agent: subject, status }) => void heard.push(`a-sees:${subject.id}:${status}`)) a.ctx.on('session/event', (_s, event) => { if (event.type === 'user/message') heard.push('a-sees:user-message') }) @@ -217,7 +217,7 @@ describe('agent scope lifecycle', () => { it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => { const ctx = await harness() const order: string[] = [] - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { order.push('session-start') // The scoped section is already registered by the time session-start fires. void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => { @@ -673,19 +673,19 @@ describe('agent scope lifecycle', () => { ctx.on('session/created', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) - ctx.on('agent/disposed', (agent) => { + ctx.on('agent/disposed', ({ agent }) => { if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { @@ -720,8 +720,8 @@ describe('agent scope lifecycle', () => { const starts: string[] = [] let ownerCtx!: Context let creating!: ReturnType - ctx.on('agent/session-start', agent => void starts.push(agent.id)) - ctx.on('agent/created', (agent) => { + ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id)) + ctx.on('agent/created', ({ agent }) => { if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx) }) @@ -749,15 +749,15 @@ describe('agent scope lifecycle', () => { const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { if (agent.id !== SessionId('session-start-dispose-s')) return announced = agent disposeCurrentLifecycle(ownerCtx) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) @@ -840,7 +840,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let boom = true const disposed: string[] = [] - ctx.on('agent/disposed', agent => void disposed.push(agent.id)) + ctx.on('agent/disposed', ({ agent }) => void disposed.push(agent.id)) ctx.on('session/created', () => { if (boom) { boom = false; throw new Error('boom created') } }) @@ -861,11 +861,11 @@ describe('agent scope lifecycle', () => { const lifecycle: string[] = [] ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) }) ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) }) - ctx.on('agent/created', (agent) => { + ctx.on('agent/created', ({ agent }) => { lifecycle.push(`agent-created:${agent.id}`) throw new Error('agent observer failed') }) - ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) + ctx.on('agent/disposed', ({ agent }) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ sessionId: SessionId('partial-session'), @@ -911,10 +911,10 @@ describe('agent scope lifecycle', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] - agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) + agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`)) - agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1')) - agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) + agentEvents(ctx, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') }) + agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') }) expect(heard).toEqual(['a1:2']) }) @@ -1064,7 +1064,7 @@ describe('agent scope lifecycle', () => { }) const agent = handle.agent let reentered = false - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || reentered) return reentered = true agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } })) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index f3548cca52..89b2e300bd 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { 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/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 8aec38004f..9fa321697a 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too 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/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 4cfc5328e8..5c03669baf 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: c3d6e6c24480894b6059417c1ab89db7aa0d7fa2 -README.zh.md: 16ee8f5e6c483555839b0c3ab174e2e2356b1359 +README.md: 2a69ab380eaad3929e27039582807037969eba64 +README.zh.md: 176f3f75cf0f6e3309b2f5d34afb4d562105608e diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index c3d6e6c244..2a69ab380e 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. `agent/pre-step` receives the exclusive claimed `UserMessage[]` plus a `PreStepContext` containing the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Other turn-scoped asynchronous seams receive their explicit `AbortSignal` positionally. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. `agent/pre-step` receives a payload carrying the subject `agent`, the exclusive claimed `UserMessage[]`, and the proposed `turn`, `step`, and cancellation `signal`; its batch may be empty when tools already require another request. Agent-scoped turn seams carry their explicit `AbortSignal` in the payload; the remaining turn-scoped seams receive it through their request value. Listeners may cooperate with a signal but must not retain it as authority over another turn. `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery. `agent/turn-stopping` runs before an otherwise completed turn closes. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages }`. The enter branch is the complete identified, frozen batch for the proposed step. A listener that wraps downstream entry preserves that batch unless it intentionally replaces it; additions follow the waterfall's natural return order. Claiming already removed the offered messages from the inbox, so rejection does not retain them. Messages inserted after the claim remain pending for a later boundary. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 16ee8f5e6c..176f3f75cf 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器完全停稳后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收独占的已领取 `UserMessage[]`,以及包含拟进入 `turn`、`step` 与取消 `signal` 的 `PreStepContext`;当工具已经要求继续请求时,该批次可以为空。其他轮次作用域异步 seam 仍按位置接收显式 `AbortSignal`。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall(瀑布式事件)。`agent/pre-step` 接收一个 payload,携带主体 `agent`、独占的已领取 `UserMessage[]` 以及拟进入的 `turn`、`step` 与取消 `signal`;当工具已经要求继续请求时,该批次可以为空。agent 作用域轮次 seam 在 payload 中携带显式 `AbortSignal`;其余轮次作用域 seam 通过其请求值接收它。监听器可以配合信号,但不得将它保留为控制另一轮次的权限。`agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时提供服务的注册项重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`。`agent/turn-stopping` 在本可完成的轮次关闭前运行。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages }`。enter 分支是拟进入步骤的完整、带标识且冻结的批次。包装下游 enter 的监听器会保留该批次,除非有意替换它;新增消息遵循 waterfall 的自然返回顺序。领取操作已经把候选消息从 inbox 删除,因此 reject 不会保留它们;领取后插入的消息仍等待后续边界。 diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index b28586b6b8..cf07f24ecf 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,7 +1,8 @@ /** - * Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the - * fused dispatcher so subject and scope key cannot diverge; registry lifecycle - * code instead captures one stable carrier for both edges. + * Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher + * {@link agentEvents} couples the agent subject to its scope carrier, so the + * scope key and the payload's `agent` cannot diverge; repeat dispatchers (the + * loop driver) build it once in the agent's constructor and reuse it. * @module @deepseek-ai/dsh-agent/dispatch */ @@ -17,25 +18,38 @@ type Params = F extends (...args: infer P) => unknown ? P : never type Return = F extends (...args: never[]) => infer R ? R : never /** - * The event names whose subject is an agent: handler parameters start with an - * `Agent` AND the handler declares a `Scoped` `this` (the scope-carrier - * contract). The `this` check keeps accidental first-parameter-happens-to-be- - * an-Agent events (or zero-arg events, whose parameter tuple would satisfy a - * bare rest-tuple check via callability) out of the fused-dispatch surface. + * The event names whose subject is an agent: the handler's first parameter is + * a payload object carrying the `agent` subject AND the handler declares a + * `Scoped` `this` (the scope-carrier contract). The `this` check keeps + * accidental payload-happens-to-carry-an-Agent events (or zero-arg events, + * whose parameter tuple would satisfy a bare rest-tuple check via callability) + * out of the fused-dispatch surface. */ export type AgentSubjectEvent = { [K in keyof Events]: Events[K] extends (this: Scoped, ...args: infer P) => unknown - ? P extends [Agent, ...unknown[]] ? K : never + ? P extends [infer Payload, ...unknown[]] + ? Payload extends { agent: Agent } ? K : never + : never : never }[keyof Events] -/** The event arguments AFTER the injected agent subject. */ -type Tail = Params extends [Agent, ...infer R] ? R : never +/** The full payload object of one agent-subject event. */ +type PayloadOf = Params extends [infer Payload, ...unknown[]] ? Payload : never + +/** The event arguments AFTER the payload: the waterfall `next` when present. */ +type Tail = Params extends [unknown, ...infer R] ? R : never + +/** + * The payload as emit-side callers pass it: the full payload minus the agent + * field, which the fused dispatcher injects so subject and scope key cannot + * diverge. + */ +type PayloadRest = Omit & object, 'agent'> /** * The fused dispatcher {@link agentEvents} returns: each method dispatches the * named agent-subject event with the agent's scope carrier as `thisArg` and - * the agent itself injected as the first event argument. + * the agent itself injected into the payload. */ export interface AgentEventDispatch { /** @@ -44,30 +58,36 @@ export interface AgentEventDispatch { * contained per listener, so a notification cannot veto lifecycle progress * or starve a later observer. * @param name - the agent-subject event to emit. - * @param rest - the event's arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. */ - emit(name: K, ...rest: Tail): void + emit(name: K, payload: PayloadRest): void /** * Awaited in-order dispatch (Cordis `serial`) in the agent's scope. * @param name - the agent-subject event to dispatch. - * @param rest - the event's arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. * @returns the serial chain's result (the first bail value, if any). */ - serial(name: K, ...rest: Tail): Promise>> + serial(name: K, payload: PayloadRest): Promise>> /** * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The * declared event parameters already end with the `next` callback, so `rest` - * is exactly the event's arguments after the injected agent — the final - * element being the innermost `next` (the default the listener chain wraps). + * is exactly the event's arguments after the payload — the final element + * being the innermost `next` (the default the listener chain wraps). * @param name - the agent-subject event to dispatch. - * @param rest - the event's arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. + * @param rest - the event's arguments after the payload (the `next` callback). * @returns the waterfall's composed result. */ - waterfall(name: K, ...rest: Tail): Return + waterfall(name: K, payload: PayloadRest, ...rest: Tail): Return } /** - * Return the fused scope carrier for one agent subject. + * Build the fused scope carrier for one agent subject. + * + * The carrier is a stateless routing object. {@link agentEvents} accepts an + * existing carrier, so callers that dispatch repeatedly for the same agent + * (the loop driver) build it once in the agent's constructor and reuse it, + * keeping hot-path dispatches allocation-free. * @param agent - the subject agent and scope key. * @returns the carrier passed as the event dispatcher `this` value. */ @@ -79,22 +99,30 @@ export function agentCarrier(agent: Agent): Scoped { * Build a dispatcher that couples the agent subject to its scope carrier. * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. + * @param carrier - the scope carrier to dispatch through; defaults to + * {@link agentCarrier} for the agent. Pass a constructor-built carrier to + * avoid rebuilding it for every dispatch. * @returns the fused dispatcher. */ -export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { - const carrier = agentCarrier(agent) +export function agentEvents(ctx: Context, agent: Agent, carrier: Scoped = agentCarrier(agent)): AgentEventDispatch { // The ordinary dispatch methods forward through Cordis' variadic mixins. The - // fused (carrier, name, agent, ...rest) tuple is provably a valid argument + // fused (carrier, name, payload, ...rest) tuple is provably a valid argument // list for the matching thisArg overload, but TypeScript cannot relate the // generic Tail spread back to that overload's conditional parameter // tuple — hence one contained, shape-preserving cast per method. + const fused = (payload: PayloadRest): PayloadOf => + // The dispatcher owns the subject injection; callers pass PayloadRest, so + // the fused record is exactly the declared payload. The spread comes + // first, so a structurally acceptable payload that happens to carry an + // `agent` field can never override the injected subject. + ({ ...payload, agent } as PayloadOf) return { - emit(name, ...rest) { + emit(name, payload) { // Cordis emit invokes callbacks through Array.map: one synchronous throw // starves later listeners, and returned promises are discarded. Agent // notifications are non-vetoing, so resolve the same filtered callback // set ourselves and contain both failure modes independently. - const args: unknown[] = [carrier, name, agent, ...rest] + const args: unknown[] = [carrier, name, fused(payload)] const callbacks = ctx.events.dispatch('emit', args) for (const callback of callbacks) { try { @@ -107,15 +135,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } } }, - async serial(name, ...rest) { + async serial(name, payload) { // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function const serial = ctx.serial as (thisArg: Scoped, name: string, ...args: unknown[]) => Promise - return await serial(carrier, name, agent, ...rest) + return await serial(carrier, name, fused(payload)) }, - waterfall(name, ...rest) { + waterfall(name, payload, ...rest) { // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function const waterfall = ctx.waterfall as (thisArg: Scoped, name: string, ...args: unknown[]) => never - return waterfall(carrier, name, agent, ...rest) + return waterfall(carrier, name, fused(payload), ...rest) }, } } @@ -125,15 +153,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { * @param ctx - the context to dispatch through. * @param agent - the subject agent and scope key. * @param name - the agent-subject event to emit. - * @param rest - the event arguments after the injected agent. + * @param payload - the event's payload fields; `agent` is injected. */ export function emitAgentEvent( ctx: Context, agent: Agent, name: K, - ...rest: Tail + payload: PayloadRest, ): void { - agentEvents(ctx, agent).emit(name, ...rest) + agentEvents(ctx, agent).emit(name, payload) } /** diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 0a16a2bf53..55cb94d8f9 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -498,7 +498,7 @@ export class AgentRegistry extends Service { /** Emit the paired disposal edge through the entry's stable carrier. */ private emitDisposed(entry: AgentEntry): void { - const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent] + const args: unknown[] = [entry.carrier, 'agent/disposed', { agent: entry.agent }] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) @@ -530,7 +530,7 @@ export class AgentRegistry extends Service { // lifecycle edge; detach still pairs a partially delivered first edge. entry.announcing = true entry.announced = true - const args: unknown[] = [entry.carrier, 'agent/created', entry.agent] + const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }] try { for (const callback of this.ctx.events.dispatch('emit', args)) { // A synchronous creation failure vetoes publication and rolls back. diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index f2d9a69539..a561e862cb 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -14,7 +14,7 @@ export const inject = ['invariants'] /** Install the agent contribution into its child registration fiber. */ const install: InvariantInstaller = (ctx, fail) => { const lastStatus = new WeakMap() - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { const previous = lastStatus.get(agent) if (previous === status) { fail(`agent/status repeated ${status} (no-op transition)`) diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts index 7b5d1a4df6..e23ea9d750 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/llm-target.ts @@ -53,7 +53,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR }) const disposeRequest = agentCtx.on( 'agent/request', - async (_agent, _turn, _step, _signal, next): Promise => { + async (_payload, next): Promise => { const resolved = await next() const selected = target.assembled if (selected === undefined) return resolved diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e634762494..fae9267347 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -48,35 +48,11 @@ export interface CancelOptions { */ export type AgentStatus = 'idle' | 'running' -/** Coordinates and cancellation for a proposed step. */ -export interface PreStepContext { - /** Turn that will own the step. */ - readonly turn: number - /** Step proposed by the loop. */ - readonly step: number - /** Current turn cancellation signal. */ - readonly signal: AbortSignal -} - /** Whether and with which messages the loop enters a proposed step. */ export type PreStepDecision = | { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] } -/** One failed model-request attempt presented to recovery listeners. */ -export interface RequestFailureContext { - /** Turn containing the failed request. */ - readonly turn: number - /** Step containing the failed request attempt. */ - readonly step: number - /** Provider selected for the failed request. */ - readonly provider: string - /** Serializable facts normalized at the final adapter boundary. */ - readonly failure: LlmFailure - /** Policy of the adapter registration that served the failed request. */ - readonly retryPolicy: ResolvedRetryPolicy | undefined -} - /** Action returned by a listener that owns model-request recovery. */ export type RequestErrorAction = { kind: 'retry' } | undefined @@ -171,105 +147,112 @@ declare module 'cordis' { * Synchronous listener failure vetoes publication, while returned-promise * rejection is reported. Detach requested during dispatch waits until every * creation listener has observed the stable entry. - * @param agent - the newly registered agent with its live session and completed setup. + * @param payload.agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/created'(this: Scoped, agent: Agent): void + 'agent/created'(this: Scoped, payload: { agent: Agent }): void /** * An agent left the registry; AgentLoop emits this after driver quiescence * and scoped-registration unwind, but before session detachment. Custom * registry users own their driver-ordering contract. - * @param agent - the exact agent removed from the registry. + * @param payload.agent - the exact agent removed from the registry. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/disposed'(this: Scoped, agent: Agent): void + 'agent/disposed'(this: Scoped, payload: { agent: Agent }): void /** * Agent status changed (`idle` ⇄ `running`). A waking delivery enters * `running` synchronously after reserving cancellation; `idle` means no * driver remains scheduled or active. - * @param agent - the agent whose status flipped. - * @param status - the status just entered (the transition's destination). + * @param payload.agent - the agent whose status flipped. + * @param payload.status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void + 'agent/status'(this: Scoped, payload: { agent: Agent; status: AgentStatus }): void /** * One message entered the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the inserted message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the inserted message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/inserted'(this: Scoped, agent: Agent, event: { message: UserMessage }): void + 'agent/inbox/inserted'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void /** * One message left the inbox inside its open turn. If the proposed step * is rejected, the claimed message ends here: it is neither discarded nor * re-emitted as a user/message, and the turn closes without a step. - * @param agent - the agent whose inbox changed. - * @param event - the claimed message and owning turn. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the claimed message. + * @param payload.turn - the owning turn. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/claimed'(this: Scoped, agent: Agent, event: { message: UserMessage; turn: number }): void + 'agent/inbox/claimed'(this: Scoped, payload: { agent: Agent; message: UserMessage; turn: number }): void /** * One message was discarded from the live inbox. - * @param agent - the agent whose inbox changed. - * @param event - the discarded message. + * @param payload.agent - the agent whose inbox changed. + * @param payload.message - the discarded message. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discarded'(this: Scoped, agent: Agent, event: { message: UserMessage }): void + 'agent/inbox/discarded'(this: Scoped, payload: { agent: Agent; message: UserMessage }): void // ---- session lifecycle (emit) ---- /** * The session lifecycle began, once before the first turn. Use * `agent.inject()` to seed model-facing context. This is a notification, not * a veto; disposal requested by a lifecycle owner is rechecked before the * driver starts. - * @param agent - the agent whose session lifecycle began. - * @param source - why the session started (fresh startup, resume, …). + * @param payload.agent - the agent whose session lifecycle began. + * @param payload.source - why the session started (fresh startup, resume, …). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void + 'agent/session-start'(this: Scoped, payload: { agent: Agent; source: SessionStartSource }): void // ---- the machine's extension seams ---- /** * Reject a proposed step or replace the messages that enter it. Calling * `next()` preserves the current messages. - * @param agent - the agent proposing the step. - * @param messages - messages removed from the inbox for this step. - * @param context - proposed turn and step coordinates plus cancellation. + * @param payload.agent - the agent proposing the step. + * @param payload.messages - messages removed from the inbox for this step. + * @param payload.turn - the turn that will own the step. + * @param payload.step - the step proposed by the loop. + * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise + 'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise /** * Replace the frozen call configuration. `await next()` yields the config * the machine would use (agent options on the first request, the logged * header afterwards); return a replacement to switch. Model-visible * content must use logged channels; this seam cannot mutate messages. - * @param agent - the agent making the model call. - * @param turn - the open turn number. - * @param step - the step whose request this is. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent making the model call. + * @param payload.turn - the open turn number. + * @param payload.step - the step whose request this is. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise + 'agent/request'(this: Scoped, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise /** * Handle one failed model-request attempt before the loop retries or closes * its step. A listener returns `{ kind: 'retry' }` without calling `next()` * when it owns recovery, or calls `next()` to delegate. The default * `undefined` leaves the failure terminal. - * @param agent - the agent whose request failed. - * @param context - request coordinates, provider, normalized failure, and serving policy. - * @param signal - the turn abort signal. + * @param payload.agent - the agent whose request failed. + * @param payload.turn - the turn containing the failed request. + * @param payload.step - the step containing the failed request attempt. + * @param payload.provider - the provider selected for the failed request. + * @param payload.failure - serializable facts normalized at the final adapter boundary. + * @param payload.retryPolicy - the policy of the adapter registration that served the failed request. + * @param payload.signal - the turn abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request-error'(this: Scoped, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise): Promise + 'agent/request-error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise /** * The turn is about to close: the model owes no response (no live tool * calls, no fresh steering). Awaited before the boundary commits — a @@ -281,25 +264,25 @@ declare module 'cordis' { * never short-circuits already-submitted next-step work: same-step * `additionalContexts` or racing steering still runs, and the turn * closes only when that inbox drains. - * @param agent - the agent whose turn is at its stop boundary. - * @param turn - the turn about to close. - * @param signal - the current turn's explicit abort signal. + * @param payload.agent - the agent whose turn is at its stop boundary. + * @param payload.turn - the turn about to close. + * @param payload.signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ - 'agent/turn-stopping'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | void + 'agent/turn-stopping'(this: Scoped, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise | void // ---- error notifications (emit) ---- /** * A step or turn errored. The machine reports a failure here even when * the error has no in-turn position for a durable record. - * @param agent - the agent whose turn errored. - * @param turn - the turn in which the failure surfaced. - * @param step - the step at which the failure surfaced. - * @param error - the failure, verbatim. + * @param payload.agent - the agent whose turn errored. + * @param payload.turn - the turn in which the failure surfaced. + * @param payload.step - the step at which the failure surfaced. + * @param payload.error - the failure, verbatim. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: unknown): void + 'agent/error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void } } diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 313850faa4..e80d575aeb 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -11,6 +11,7 @@ import type { Agent, AgentCancelCause, AgentFactory, + AgentStatus, CreateAgentOptions, ResumeAgentOptions, } from '@deepseek-ai/dsh-agent' @@ -145,8 +146,8 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) const agent = stubAgent('a1') const dispose = ctx.agents.register(agent) @@ -195,9 +196,9 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) ctx.on('agent/created', () => { throw new Error('creation veto') }) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() @@ -213,7 +214,7 @@ describe('AgentRegistry', () => { ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never) ctx.on('agent/disposed', () => { throw new Error('disposed sync') }) ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never) - ctx.on('agent/disposed', agent => void heard.push(agent.id)) + ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id)) const dispose = ctx.agents.register(stubAgent('contained')) await Promise.resolve() @@ -232,8 +233,8 @@ describe('AgentRegistry', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const lifecycle: string[] = [] - ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) - ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) + ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`)) + ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') const detachFirst = ctx.agents.enter(first, undefined) @@ -280,9 +281,9 @@ describe('agentEvents()', () => { const agent = stubAgent('event') ctx.on('agent/status', () => { throw new Error('sync listener') }) ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never) - ctx.on('agent/status', (_agent, status) => void heard.push(status)) + ctx.on('agent/status', ({ status }) => void heard.push(status)) - agentEvents(ctx, agent).emit('agent/status', 'running') + agentEvents(ctx, agent).emit('agent/status', { status: 'running' }) await Promise.resolve() expect(heard).toEqual(['running']) expect(warnings).toEqual([ @@ -296,15 +297,30 @@ describe('agentEvents()', () => { const agent = stubAgent('serial-event') const signal = new AbortController().signal const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = [] - ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, turn, signal: receivedSignal }) => { await Promise.resolve() heard.push({ agent: subject, turn, signal: receivedSignal }) }) - await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal) + await agentEvents(ctx, agent).serial('agent/turn-stopping', { turn: 3, signal }) expect(heard).toEqual([{ agent, turn: 3, signal }]) }) + + it('injects the fused subject even when the payload carries a conflicting agent field', async () => { + const ctx = new Context() + const agent = stubAgent('fused-subject') + const other = stubAgent('payload-agent') + const heard: Agent[] = [] + ctx.on('agent/status', ({ agent: subject }) => void heard.push(subject)) + // A structurally acceptable payload may carry an extra `agent` field; the + // dispatcher's injected subject must win over it. + const payload: { status: AgentStatus; agent: Agent } = { status: 'running', agent: other } + + agentEvents(ctx, agent).emit('agent/status', payload) + + expect(heard).toEqual([agent]) + }) }) describe('explicit cancellation contract', () => { diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 158376a3d7..458a10714d 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -21,17 +21,17 @@ describe('agent status invariants', () => { const ctx = await setup() const agent = mockAgent('a1') expect(() => { - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' }) }).not.toThrow() }) it('rejects a no-op transition', async () => { const ctx = await setup() const agent = mockAgent('a3') - ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }) + ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) }) .toThrow(/no-op transition/) }) @@ -39,7 +39,7 @@ describe('agent status invariants', () => { const ctx = await setup() const a = mockAgent('a5') const b = mockAgent('b5') - ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') - expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() + ctx.emit(scopeTarget(a, a), 'agent/status', { agent: a, status: 'running' }) + expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', { agent: b, status: 'running' }) }).not.toThrow() }) }) diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts index d3ec2f96bd..991a69ea32 100644 --- a/packages/core/agent/tests/llm-target.spec.ts +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => { expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toBe(seed) target.current = { @@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => { expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) target.current = { provider: 'beta', model: 'b1' } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toEqual({ provider: 'alpha', model: 'a1', @@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => { temperature: 0.2, } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, signal, () => Promise.resolve(inherited), + 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited), )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) dispose() expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 2, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toBe(seed) await ctx.fiber.dispose() }) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index e544c47987..672914c5c3 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -8,20 +8,20 @@ type ScopedSubjectResolver = (args: readonly unknown[]) => unknown const scopedSubjectResolvers: Readonly> = Object.freeze({ - 'agent/created': args => args[0], - 'agent/disposed': args => args[0], - 'agent/error': args => args[0], - 'agent/inbox/claimed': args => args[0], - 'agent/inbox/discarded': args => args[0], - 'agent/inbox/inserted': args => args[0], - 'agent/pre-step': args => args[0], - 'agent/request': args => args[0], - 'agent/request-error': args => args[0], - 'agent/session-start': args => args[0], - 'agent/status': args => args[0], - 'agent/turn-stopping': args => args[0], + 'agent/created': args => (args[0] as Record)['agent'], + 'agent/disposed': args => (args[0] as Record)['agent'], + 'agent/error': args => (args[0] as Record)['agent'], + 'agent/inbox/claimed': args => (args[0] as Record)['agent'], + 'agent/inbox/discarded': args => (args[0] as Record)['agent'], + 'agent/inbox/inserted': args => (args[0] as Record)['agent'], + 'agent/pre-step': args => (args[0] as Record)['agent'], + 'agent/request': args => (args[0] as Record)['agent'], + 'agent/request-error': args => (args[0] as Record)['agent'], + 'agent/session-start': args => (args[0] as Record)['agent'], + 'agent/status': args => (args[0] as Record)['agent'], + 'agent/turn-stopping': args => (args[0] as Record)['agent'], 'approval/request': args => (args[0] as Record)['agent'], - 'goal/changed': args => args[0], + 'goal/changed': args => (args[0] as Record)['agent'], 'session/created': null, 'session/disposed': null, 'session/event': null, diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index d647344537..8744bb9aa7 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -28,7 +28,7 @@ describe('scoped-dispatch invariants', () => { const ctx = await setup() expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() const agent = { id: 'a1' } - expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) }) + expect(() => { emit(ctx, undefined, 'agent/error', [{ agent, turn: 1, step: 0, error: new Error('x') }]) }) .toThrow(/dispatched without a scope carrier/) }) @@ -45,34 +45,34 @@ describe('scoped-dispatch invariants', () => { source: { kind: 'user' }, }) const agentRows = { - 'agent/created': [agent], - 'agent/disposed': [agent], - 'agent/status': [agent, 'idle'], - 'agent/inbox/inserted': [agent, { message }], - 'agent/inbox/claimed': [agent, { message, turn: 1 }], - 'agent/inbox/discarded': [agent, { message }], - 'agent/session-start': [agent, 'startup'], - 'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], - 'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], + 'agent/created': [{ agent }], + 'agent/disposed': [{ agent }], + 'agent/status': [{ agent, status: 'idle' }], + 'agent/inbox/inserted': [{ agent, message }], + 'agent/inbox/claimed': [{ agent, message, turn: 1 }], + 'agent/inbox/discarded': [{ agent, message }], + 'agent/session-start': [{ agent, source: 'startup' }], + 'agent/pre-step': [{ agent, messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], + 'agent/request': [{ agent, turn: 1, step: 1, signal }, () => Promise.resolve(config)], 'agent/request-error': [ - agent, { + agent, turn: 1, step: 1, provider: 'p', failure: { message: 'request', code: 'UNKNOWN' }, retryPolicy: undefined, + signal, }, - signal, () => Promise.resolve(undefined), ], - 'agent/turn-stopping': [agent, 1, signal], - 'agent/error': [agent, 1, 0, new Error('x')], + 'agent/turn-stopping': [{ agent, turn: 1, signal }], + 'agent/error': [{ agent, turn: 1, step: 0, error: new Error('x') }], } satisfies { [K in AgentEventName]: EventArgs } const rows: Array<[string, unknown[]]> = [ ...Object.entries(agentRows), ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], - ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], + ['goal/changed', [{ agent, change: { operation: 'create', ref: { id: 'goal-a', revision: 1 } } }]], ['system-prompt/assemble', [[], { scope: agent }]], ['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index adac4f28d5..d250998624 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -15,7 +15,7 @@ import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' -import { SurfaceManager } from './surface.ts' +import { deriveEventMessage, SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' @@ -29,7 +29,7 @@ export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOM export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts' export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' -export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' /** @@ -755,50 +755,13 @@ export class Session { } /** - * Project a single event into the LLM message it derives to, or null when - * it produces none — a non-surface event (chunk, boundary, log-only record) - * or an empty-content assistant/message (which exists only to host usage). - * The per-node pure function {@link deriveMessages} folds over the surface; - * an external reconstructor (or the dev invariant) folds the same function - * over a log prefix's surface to rebuild the exact messages any request was - * built from (the reconstructability Agent Note). The returned message is - * the already frozen message nested in the event wrapper and shared by - * delivery, durable history, and model requests. + * Instance face of the pure per-node `deriveEventMessage` export from + * `surface.ts`. * @param event - the event to project. * @returns the derived message, or null when the event produces none. */ deriveEventMessage(event: SessionEvent): Message | null { - // Intentionally non-exhaustive: only message-producing events derive - // history; turn/step boundaries, chunks, usage, and errors are - // trace/replay data. - - switch (event.type) { - // Ordinary prompts and injected context project in user role: the - // event's model-facing content stays verbatim. Do NOT - // re-add per-type framing (e.g. ``) here: framing is - // caller-owned — a producer bakes it into `content`, as workspace-context - // does with `` — or, if reintroduced, must be driven by - // the event `meta` map and a dedicated renderer, keeping this projection a - // verbatim pass-through. See the deferred design note in - // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md - case 'user/message': { - return event.data - } - case 'assistant/message': { - // Skip an empty-content assistant/message: it exists only to host a - // max-tokens step's usage and must not inject a content-less assistant - // turn into the provider transcript. - if (event.data.message.content.length === 0) return null - return event.data.message - } - case 'tool/result': { - return event.data.message - } - default: - // A non-surface event (boundary, chunk, log-only record) projects to - // no message. Merge-extensible union: no assertNever here. - return null - } + return deriveEventMessage(event) } } diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index f6eb6fe6a6..ad1c4b2ad3 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -8,6 +8,7 @@ * @module @deepseek-ai/dsh-session/surface */ +import type { Message } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts' /** Runtime counterpart of the message-producing event union. */ @@ -66,6 +67,52 @@ export function isReplacementSurfaceEvent( return isSurfaceEvent(event) && event.surfaceOp !== 'append' } +/** + * Project a single event into the LLM message it derives to, or null when it + * produces none — a non-surface event (chunk, boundary, log-only record) or an + * empty-content assistant/message (which exists only to host usage). This is + * THE per-node projection rule: `Session.deriveMessages` folds it over the + * live surface, external reconstructors and pure projections fold the same + * function over a log prefix's surface to rebuild the exact messages any + * request was built from. The returned message is the already frozen message + * nested in the event wrapper and shared by delivery, durable history, and + * model requests. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ +export function deriveEventMessage(event: SessionEvent): Message | null { + // Intentionally non-exhaustive: only message-producing events derive + // history; turn/step boundaries, chunks, usage, and errors are trace/replay + // data. + switch (event.type) { + // Ordinary prompts and injected context project in user role: the event's + // model-facing content stays verbatim. Do NOT re-add per-type framing + // (e.g. ``) here: framing is caller-owned — a producer bakes it + // into `content`, as workspace-context does with `` — or, + // if reintroduced, must be driven by the event `meta` map and a dedicated + // renderer, keeping this projection a verbatim pass-through. See the + // deferred design note in + // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md + case 'user/message': { + return event.data + } + case 'assistant/message': { + // Skip an empty-content assistant/message: it exists only to host a + // max-tokens step's usage and must not inject a content-less assistant + // turn into the provider transcript. + if (event.data.message.content.length === 0) return null + return event.data.message + } + case 'tool/result': { + return event.data.message + } + default: + // A non-surface event (boundary, chunk, log-only record) projects to + // no message. Merge-extensible union: no assertNever here. + return null + } +} + /** One replacement operation observed while folding a session surface. */ export interface SurfaceFoldReplacement { /** Seq of the event that replaced the prior surface range. */ diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index cb191bcada..16fc1394fa 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -8,7 +8,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' -import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm' declare module 'cordis' { interface Context { @@ -200,15 +200,39 @@ export function renderPrompt(assembly: PromptAssembly): string { * @returns the current full snapshot, or `''` when no context is active. */ export function renderContextSnapshot(assembly: PromptAssembly): string { - const body = assembly.contexts - .map(context => interpolate(context, assembly.variables, 'context')) - .filter(text => text.length > 0) - .join('\n\n') + return joinContextSections(renderContextSections(assembly)) +} + +/** + * The model-facing snapshot text for an already-rendered section list. + * + * A caller that also needs the sections renders them once and joins here, so a + * request does not interpolate every context twice. + * @param sections - sections from {@link renderContextSections}. + * @returns the current full snapshot, or `''` when no context is active. + */ +export function joinContextSections(sections: readonly ContextSnapshotSection[]): string { + const body = sections.map(section => section.text).join('\n\n') if (body.length === 0) return '' return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}` } -/** Interpolate one section or context and attribute diagnostics to its owner. */ +/** + * The same snapshot, kept as the named contributions it was assembled from. + * + * {@link renderContextSnapshot} joins these for the model; a consumer that + * presents the snapshot uses them to attribute each part to the subsystem that + * contributed it, without re-splitting the joined prose. + * @param assembly - the assembly whose contexts and variables to render. + * @returns one entry per contributing context that rendered to non-empty text. + */ +export function renderContextSections(assembly: PromptAssembly): ContextSnapshotSection[] { + return assembly.contexts + .map(context => ({ name: context.name, text: interpolate(context, assembly.variables, 'context') })) + .filter(section => section.text.length > 0) +} + +/** Interpolate one section or context and attribute diagnostics to its owning input. */ function interpolate( input: AssembledSection | AssembledContext, variables: Record, diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 1a8938534f..9c60bee081 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -48,7 +48,7 @@ async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const signal = new AbortController().signal const decision = 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: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 99e8ed7c91..87fd17c36b 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -41,7 +41,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise { const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd }) const signal = new AbortController().signal const decision = 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: [] }), ) if (decision.kind === 'enter') { @@ -476,9 +476,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(loadedRequest).toContain('Use the freshly loaded body.') const transcript = handle.agent.session.events.flatMap>((event) => { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') { + if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') { return [{ type: event.type, source: event.data.source, @@ -512,8 +510,14 @@ describe('dsh-agent-spine-demo bundle', () => { }, { "source": { - "kind": "plugin", - "plugin": "dsh-tool-skill", + "entries": [ + { + "description": "Hot-added skill", + "name": "hot-skill", + }, + ], + "form": "catalog", + "kind": "skill-catalog", }, "text": " A skill is a reusable set of task-specific instructions. The following skills are available in this session: diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 933466b2e3..574e18c583 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -45,7 +45,7 @@ async function composePrefix(ctx: Context): Promise { const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const signal = new AbortController().signal const decision = 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: [] }), ) if (decision.kind === 'enter') { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 39afe0a24e..9fd87aaf52 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -401,7 +401,7 @@ describe('runOneShot and executeCli', () => { if (session === agent.session && event.type === 'assistant/message' && event.data.turn === 1) startupStarted() }) - ctx.on('agent/turn-stopping', async (subject, turn) => { + ctx.on('agent/turn-stopping', async ({ agent: subject, turn }) => { if (subject === agent && turn === 1) await releaseStartup.promise }) agent.followup(createUserMessage({ @@ -432,7 +432,7 @@ describe('runOneShot and executeCli', () => { } let replacementQueued = false - ctx.on('agent/status', (subject, status) => { + ctx.on('agent/status', ({ agent: subject, status }) => { if (subject !== agent || status !== 'idle' || replacementQueued) return replacementQueued = true agent.followup(createUserMessage({ diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index eff2588ec2..0d700e61b6 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -25,7 +25,7 @@ export async function fsHarness(fsCwd: string, persona = ''): 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/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index b81d1cb599..5c92f048d8 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -243,20 +243,20 @@ export function apply(ctx: Context): void { // One composite effect keeps the step fence installed until this // plugin's own scheduling tasks settle. ctx.effect(function* () { - ctx.on('agent/error', (agent) => { + ctx.on('agent/error', ({ agent }) => { const state = stateFor(agent) disarm(state) }) - ctx.on('agent/created', (agent) => { stateFor(agent) }) - ctx.on('agent/disposed', (agent) => { states.delete(agent) }) - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/created', ({ agent }) => { stateFor(agent) }) + ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) }) + ctx.on('agent/session-start', ({ agent }) => { const state = stateFor(agent) state.attempt = undefined state.competingQueued = false state.needsCheckpoint = false }) - ctx.on('agent/status', (agent, status) => { + ctx.on('agent/status', ({ agent, status }) => { const state = stateFor(agent) if (status === 'idle') { state.competingQueued = false @@ -275,13 +275,13 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('goal/changed', (agent) => { + ctx.on('goal/changed', ({ agent }) => { const state = stateFor(agent) state.needsCheckpoint = true requestDrive(state) }) - ctx.on('agent/inbox/inserted', (agent, { message }) => { + ctx.on('agent/inbox/inserted', ({ agent, message }) => { if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return const state = stateFor(agent) const attempt = state.attempt @@ -289,14 +289,14 @@ export function apply(ctx: Context): void { state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) - ctx.on('agent/inbox/claimed', (agent, { message }) => { + ctx.on('agent/inbox/claimed', ({ agent, message }) => { const state = stateFor(agent) const attempt = state.attempt if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { attempt.phase = 'claimed' } }) - ctx.on('agent/inbox/discarded', (agent, { message }) => { + ctx.on('agent/inbox/discarded', ({ agent, message }) => { const state = stateFor(agent) const attempt = state.attempt if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { @@ -346,7 +346,7 @@ export function apply(ctx: Context): void { && source.round === goal.roundsStarted + 1 } - ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise => { const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } => isGoalRoundSource(message.source)) if (submitted === undefined) return next() diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index d9fd63c940..2d14abf158 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -107,7 +107,7 @@ function onInboxMessage( agent: Agent, listener: (message: UserMessage) => void, ): () => void { - return ctx.on('agent/inbox/inserted', (subject, { message }) => { + return ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => { if (subject === agent) listener(message) }) } @@ -118,7 +118,7 @@ function onClaimedMessage( agent: Agent, listener: (message: UserMessage) => void, ): () => void { - return ctx.on('agent/inbox/claimed', (subject, { message }) => { + return ctx.on('agent/inbox/claimed', ({ agent: subject, message }) => { if (subject === agent) listener(message) }) } @@ -247,7 +247,7 @@ describe('same-session goal driving', () => { it('maps a downstream step rejection to blocked without entering the round', async () => { const test = await harness([]) - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal' ? Promise.resolve({ kind: 'reject' as const }) : next()) test.ctx.goals.create(test.agent, { objective: 'respect policy' }) @@ -265,10 +265,10 @@ describe('same-session goal driving', () => { it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { const test = await harness([textResponse('human follow-up')]) - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' + test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal' ? Promise.resolve({ kind: 'reject' as const }) : next()) - test.ctx.on('goal/changed', (agent, change) => { + test.ctx.on('goal/changed', ({ agent, change }) => { if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })) }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) @@ -370,7 +370,7 @@ describe('same-session goal driving', () => { it('rechecks revision after downstream prompt hooks before admitting', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !edited) { edited = true const current = test.ctx.goals.get(agent) @@ -389,7 +389,7 @@ describe('same-session goal driving', () => { it('does not block a goal that downstream paused before rejecting its prompt', async () => { const test = await harness([]) - test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) { return next() } @@ -432,7 +432,7 @@ describe('same-session goal driving', () => { test.agent.inbox.prepend('next-step', roundZeroContext) }) let edited = false - test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { const decision = await next() if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision edited = true @@ -513,8 +513,10 @@ describe('same-session goal driving', () => { const test = await harness([]) test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) agentEvents(test.ctx, test.agent).emit('goal/changed', { - operation: 'clear', - ref: { id: GoalId('cleared-goal'), revision: 2 }, + change: { + operation: 'clear', + ref: { id: GoalId('cleared-goal'), revision: 2 }, + }, }) await new Promise((resolve) => { setImmediate(resolve) }) @@ -529,7 +531,7 @@ describe('same-session goal driving', () => { ]) // The llm-retry shape: schedule one retry for the failed goal-round request. let retried = false - test.ctx.on('agent/request-error', async (_subject) => { + test.ctx.on('agent/request-error', async (_payload) => { if (!retried) { retried = true return { kind: 'retry' } @@ -552,7 +554,7 @@ describe('same-session goal driving', () => { // attempt through cancel-requested) and THEN throws: the catch finds no // matching reservation and must not reschedule a paused goal. let fired = false - test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !fired) { fired = true agent.cancel({ kind: 'user' }) @@ -576,7 +578,7 @@ describe('same-session goal driving', () => { // Registered after goal-session's own listener: the throw propagates back // through goal-session's next() await, dropping the whole step proposal. let threw = false - test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && !threw) { threw = true throw new Error('downstream pre-step hook exploded') @@ -598,7 +600,7 @@ describe('same-session goal driving', () => { textResponse('goal round ran'), ]) let retried = false - test.ctx.on('agent/request-error', async (_subject) => { + test.ctx.on('agent/request-error', async (_payload) => { if (!retried) { retried = true return { kind: 'retry' } @@ -721,7 +723,7 @@ describe('same-session goal driving', () => { it('fails a post-hook read closed before the prompt can enter history', async () => { const test = await harness([]) let armed = true - test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && armed) { armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -809,7 +811,7 @@ describe('same-session goal driving', () => { it('rejects the step when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !cancelled) { cancelled = true agent.cancel({ kind: 'user' }) @@ -864,7 +866,7 @@ describe('same-session goal driving', () => { it('resets process-local scheduling state at a session-start edge', async () => { const test = await harness([textResponse('after explicit resume')]) const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) - agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume') + agentEvents(test.ctx, test.agent).emit('agent/session-start', { source: 'resume' }) await Promise.resolve() expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) @@ -898,7 +900,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('round one')]) test.ctx.on('session/event', (session, event) => { if (session === test.agent.session && event.type === 'turn/end') { - agentEvents(test.ctx, test.agent).emit('agent/error', event.data.turn, 1, new Error('post-turn flush failed')) + agentEvents(test.ctx, test.agent).emit('agent/error', { turn: event.data.turn, step: 1, error: new Error('post-turn flush failed') }) } }) test.ctx.goals.create(test.agent, { objective: 'stop when durability is lost', maxGoalRounds: 8 }) @@ -923,7 +925,7 @@ describe('same-session goal driving', () => { await handle.dispose() const warn = vi.spyOn(test.ctx.logger, 'warn') - agentEvents(test.ctx, handle.agent).emit('agent/error', closed.data.turn, 1, new Error('late flush failure')) + agentEvents(test.ctx, handle.agent).emit('agent/error', { turn: closed.data.turn, step: 1, error: new Error('late flush failure') }) expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined() expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session')) @@ -959,7 +961,7 @@ describe('same-session goal driving', () => { it('waits for work queued by a pause observer before considering the next round', async () => { const test = await harness(['hang', textResponse('inspection answer')]) - test.ctx.on('goal/changed', (agent, change) => { + test.ctx.on('goal/changed', ({ agent, change }) => { if (agent === test.agent && change.operation === 'pause') { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } })) } @@ -982,7 +984,7 @@ describe('same-session goal driving', () => { it('does not re-block a goal the downstream veto already saw cancelled', async () => { const test = await harness([]) let vetoed = false - test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', ({ agent, messages }, next) => { if (messages[0]?.source.kind === 'goal' && !vetoed) { vetoed = true agent.cancel({ kind: 'user' }) @@ -1007,7 +1009,7 @@ describe('same-session goal driving', () => { it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => { const test = await harness([]) let release: (() => void) | undefined - test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { + test.ctx.on('agent/pre-step', async ({ messages }, next) => { if (messages[0]?.source.kind === 'goal' && release === undefined) { await new Promise((resolve) => { release = resolve }) } diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 377ba402e2..fec44de2f3 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -134,10 +134,10 @@ declare module 'cordis' { * Goal mutation accepted by one live agent. The matching `goal/change` * session event has already committed. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - agent whose session owns the goal. - * @param change - fresh current projection or clear tombstone. + * @param payload.agent - agent whose session owns the goal. + * @param payload.change - fresh current projection or clear tombstone. * @mode emit */ - 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void + 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, payload: { agent: Agent; change: GoalChanged }): void } } diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index f6a4a99fc6..1cd3c6074a 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -193,7 +193,7 @@ export class GoalService extends Service { this.resolved = { defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), } - ctx.on('agent/session-start', (agent) => { + ctx.on('agent/session-start', ({ agent }) => { this.cache(agent.session).activation = 'disarmed' }) // The `goal` projection unit: last-wins fold of goal/change whole values @@ -547,7 +547,7 @@ export class GoalService extends Service { ref: { ...ref }, ...goal === undefined ? {} : { goal }, } - agentEvents(this.ctx, agent).emit('goal/changed', notification) + agentEvents(this.ctx, agent).emit('goal/changed', { change: notification }) } /** Build a detached current view. */ diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 58661481cf..38eea7cf61 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -83,7 +83,7 @@ describe('GoalService creation and replay', () => { vi.setSystemTime(1_700_000_000_000) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) const seen: string[] = [] - ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) }) const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) @@ -191,7 +191,7 @@ describe('GoalService creation and replay', () => { const { ctx, agent, session } = await harness() let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' }) expect(goal.activation).toBe('armed') - agentEvents(ctx, agent).emit('agent/session-start', 'resume') + agentEvents(ctx, agent).emit('agent/session-start', { source: 'resume' }) expect(ctx.goals.get(agent)?.activation).toBe('disarmed') goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) @@ -223,7 +223,7 @@ describe('GoalService creation and replay', () => { await fiber.dispose() expect(ctx.get('goals')).toBeUndefined() - agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume') + agentEvents(ctx, stub.agent).emit('agent/session-start', { source: 'resume' }) expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' }) await ctx.plugin(GoalService) @@ -384,7 +384,7 @@ describe('GoalService mutations', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const seen: string[] = [] ctx.on('goal/changed', () => { throw new Error('broken observer') }) - ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) + ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) }) expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active') expect(seen).toEqual(['create']) expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9ceed21bc9..d22ff26dc2 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' +import { boundContextSummary, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -315,7 +315,12 @@ export function apply(ctx: Context, config: Config): void { content: args.action === 'complete' ? renderWrapupContext(goal.objective) : renderWrapupContext(goal.objective, args.blocked_reason as string), - source: { kind: 'plugin', plugin: 'tool-goal' }, + source: { + kind: 'plugin', + plugin: 'tool-goal', + form: 'notice', + summary: boundContextSummary(`${args.action as string}: ${goal.objective}`), + }, })) } return Promise.resolve(goalValue(goal)) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 3b1e892ec5..d8bc950019 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -374,7 +374,12 @@ describe('goal tool state transitions', () => { expect(complete.concludesTurn).toBeUndefined() const contexts = complete.additionalContexts ?? [] expect(contexts).toHaveLength(1) - expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' }) + expect(contexts[0]?.source).toEqual({ + kind: 'plugin', + plugin: 'tool-goal', + form: 'notice', + summary: 'complete: pause cleanly', + }) const block = contexts[0]?.content[0] if (block?.type !== 'text') throw new Error('expected one text wrap-up block') expect(block.text).toContain('') @@ -399,7 +404,7 @@ describe('goal tool state transitions', () => { let turn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'continue later' }) closeTurn(root, turn) - agentEvents(ctx, root.agent).emit('agent/session-start', 'resume') + agentEvents(ctx, root.agent).emit('agent/session-start', { source: 'resume' }) expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed') turn = openTurn(root, { kind: 'user' }, '继续') const resumed = await execute(ctx, 'update_goal', { diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index d58d4f0528..464dd318da 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -200,7 +200,10 @@ export function apply(ctx: Context, config: Config): void { const text = count === thresholds[0] ? GENTLE_REMINDER : detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars)) - return createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }) + return createUserMessage({ + content: [{ type: 'text', text }], + source: { ...PLUGIN_SOURCE, form: 'notice', summary: `${exec.name} × ${count}` }, + }) } // Observe-and-enrich, never veto: count first (state advances regardless of @@ -223,7 +226,7 @@ export function apply(ctx: Context, config: Config): void { // A user interjection changes the context; repetition across it is not a // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). - ctx.on('agent/pre-step', (agent, messages, _context, next): Promise => { + ctx.on('agent/pre-step', ({ agent, messages }, next): Promise => { if (messages.some(message => message.source.kind === 'user')) chains.delete(agent) return next() }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index a7c31a2a09..40382218d8 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -32,7 +32,7 @@ async function harness(config: Config = {}): Promise { } function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) + return new Promise((resolve) => { const d = ctx.on('agent/status', ({ agent: s, status: st }) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ @@ -45,7 +45,14 @@ function reminders(agent: Agent): { text: string; source: unknown }[] { })) } -const GUARD_SOURCE = { kind: 'plugin', plugin: 'repeat-tool-guard' } +// The reminder is a `notice`-form context; its summary names the repeated +// call so a reader sees it without expanding the row. +const guardSource = (tool: string, count: number) => ({ + kind: 'plugin', + plugin: 'repeat-tool-guard', + form: 'notice', + summary: `${tool} × ${count}`, +}) describe('threshold escalation', () => { it('reminds gently at the first default threshold (3) and in detail at the second (5)', async () => { @@ -62,11 +69,11 @@ describe('threshold escalation', () => { const found = reminders(agent) expect(found).toHaveLength(2) expect(found[0]!.text).toContain('repeating the exact same tool call') - expect(found[0]!.source).toEqual(GUARD_SOURCE) + expect(found[0]!.source).toEqual(guardSource('probe', 3)) expect(found[1]!.text).toContain('consecutive_calls: 5') expect(found[1]!.text).toContain('- tool: probe') expect(found[1]!.text).toContain('{"q":"same"}') - expect(found[1]!.source).toEqual(GUARD_SOURCE) + expect(found[1]!.source).toEqual(guardSource('probe', 5)) }) it('keys the gentle text to thresholds[0], not the literal 3', async () => { @@ -327,7 +334,7 @@ describe('fold onto the downstream decision', () => { expect(found[0]!.text).toBe('downstream-ctx') expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' }) expect(found[1]!.text).toContain('repeating the exact same tool call') - expect(found[1]!.source).toEqual(GUARD_SOURCE) + expect(found[1]!.source).toEqual(guardSource('probe', 2)) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 344c42e94f..77b3a2711b 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -203,7 +203,7 @@ export function apply(ctx: Context, config: Config): void { // SessionStart injects context when its detached hook resolves; a slow hook // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -216,7 +216,7 @@ export function apply(ctx: Context, config: Config): void { // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no // matcher subject (CC ignores matchers for this event). --- - ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const content = messages.flatMap(message => message.content) const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) @@ -267,7 +267,7 @@ export function apply(ctx: Context, config: Config): void { // A blocking Stop hook steers at the stopping boundary, which makes the // machine observe pending input and run another step. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. - ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise => { + ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index f69d876d11..b7ea693583 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -520,7 +520,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - ctx.on('agent/pre-step', async (_agent, messages) => ({ + ctx.on('agent/pre-step', async ({ messages }) => ({ kind: 'enter' as const, messages: [{ ...messages[0]!, diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index e96c1a555a..304deef63c 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config): void { // SessionStart injects plain stdout when its detached hook resolves; a slow // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. - ctx.on('agent/session-start', (agent, source) => { + ctx.on('agent/session-start', ({ agent, source }) => { detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) .then((merged) => { const context = contextFrom(merged) @@ -196,7 +196,7 @@ export function apply(ctx: Context, config: Config): void { }) // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. - ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise => { + ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise => { if (messages.length === 0) return next() const payload = { ...base(ctx, agent, 'UserPromptSubmit', model), @@ -257,7 +257,7 @@ export function apply(ctx: Context, config: Config): void { // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. - ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise => { + ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 664f3cb2b4..942feaaa05 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -128,7 +128,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - ctx.on('agent/pre-step', async (_agent, messages) => ({ + ctx.on('agent/pre-step', async ({ messages }) => ({ kind: 'enter' as const, messages: [{ ...messages[0]!, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f76e6ec43c..8ee0d81334 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: aea11a0665f1dd4493da8bd09e0fbb974f93afce -README.zh.md: 36b89a50b09385fa1681a25a19c2e1bea1a2bff9 +README.md: 00b9ea075af7eb55041d48ceb3a1363e9de90397 +README.zh.md: e62e3fa99c2908afeca85b7182701f6fcdf19de9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index aea11a0665..00b9ea075a 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 36b89a50b0..e62e3fa99c 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 838e4f3a92..f528b2297e 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2506,6 +2506,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async models(request) { return ok(request, await buildModelCatalog(ctx)) }, + + async discoverModels(request, signal) { + const { settingsNs, provider, baseURL, api, apiKey } = request.payload + try { + const models = await ctx.llm.discoverModels(settingsNs, { + ...provider === undefined ? {} : { provider }, + ...baseURL === undefined ? {} : { baseURL }, + ...api === undefined ? {} : { api }, + ...apiKey === undefined ? {} : { apiKey }, + ...signal === undefined ? {} : { signal }, + }) + return ok(request, { models }) + } catch (error: unknown) { + // Every failure here is the user's next move, not a transport fault: + // a wrong endpoint, a rejected key, or a protocol with no listing all + // end at the same place — fill the models in by hand. The details + // repeat only what the caller already sent, never the credential. + return err(request, { + code: 'model-discovery-failed', + message: error instanceof Error ? error.message : String(error), + details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } }, + }) + } + }, }, events: { @@ -2595,10 +2619,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('session/disposed', (session: Session) => { queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) }), - ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { + ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => { queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) }), - ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => { + ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => { queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) })) }), ctx.on('domain/changed', (change) => { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 2292ae2ffb..4f10d92853 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -51,7 +51,7 @@ export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, Too export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' -export type { ConfigurableProviderView, LlmApi } from './llm.ts' +export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts index 4d86302c9f..6ded8c32ac 100644 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ b/packages/host/apiproxy/src/api/llm.schema.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { ConfigurableProviderView } from './llm.ts' +import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts' import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts' /** ConfigurableProviderView row of llm.providers. */ @@ -34,3 +34,30 @@ export const llmModelsValueSchema = z.object({ groups: z.array(modelProviderGroupSchema), failures: z.array(modelCatalogFailureSchema), }) satisfies z.ZodType>> + +/** DiscoveredModelView row of llm.discoverModels. */ +export const discoveredModelViewSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).optional(), + contextWindow: z.number().int().positive().optional(), + maxTokens: z.number().int().positive().optional(), +}) satisfies z.ZodType> + +/** llm.discoverModels request payload. */ +export const llmDiscoverModelsRequestSchema = z.object({ + settingsNs: z.string().min(1), + provider: z.string().min(1).optional(), + baseURL: z.string().min(1).optional(), + api: z.string().min(1).optional(), + // Write-only at the host: used for this one interrogation, never stored and + // never returned. It does ride the client's outgoing envelope like every + // other secret-bearing payload (`credentials.set`, `settings.update`), which + // `subscribeEnvelopes()` observers can see — redacting that tap is a + // configuration-plane-wide change, not this method's to make alone. + apiKey: z.string().min(1).optional(), +}) satisfies z.ZodType>> + +/** llm.discoverModels response value. */ +export const llmDiscoverModelsValueSchema = z.object({ + models: z.array(discoveredModelViewSchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index a62319fd62..edd85a52b2 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -40,4 +40,43 @@ export interface LlmApi { * failures ride `failures` without failing the sound groups. */ models(request: RpcRequest<{}>): Promise> + + /** + * Interrogate a provider endpoint the configuration surface is still + * drafting, and return the models it advertises for the user to adopt. + * + * The payload is the draft, not a stored route: `settingsNs` selects the + * adapter family that answers, and the rest comes from the form. `provider` + * names the route being edited when there is one — an adapter that already + * describes that route answers from its own registry, with better metadata + * and no network call, and needs no endpoint. A route it does not describe is + * asked over the wire, which is what `baseURL`, `api`, and `apiKey` are for. + * + * Nothing is written — the reply is candidates, and only a later + * `settings.mutate` decides what a route serves. `apiKey` is accepted here + * but never stored or returned; a provider whose key is already stored omits + * it and the endpoint answers unauthenticated or refuses. + */ + discoverModels( + request: RpcRequest<{ + settingsNs: string + provider?: string + baseURL?: string + api?: string + apiKey?: string + }>, + signal?: AbortSignal, + ): Promise> +} + +/** Wire view of one model an interrogated endpoint advertises. */ +export interface DiscoveredModelView { + /** 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 } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 0378f885e1..9a8750c722 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -66,6 +66,7 @@ export interface RpcMethodMap { 'credentials.unset': CredentialsApi['unset'] 'llm.providers': LlmApi['providers'] 'llm.models': LlmApi['models'] + 'llm.discoverModels': LlmApi['discoverModels'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 4424b5527c..2733c6e940 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -55,6 +55,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), + z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 3b92edc661..54bbb5a8cc 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -70,6 +70,15 @@ export interface RpcErrorDetailsMap { 'settings-conflict': { ns: string; expected: number; actual: number } /** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */ 'credential-rejected': { ref: string } + /** + * Interrogating a draft provider endpoint did not produce a model listing: + * no adapter family serves the namespace, the protocol has no listing this + * build can read, or the endpoint was unreachable, refused the credential, + * or answered with something else. The message is the adapter's own text — + * it is what the form shows before falling back to hand-entry — and the + * details name the endpoint asked, never the credential offered. + */ + 'model-discovery-failed': { settingsNs: string; baseURL?: string } 'title-invalid': { sessionId: SessionId } 'fork-unavailable': { sessionId: SessionId } 'subagent-parent-unavailable': { parentSessionId: SessionId } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0aa630b328..0f54d76dbc 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -55,7 +55,7 @@ import { import { credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema, } from '../api/credentials.schema.ts' -import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' +import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' import { subagentHistoryValueSchema, subagentListValueSchema, @@ -146,6 +146,7 @@ export interface IApiClient { llm: { providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise>> models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise>> + discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise>> } /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */ respond(message: ClientResponse, signal?: AbortSignal): Promise @@ -200,6 +201,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('llm.providers', payload, signal), models: (payload, signal) => this.callUnary('llm.models', payload, signal), + discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal), } readonly events: IApiClient['events'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 8feffc63b6..d41b51ad6d 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -57,7 +57,7 @@ import { import { credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema, } from '../api/credentials.schema.ts' -import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' +import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' import { subagentHistoryRequestSchema, subagentListRequestSchema, @@ -125,6 +125,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) }, 'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) }, 'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) }, + 'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index ca79ae367d..54235c0218 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -523,11 +523,16 @@ describe('llm domain', () => { ]) ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash'])) ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1'])) + // Only one namespace can answer an interrogation, so the flag follows the + // entry's namespace rather than being assumed for every row. + ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([])) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.llm.providers(request({}))) expect(value.providers).toEqual([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false }, + // An undeclared live route has no settings address, so nothing can be + // interrogated on its behalf either. { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true }, ]) }) @@ -560,3 +565,108 @@ describe('llm domain', () => { expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }]) }) }) + +describe('llm.discoverModels', () => { + it('carries a draft to its namespace and returns candidates without storing anything', async () => { + const ctx = await harness() + const seen: unknown[] = [] + ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => { + seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey }) + return Promise.resolve([ + { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, + { id: 'acme-small' }, + ]) + }) + const api = createApiProxy(ctx, DEFAULTS) + + const value = expectOk(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }))) + + expect(value.models).toEqual([ + { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, + { id: 'acme-small' }, + ]) + expect(seen).toEqual([{ + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }]) + // Interrogating a draft is a read: no namespace gained a section, and no + // credential reference was written. + expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) + .not.toContain('llm-pi-ai') + }) + + it('carries the route being edited so an adapter can answer from its own registry', async () => { + const ctx = await harness() + let probe: unknown + ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => { + probe = request_ + return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }]) + }) + const api = createApiProxy(ctx, DEFAULTS) + + const value = expectOk(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + provider: 'deepseek', + }))) + + // No endpoint at all: a route the adapter already describes needs none. + expect(probe).toEqual({ provider: 'deepseek' }) + expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }]) + }) + + it('omits a credential and protocol the draft does not name', async () => { + const ctx = await harness() + let probe: unknown + ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => { + probe = request_ + return Promise.resolve([]) + }) + const api = createApiProxy(ctx, DEFAULTS) + + expectOk(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + }))) + + // Absent fields stay absent rather than crossing as explicit undefined: + // the adapter distinguishes "no protocol named" from "protocol undefined". + expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' }) + }) + + it('reports a failed interrogation as the form\'s next move, naming no credential', async () => { + const ctx = await harness() + ctx.llm.registerModelDiscovery('llm-pi-ai', () => + Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key'))) + const api = createApiProxy(ctx, DEFAULTS) + + const error = expectErr(await api.llm.discoverModels(request({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + apiKey: 'wrong', + }))) + + expect(error.code).toBe('model-discovery-failed') + expect(error.message).toContain('answered 401; check the API key') + expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' }) + expect(JSON.stringify(error)).not.toContain('wrong') + }) + + it('reports a namespace no adapter family serves', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + + const error = expectErr(await api.llm.discoverModels(request({ + settingsNs: 'llm-deepseek', + baseURL: 'https://api.deepseek.com', + }))) + + expect(error.code).toBe('model-discovery-failed') + expect(error.message).toContain('no model discovery is registered') + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 9f6ef65e2f..83955f2d8b 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -280,7 +280,7 @@ describe('sessions.fork', () => { }) const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' } await expect(agentEvents(child.ctx, child).waterfall( - 'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback), + 'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback), )).resolves.toMatchObject({ provider: 'inherited-provider', model: 'inherited-model', diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 2a4754f144..c2dfdae7a7 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -181,13 +181,13 @@ describe('Web session model selection', () => { reasoningEffort: 'max', }) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' }) expect((await ctx.systemPrompt.assemble()).variables) .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, signal, () => Promise.resolve(seed), + 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'private-preview', diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ded1e433b1..490e0ad7f1 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -112,6 +112,7 @@ function scriptedApi(overrides: { llm: { providers: r => ok(r, { providers: [] }), models: r => ok(r, { groups: [], failures: [] }), + discoverModels: err, ...overrides.llm, }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, @@ -694,6 +695,7 @@ describe('config unary surface', () => { llm: { providers: record('llm.providers', r => ok(r, { providers: [providerRow] })), models: record('llm.models', r => ok(r, { groups: [group], failures: [] })), + discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })), }, }) const c = client(api) @@ -719,16 +721,31 @@ describe('config unary surface', () => { expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } }) const models = await c.llm.models({}) expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } }) + const discovered = await c.llm.discoverModels({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }) + expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } }) expect(seen.map(call => call.method)).toEqual([ 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', - 'llm.providers', 'llm.models', + 'llm.providers', 'llm.models', 'llm.discoverModels', ]) expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } }) expect(seen[4]?.payload) .toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 }) expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' }) + // The draft crosses whole, credential included: the host needs it for this + // one interrogation and stores none of it. + expect(seen[10]?.payload).toEqual({ + settingsNs: 'llm-pi-ai', + baseURL: 'https://gateway.acme.example/v1', + api: 'openai-completions', + apiKey: 'probe-key', + }) }) it('rejects an invalid credential reference name at the carrier boundary', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 7d41b41612..bcccfdd52e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -253,6 +253,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async models(request) { return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } } }, + async discoverModels(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } } + }, }, events: { mux: (_request, signal) => stream(muxFrames, signal), diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 60f133ae2b..de37aa69ed 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: bd28be4119a542eda8c8ee2e53aa860f9c02c17d -README.zh.md: 03306f2fd1c54f9c3c5dbc808920abce944141e5 +README.md: 11ddece6f68a752f8afc0392029c752013389d4a +README.zh.md: c73ddeb52264bf7e2d2b9a74f0a7da9194e27c16 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index bd28be4119..11ddece6f6 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 03306f2fd1..c73ddeb522 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即「不可进入」),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空白段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:浏览器侧(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染(「Loading…」胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的「显示隐藏」footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流程扩展位,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index a458ca94c7..48f9c14309 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -7,7 +7,6 @@ * cordis.yml row; no client code branches on a capability kind. The dialog's * copy is locale-registered here — the flow package owns its own strings. */ -import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -22,8 +21,8 @@ export const inject = ['slots', 'workspaces', 'locale'] /** * Client plugin body: register the dialog's dictionaries and the browse flow - * into both directory-flow holes (declaration-aware deferral — the declaring - * ui-workspace entries may activate later, and an HMR collapse re-declares). + * into both directory-flow holes through `slots.inject()` because the + * ui-workspace entries may activate later or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { @@ -78,16 +77,16 @@ export function apply(ctx: ClientContext): void { createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), t: ctx.locale.bind(LOCALE_NS), }) - ctx.effect(() => { - // One occupant, both holes, as a unit: construction or late conflicts - // (holes declared after rival providers activated) roll the whole pair - // back and fail loud — semantics owned by deferGroupRegistration. - const group = deferGroupRegistration( - ctx.slots, - ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, - BrowseDirectoryFlow, - name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow), - ) - return () => { group.dispose() } - }, 'directory-picker-browse: flow registrations') + // Both declaration lifetimes must be live before the pair installs; the + // generator makes the two registrations one transactional effect. The + // outer/inner nesting order is arbitrary; neither hole has precedence. + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, BrowseDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, BrowseDirectoryFlow) + })) } diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index d501f3e34c..e7a07fb63a 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -73,11 +73,11 @@ describe('directory-picker-browse client half', () => { for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) }) - it('rolls back the first deferral when the second hole is already occupied', async () => { + it('rolls back the outer injection when the second hole is already occupied', async () => { const b = await bench() b.declare() // Foreign occupant in the SECOND registered hole: the pair construction - // throws after the first deferral installed its subscription. + // throws after the outer injection installed its subscription. b.slots.register({ name: HOLES[1] } as never, () => null) const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } @@ -98,19 +98,21 @@ describe('directory-picker-browse client half', () => { } }) - it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => { const b = await bench() const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } process.on('unhandledRejection', onUnhandled) process.on('uncaughtException', onUnhandled) try { - // This provider activates BEFORE any hole exists: both deferrals wait. + // The rival subscribes first, so synchronous declaration notifications + // let it occupy the pair before this provider's waiting injection runs. + b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () { + yield b.slots.register({ name: HOLES[0] } as never, () => null) + yield b.slots.register({ name: HOLES[1] } as never, () => null) + })) await b.ctx.plugin({ inject: [...inject], apply }).await() b.declare() - // A rival occupies both holes ahead of the pending microtask flush. - b.slots.register({ name: HOLES[0] } as never, () => null) - b.slots.register({ name: HOLES[1] } as never, () => null) await new Promise(resolve => setTimeout(resolve, 20)) // The rival keeps both holes; this provider rolled back wholesale and // surfaced the conflict on the fail-loud channel — no partial mix. diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index de058b262b..9e4a432c5e 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/directory-picker-native/README.md -README.md: 0ff5524791094095550542de9bb525a4a633f5b5 -README.zh.md: 19915340c14249ae07d56001911165841dd550d2 +README.md: 4dd0c79d080fe097d063cfd2200e30aafc2d2d42 +README.zh.md: 8ebf2a6e978ad042e522e7e2831a508501d3eca5 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 0ff5524791..4dd0c79d08 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). -**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. Both directory-flow declarations must be live before either contribution installs. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 19915340c1..8ebf2a6e97 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 -**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index 2d5de9a9e7..af220fe5ba 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -7,7 +7,6 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -20,22 +19,22 @@ export const inject = ['slots', 'workspaces'] /** * Client plugin body: register the renderless native flow into both - * directory-flow holes (declaration-aware deferral — the declaring - * ui-workspace entries may activate later, and an HMR collapse re-declares). + * directory-flow holes through `slots.inject()` because the ui-workspace + * entries may activate later or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) - ctx.effect(() => { - // One occupant, both holes, as a unit: construction or late conflicts - // (holes declared after rival providers activated) roll the whole pair - // back and fail loud — semantics owned by deferGroupRegistration. - const group = deferGroupRegistration( - ctx.slots, - ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, - NativeDirectoryFlow, - name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow), - ) - return () => { group.dispose() } - }, 'directory-picker-native: flow registrations') + // Both declaration lifetimes must be live before the pair installs; the + // generator makes the two registrations one transactional effect. The + // outer/inner nesting order is arbitrary; neither hole has precedence. + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + })) } diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index ecddf84eb4..34eb5e40b4 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -56,7 +56,16 @@ describe('directory-picker-native client half', () => { for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) }) - it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + it('fails loudly instead of deduplicating a duplicate package row', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const duplicate = b.ctx.plugin({ inject: [...inject], apply }) + await expect(duplicate.await()).rejects.toThrow(/already has a registration/) + for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) + }) + + it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => { const b = await bench() const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } @@ -64,15 +73,14 @@ describe('directory-picker-native client half', () => { process.on('unhandledRejection', onUnhandled) process.on('uncaughtException', onUnhandled) try { - // This provider activates BEFORE any hole exists: both deferrals wait. - // (Duplicate rows of the SAME package converge silently — the deferral - // skips a hole its own component already occupies; the conflict needs - // a rival provider.) + // The rival subscribes first, so synchronous declaration notifications + // let it occupy the pair before this provider's waiting injection runs. + b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () { + yield b.slots.register({ name: HOLES[0] } as never, () => null) + yield b.slots.register({ name: HOLES[1] } as never, () => null) + })) await b.ctx.plugin({ inject: [...inject], apply }).await() b.declare() - // A rival occupies both holes ahead of the pending microtask flush. - b.slots.register({ name: HOLES[0] } as never, () => null) - b.slots.register({ name: HOLES[1] } as never, () => null) await new Promise(resolve => setTimeout(resolve, 20)) // The rival keeps both holes; this provider rolled back wholesale and // surfaced the conflict on the fail-loud channel — no partial mix. @@ -97,11 +105,11 @@ describe('directory-picker-native client half', () => { } }) - it('rolls back the first deferral when the second hole is already occupied', async () => { + it('rolls back the outer injection when the second hole is already occupied', async () => { const b = await bench() b.declare() // Foreign occupant in the SECOND registered hole: the pair construction - // throws after the first deferral installed its subscription. + // throws after the outer injection installed its subscription. b.slots.register({ name: HOLES[1] } as never, () => null) const rejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { rejections.push(reason) } 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/message.ts b/packages/llm/llm/src/message.ts index 3fa7606c6c..7db1f855a8 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -29,17 +29,99 @@ export interface ToolMessageSource { callId: CallId } +/** + * What SHAPE of information a producer-supplied context carries, declared by + * the producer beside its provenance. + * + * `MessageSource.kind` answers *who produced this*; `form` answers *what kind + * of thing it is*, and the two axes are deliberately independent — several + * producers share one form (three snapshot producers today), and one producer + * may emit more than one form over a session. + * + * The vocabulary is SEMANTIC, never visual: a value states that the content is + * a file's instructions or a catalog of available items, and a consumer decides + * what that looks like. Colors, icons, ordering, and collapse defaults are the + * consumer's business and must not enter this union. It grows one value at a + * time as producers gain the structured fields their form needs; an absent or + * unknown value is the documented default, presented as opaque content. + */ +export type ContextForm = + /** Instructions read out of workspace files the model is expected to follow. */ + | 'instructions' + /** A catalog of items available in this session, republished as it changes. */ + | 'catalog' + /** Current state, where a later snapshot from the same producer supersedes an earlier one. */ + | 'snapshot' + /** A one-off account of something that just happened; it supersedes nothing. */ + | 'notice' + /** A message another agent addressed to this one. */ + | 'relay' + /** Material lifted out of another session's log, possibly reduced on the way in. */ + | 'recall' + +/** One named contribution to a `snapshot`-form context, in assembly order. */ +export interface ContextSnapshotSection { + /** The contributing subsystem's name. */ + readonly name: string + /** That contribution's model-facing text, exactly as assembled. */ + readonly text: string +} + +/** + * Producer-declared {@link ContextForm} and the fields that form requires, + * mixed into the source shapes that carry one. + * + * Discriminated by `form` so a producer cannot declare a shape without the + * facts that shape is presented from: a `notice` must record its one-line + * account, a `snapshot` its sections. Omitting `form` stays valid — an + * undeclared context is the documented default. + */ +export type ContextFormed = + | { readonly form?: never } + | { readonly form: 'instructions' } + | { readonly form: 'catalog' } + | { + readonly form: 'snapshot' + /** The named contributions this snapshot assembled, in order. */ + readonly sections: readonly ContextSnapshotSection[] + } + | { + readonly form: 'notice' + /** One-line account of what happened, shown without expanding the row. */ + readonly summary: string + } + | { readonly form: 'relay' } + | { readonly form: 'recall' } + /** * Where a message (or injected content) came from. * Merge-extensible sum type — plugins add their own `kind`s. */ export interface MessageSourceMap { user: { kind: 'user' } - plugin: { kind: 'plugin'; plugin: string } + plugin: { kind: 'plugin'; plugin: string } & ContextFormed model: ModelMessageSource tool: ToolMessageSource } +/** + * Bound for a `notice` summary. The account rides a collapsed transcript row + * and is committed to the durable log, while its inputs — task labels, goal + * objectives, tool arguments — are caller text with no length of their own. + */ +export const CONTEXT_SUMMARY_MAX_CHARS = 120 + +/** + * Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}. + * @param summary - the producer's one-line account, of any length. + * @returns the account, ellipsized when it exceeds the bound. + */ +export function boundContextSummary(summary: string): string { + return summary.length <= CONTEXT_SUMMARY_MAX_CHARS + ? summary + : `${summary.slice(0, CONTEXT_SUMMARY_MAX_CHARS - 1)}…` +} + /** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */ export type MessageSource = MessageSourceMap[keyof MessageSourceMap] 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/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 6c5abe03df..5c5a597802 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/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/token-meter/README.md -README.md: 0935a48a5f5773fbb280bc45e07faaa05c0a4f6e -README.zh.md: 83282ab47e6d406cfca30bbd8af40e0c94050504 +README.md: 8f868f25f3c4caf1fdab5b50965aab41efecf5af +README.zh.md: 3621105ff35606b62b0587063038116b4772c6cf diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 0935a48a5f..8f868f25f3 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -23,17 +23,21 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket ## Session projections -When the composition provides `ctx.sessionProjections`, token-meter registers two units through an optional child fiber. +When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again. -`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and optional `contextWindow` from the newest `request/context` record. Pressure stays absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage. +`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — optional `projectedTokens`, and optional `contextWindow` from the newest `request/context` record. Both figures stay absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so `pressureTokens` holds still while a turn streams and steps forward when the next request reports its usage. -Both units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes both keys. A composition without the projection seam keeps the measurement service's existing behavior. +`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero and folded through the same `surface-fold.ts` the measurement service replays. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`. + +`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays `surface-fold.ts` — the same positional fold `measure()` runs — so it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. + +All three units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes all three keys. A composition without the projection seam keeps the measurement service's existing behavior. ### Context occupancy is an approximation, by design -`pressureTokens` and `contextWindow` are independent last-wins fields and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's pressure until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now. +The occupancy fields are independent last-wins records and are **not** one atomic observation of a single request. Switching models pairs the fresh capacity with the previous route's sample until the next request reports usage, and `pressureTokens` describes the last request rather than the surface as it stands right now — `projectedTokens` carries that sample forward over the surface's movement, but its anchor is still the older request. This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads `measure()` instead. A UI computes occupancy by dividing measured pressure by the separately resolved capacity for the selected model. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 83282ab47e..3621105ff3 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -23,17 +23,21 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 ## 会话投影 -当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册两个单元。 +当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册三个单元。 `tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。 -`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前压力保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。 +`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。 -两个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这两个键。不带投影 seam 的组合会保留测量服务的既有行为。 +`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到又跑完一整轮为止。占用率展示读取 `projectedTokens`。 + +`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——与 `measure()` 运行的位置折叠是同一份——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点恰好把这些明细行仍然带着的误差排除在外(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 + +三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。 ### 上下文占用率是刻意为之的近似值 -`pressureTokens` 与 `contextWindow` 是两个各自后者胜的独立字段,**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层。 +这些占用率字段各自后者胜、彼此独立,**不是**对单个请求的一次原子观测。切换模型时,新容量会与上一路由的样本配对,直到下一个请求报告用量为止;而 `pressureTokens` 描述的是最后一个请求,不是此刻的表层——`projectedTokens` 把该样本沿表层的增减推进到当下,但它的锚点仍然是那个较早的请求。 这是刻意的选择。占用率百分比是面向用户的参考数字,既不是计费记录,也不是门控输入:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。UI 用测得的压力除以为所选模型单独解析出的容量来计算占用率。 diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 80c0d99c8a..5670fe97cd 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -30,6 +30,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -41,6 +42,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts new file mode 100644 index 0000000000..c83879c63a --- /dev/null +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -0,0 +1,70 @@ +/** + * Pure fold for the heuristic context-composition projection: system prompt + * and tool schemas from the newest request envelope, conversation from the + * live surface. Prices with the same shared estimator as the meter service, + * so the three figures match `measure()`'s heuristic vocabulary exactly. + */ + +import { z } from 'zod' +import { canonicalHeader } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts' +import { foldSurfaceProjection } from './surface-projection.ts' +import type { ShadowPriceClaim } from './surface-projection.ts' +// Import for the `contextBreakdown` SessionProjectionMap key merge. +import type {} from './projection.ts' + +interface ContextBreakdownState { + systemTokens: number + toolsTokens: number + messageTokens: number + /** Shadow price armed by the immediately preceding metering event. */ + claim?: ShadowPriceClaim +} + +const breakdownSchema = z.object({ + systemTokens: z.number().int().nonnegative(), + toolsTokens: z.number().int().nonnegative(), + messageTokens: z.number().int().nonnegative(), +}).strict() + +/** + * Token-meter's context-composition projection unit. + * + * Envelope figures are last-wins per `request/header`; the message figure + * rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy + * 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> = { + key: 'contextBreakdown', + schema: breakdownSchema, + init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }), + apply: (state, event) => { + const fold = foldSurfaceProjection(state.claim, event) + let systemTokens = state.systemTokens + let toolsTokens = state.toolsTokens + if (event.type === 'request/header') { + const header = canonicalHeader(event.data.header) + systemTokens = estimateSystemTokens(header) + toolsTokens = estimateToolsTokens(header) + } + if (systemTokens === state.systemTokens + && toolsTokens === state.toolsTokens + && fold.deltaTokens === 0 + && fold.claim === undefined + && state.claim === undefined) return state + return { + systemTokens, + toolsTokens, + messageTokens: state.messageTokens + fold.deltaTokens, + ...fold.claim === undefined ? {} : { claim: fold.claim }, + } + }, + view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }), + stateVersion: 2, +} diff --git a/packages/llm/token-meter/src/estimate.ts b/packages/llm/token-meter/src/estimate.ts new file mode 100644 index 0000000000..1e02428086 --- /dev/null +++ b/packages/llm/token-meter/src/estimate.ts @@ -0,0 +1,87 @@ +/** + * Fixed-density heuristic token pricing shared by the meter service and the + * pure context-breakdown projection, so both surfaces price identical content + * to identical numbers. + * + * @module @deepseek-ai/dsh-token-meter/estimate + */ + +import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { EpochHeader } from '@deepseek-ai/dsh-session' + +/** Fixed text-density estimate used until exact tokenization is needed. */ +const CHARS_PER_TOKEN = 4 + +/** Per-block structural overhead for JSON framing and type tags. */ +const BLOCK_OVERHEAD = 4 + +/** Role-field framing overhead added to every priced message. */ +export const ROLE_OVERHEAD = 4 + +/** + * Price content blocks recursively under the fixed density heuristic. + * @param blocks - content blocks to price without mutation. + * @returns heuristic tokens including per-block structural overhead. + */ +export function estimateContent(blocks: readonly ContentBlock[]): number { + let tokens = 0 + for (const block of blocks) { + switch (block.type) { + case 'text': + case 'reasoning': + tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD + break + case 'tool-call': + tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) + + BLOCK_OVERHEAD + break + case 'tool-result': + tokens += estimateContent(block.content) + BLOCK_OVERHEAD + break + default: + // ContentBlockMap is merge-extensible; unknown blocks retain a + // conservative structural JSON price under the fixed heuristic. + tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN) + } + } + return tokens +} + +/** + * Heuristically price one model-visible message. + * @param message - message to price without mutation. + * @returns content and role-framing tokens under the fixed heuristic. + */ +export function estimateMessage(message: Message): number { + return estimateContent(message.content) + ROLE_OVERHEAD +} + +/** + * Price the system-prompt part of a canonical request envelope. + * @param header - canonical envelope, or undefined before any request. + * @returns heuristic system-prompt tokens; 0 when absent. + */ +export function estimateSystemTokens(header: EpochHeader | undefined): number { + if (header?.system === undefined) return 0 + return Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD +} + +/** + * Price the tool-schema part of a canonical request envelope. + * @param header - canonical envelope, or undefined before any request. + * @returns heuristic tool-schema tokens; 0 when absent or empty. + */ +export function estimateToolsTokens(header: EpochHeader | undefined): number { + if (header?.tools === undefined || header.tools.length === 0) return 0 + return Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD +} + +/** + * Price the complete non-surface request envelope. + * @param header - canonical envelope, or undefined before any request. + * @returns heuristic system plus tool tokens. + */ +export function estimateHeader(header: EpochHeader | undefined): number { + return estimateSystemTokens(header) + estimateToolsTokens(header) +} diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 369c6e5e85..31991b482a 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -7,8 +7,8 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' -import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' +import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' // Type-only: resolves the optional projection registry Context seam. import type {} from '@deepseek-ai/dsh-session-projection' @@ -18,19 +18,13 @@ import type { TokenMeterConfig, TokenSurfaceNode, } from './types.ts' +import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts' import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts' +import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts' +import { foldSurfaceTokens } from './surface-fold.ts' export type * from './types.ts' -/** Fixed text-density estimate used until exact tokenization is needed. */ -const CHARS_PER_TOKEN = 4 - -/** Per-block structural overhead for JSON framing and type tags. */ -const BLOCK_OVERHEAD = 4 - -/** Role-field framing overhead added to every priced message. */ -const ROLE_OVERHEAD = 4 - interface MeasurementAnchor { readonly header: EpochHeader | undefined readonly surfaceTokens: number @@ -46,11 +40,6 @@ interface ReplayState { anchor: MeasurementAnchor | undefined } -interface PreparedSurfaceMutation { - readonly tokens: number - commit(state: ReplayState): void -} - /** Sum disjoint provider usage buckets without double-counting reasoning output. */ function usageTokens(usage: TokenUsage): number { return usage.inputTokens @@ -98,6 +87,7 @@ export class TokenMeterService extends Service { ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition) projectionCtx.sessionProjections.register(contextPressureProjectionDefinition) + projectionCtx.sessionProjections.register(contextBreakdownProjectionDefinition) }) // Readers catch up independently, while eager observation bounds ordinary @@ -141,7 +131,7 @@ export class TokenMeterService extends Service { } else { baseline = { kind: 'estimated', - tokens: this._estimateHeader(header) + state.surfaceTokens, + tokens: estimateHeader(header) + state.surfaceTokens, } surfaceDeltaTokens = 0 } @@ -157,12 +147,13 @@ export class TokenMeterService extends Service { } /** - * Heuristically price one model-visible message. + * Heuristically price one model-visible message (instance face of the pure + * `estimateMessage` export from `estimate.ts`). * @param message - message to price without mutation. * @returns content and role-framing tokens under the fixed service heuristic. */ estimateMessage(message: Message): number { - return this._estimateContent(message.content) + ROLE_OVERHEAD + return estimateMessage(message) } /** Catch one session's fold up to the current durable tail. */ @@ -224,7 +215,7 @@ export class TokenMeterService extends Service { } const surface = isSurfaceEvent(event) - ? this._prepareSurfaceMutation(session, state, event) + ? foldSurfaceTokens(state.surface, event) : undefined if (event.type === 'assistant/message') { @@ -246,7 +237,7 @@ export class TokenMeterService extends Service { ) const anchorSurfaceTokens = stepStart.surfaceTokens + providerAssistantTokens const providerTokens = usageTokens(event.data.usage) - const estimatedAnchorTokens = this._estimateHeader(nextHeader) + anchorSurfaceTokens + const estimatedAnchorTokens = estimateHeader(nextHeader) + anchorSurfaceTokens nextAnchor = { header: nextHeader, surfaceTokens: anchorSurfaceTokens, @@ -263,7 +254,7 @@ export class TokenMeterService extends Service { surfaceTokens: anchorSurfaceTokens, baseline: { kind: 'estimated', - tokens: this._estimateHeader(nextHeader) + anchorSurfaceTokens, + tokens: estimateHeader(nextHeader) + anchorSurfaceTokens, }, } } @@ -271,53 +262,13 @@ export class TokenMeterService extends Service { state.header = nextHeader state.stepStart = nextStepStart - if (surface !== undefined) surface.commit(state) + if (surface !== undefined) { + state.surface = surface.nodes + state.surfaceTokens += surface.deltaTokens + } state.anchor = nextAnchor } - /** Validate one surface operation and return its allocation-light commit. */ - private _prepareSurfaceMutation( - session: Session, - state: ReplayState, - event: SurfaceEvent, - ): PreparedSurfaceMutation { - const tokens = this._estimateSurfaceEvent(session, event) - const op = event.surfaceOp - if (op === 'append') { - return { - tokens, - commit(target) { - target.surface.push({ seq: event.seq, tokens }) - target.surfaceTokens += tokens - }, - } - } - - const startIdx = state.surface.findIndex(node => node.seq === op.start) - const endIdx = state.surface.findIndex(node => node.seq === op.end) - if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { - throw new Error( - `token meter: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, - ) - } - const removedTokens = state.surface - .slice(startIdx, endIdx + 1) - .reduce((total, node) => total + node.tokens, 0) - return { - tokens, - commit(target) { - target.surface.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) - target.surfaceTokens += tokens - removedTokens - }, - } - } - - /** Price one current surface event exactly as it projects to a request. */ - private _estimateSurfaceEvent(session: Session, event: SurfaceEvent): number { - const message = session.deriveEventMessage(event) - return message === null ? 0 : this.estimateMessage(message) - } - /** * Reassemble provider output from exact chunk provenance for a usage anchor. * Missing legacy provenance conservatively treats the durable output as the @@ -355,46 +306,7 @@ export class TokenMeterService extends Service { assembler.push(sourceEvent.data.chunk) } const providerContent = assembler.blocks() - return providerContent.length === 0 ? 0 : this._estimateContent(providerContent) + ROLE_OVERHEAD - } - - /** Price content blocks recursively under the fixed density heuristic. */ - private _estimateContent(blocks: readonly ContentBlock[]): number { - let tokens = 0 - for (const block of blocks) { - switch (block.type) { - case 'text': - case 'reasoning': - tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD - break - case 'tool-call': - tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) - + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) - + BLOCK_OVERHEAD - break - case 'tool-result': - tokens += this._estimateContent(block.content) + BLOCK_OVERHEAD - break - default: - // ContentBlockMap is merge-extensible; unknown blocks retain a - // conservative structural JSON price under the fixed heuristic. - tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN) - } - } - return tokens - } - - /** Price the canonical non-surface request envelope. */ - private _estimateHeader(header: EpochHeader | undefined): number { - if (header === undefined) return 0 - let tokens = 0 - if (header.system !== undefined) { - tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD - } - if (header.tools !== undefined && header.tools.length > 0) { - tokens += Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD - } - return tokens + return providerContent.length === 0 ? 0 : estimateContent(providerContent) + ROLE_OVERHEAD } } diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index e0bbe268e1..53ae13c466 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -17,9 +17,14 @@ export const inject = ['invariants'] /** * No runtime invariant: token estimates are per-call outputs and the private * session cache is invalidated at its event mutation boundary. The package's - * projection does expose an observation stream, but its schema fixes the JSON - * payload and its pure fold replaces same-step samples; totals need not be - * monotone when a final usage sample corrects an earlier chunk. + * three projections do expose observation streams, but their schemas fix the + * JSON payloads; the usage folds replace same-step samples, so totals need not + * be monotone when a final sample corrects an earlier chunk, and the + * composition fold prices through the same `estimate.ts` heuristic as the + * measurement service and subtracts producer-logged shadow prices derived + * from that service's own nodes, which makes its message figure equal + * `measure().surfaceTokens` by construction rather than by a relation worth + * observing at runtime. */ const install: InvariantInstaller = () => {} diff --git a/packages/llm/token-meter/src/projection.ts b/packages/llm/token-meter/src/projection.ts index 080f593774..5c451617e9 100644 --- a/packages/llm/token-meter/src/projection.ts +++ b/packages/llm/token-meter/src/projection.ts @@ -20,13 +20,12 @@ export interface TokenUsageProjection { /** * Approximate context occupancy for a status display. * - * The two fields, when present, are deliberately NOT one atomic request - * observation: `pressureTokens` is the newest provider-reported prompt size, - * `contextWindow` the newest recorded route capacity. Switching models can - * therefore pair a fresh capacity with the previous route's pressure until the - * next request reports usage. This is an intentional trade — the value is a - * user-facing reference, not a billing or gating input. See the token-meter - * README for the full rationale. + * The fields, when present, are deliberately NOT one atomic request + * observation: each is a last-wins record of a different moment. Switching + * models can therefore pair a fresh capacity with the previous route's + * pressure until the next request reports usage. This is an intentional trade + * — the value is a user-facing reference, not a billing or gating input. See + * the token-meter README for the full rationale. */ export interface ContextPressureProjection { /** @@ -35,15 +34,44 @@ export interface ContextPressureProjection { * grow as the current turn streams. Absent until a provider reports usage. */ pressureTokens?: number + /** + * What the NEXT request's prompt would cost: {@link pressureTokens} plus the + * heuristic repricing of everything the surface gained or lost since that + * sample. Only the delta is estimated, so the figure stays anchored to the + * provider while still reacting the moment a compaction shadows a span — + * which `pressureTokens` alone cannot do, since compaction reports no usage + * of its own. Absent until a provider reports usage. + */ + projectedTokens?: number /** Newest recorded route capacity; absent when no adapter advertised one. */ contextWindow?: number } +/** + * Heuristic composition of the next request's context: what the prompt is + * made of, not what it costs. All three figures use the meter's fixed + * density estimate, so they will not sum to the provider-anchored + * `projectedTokens`: the estimator systematically underprices CJK text and + * JSON schemas, which is exactly the error the anchoring in + * {@link ContextPressureProjection.projectedTokens} keeps out of the occupancy + * figure. Present these as approximations of composition, never as a total. + */ +export interface ContextBreakdownProjection { + /** Heuristic tokens of the newest request envelope's system prompt; 0 before any request. */ + systemTokens: number + /** Heuristic tokens of the newest request envelope's tool schemas; 0 before any request. */ + toolsTokens: number + /** Heuristic tokens of the current model-visible conversation surface. */ + messageTokens: number +} + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { /** Provider-reported usage accumulated across the complete durable log. */ tokenUsage: TokenUsageProjection /** Newest request pressure paired with the newest known route capacity. */ contextPressure: ContextPressureProjection + /** Heuristic system/tools/message composition of the next request. */ + contextBreakdown: ContextBreakdownProjection } } diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts new file mode 100644 index 0000000000..2848025b19 --- /dev/null +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -0,0 +1,65 @@ +/** + * The measurement service's positional surface fold: the per-node priced + * 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. 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 + */ + +import { deriveEventMessage } from '@deepseek-ai/dsh-session' +import type { SurfaceEvent } from '@deepseek-ai/dsh-session' +import type { TokenSurfaceNode } from './types.ts' +import { estimateMessage } from './estimate.ts' + +/** One surface event's placement and cost against the surface preceding it. */ +export interface SurfaceTokenFold { + /** Heuristic price of the event's own message; 0 when it derives none. */ + readonly tokens: number + /** The surface after the event, detached from the input. */ + readonly nodes: TokenSurfaceNode[] + /** Signed change in the surface total: `tokens` minus anything shadowed. */ + readonly deltaTokens: number +} + +/** + * Fold one surface event onto a priced surface. + * + * Total and allocation-fresh: the caller assigns the result rather than + * mutating in place, so a throw here leaves the caller's state untouched and + * the same malformed event fails identically on every retry. + * @param nodes - the priced surface preceding this event, in model-visible order. + * @param event - the surface event to place. + * @returns the event's price, the next surface, and the signed total delta. + * @throws when a replacement names a range absent from `nodes` — committed + * logs are surface-validated at append time, so an unresolvable range is log + * corruption and must fail loud rather than skip the event. + */ +export function foldSurfaceTokens( + nodes: readonly TokenSurfaceNode[], + event: SurfaceEvent, +): SurfaceTokenFold { + const message = deriveEventMessage(event) + const tokens = message === null ? 0 : estimateMessage(message) + const op = event.surfaceOp + if (op === 'append') { + return { tokens, nodes: [...nodes, { seq: event.seq, tokens }], deltaTokens: tokens } + } + const startIdx = nodes.findIndex(node => node.seq === op.start) + const endIdx = nodes.findIndex(node => node.seq === op.end) + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + throw new Error( + `token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, + ) + } + const removed = nodes + .slice(startIdx, endIdx + 1) + .reduce((total, node) => total + node.tokens, 0) + const next = [...nodes] + next.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) + return { tokens, nodes: next, deltaTokens: tokens - removed } +} diff --git a/packages/llm/token-meter/src/surface-projection.ts b/packages/llm/token-meter/src/surface-projection.ts new file mode 100644 index 0000000000..9c42d5248e --- /dev/null +++ b/packages/llm/token-meter/src/surface-projection.ts @@ -0,0 +1,94 @@ +/** + * The O(1) surface-token fold shared by the token-meter projection units. + * + * A projection state must stay bounded — the persisted projection cache + * checkpoints every unit's whole state, so carrying the priced surface + * (one node per model-visible message) would grow a checkpoint without + * bound over the session's life. Instead, replacements ride the compact + * seam's shadow-price protocol: the metering event immediately before a + * surface `replace` (`compact/summary` or `compact/prune`) states the + * 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. 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 + */ + +import { deriveEventMessage, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +// Type-only: the `compact/*` SessionEventMap merges (shadow-price events). +import type {} from '@deepseek-ai/dsh-compact' +import { estimateMessage } from './estimate.ts' + +/** + * One armed shadow price: the heuristic tokens of the surface range the + * IMMEDIATELY following event replaces. Plain JSON — it is part of the + * persisted unit state while armed. + */ +export interface ShadowPriceClaim { + /** Declared inclusive first surface-node seq of the priced range. */ + start: number + /** Declared inclusive last surface-node seq of the priced range. */ + end: number + /** Heuristic tokens of the priced range under the fixed estimator. */ + tokens: number +} + +/** One event's effect on a running surface-token total. */ +export interface SurfaceTokensFold { + /** Signed change in the surface total; 0 for events off the surface. */ + readonly deltaTokens: number + /** Claim to carry into the next event; undefined when none survives. */ + readonly claim: ShadowPriceClaim | undefined +} + +/** + * 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` 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 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, + event: SessionEvent, +): SurfaceTokensFold { + if (event.type === 'compact/summary' || event.type === 'compact/prune') { + const { shadowedRange, shadowedTokenCount } = event.data + return { + deltaTokens: 0, + claim: { start: shadowedRange.start, end: shadowedRange.end, tokens: shadowedTokenCount }, + } + } + if (!isSurfaceEvent(event)) return { deltaTokens: 0, claim: undefined } + const message = deriveEventMessage(event) + const tokens = message === null ? 0 : estimateMessage(message) + const op = event.surfaceOp + if (op === 'append') return { deltaTokens: tokens, claim: undefined } + // 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` + + ` (armed claim covers ${claim.start}-${claim.end})`, + ) + } + return { deltaTokens: tokens - claim.tokens, claim: undefined } +} diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index be94993f35..779bd8e271 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -6,7 +6,7 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' -export type { ContextPressureProjection, TokenUsageProjection } from './projection.ts' +export type { ContextBreakdownProjection, ContextPressureProjection, TokenUsageProjection } from './projection.ts' /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 302485da8e..a7fc9debf0 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -4,8 +4,11 @@ import { z } from 'zod' import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts' +import { foldSurfaceProjection } from './surface-projection.ts' +import type { ShadowPriceClaim } from './surface-projection.ts' interface UsageSample { turn: number @@ -60,6 +63,7 @@ const projectionSchema = z.object({ // `number | undefined` where the interface declares absent-or-number fields. const pressureSchema = z.object({ pressureTokens: z.number().int().nonnegative().optional(), + projectedTokens: z.number().int().nonnegative().optional(), contextWindow: z.number().int().positive().optional(), }).strict() as unknown as z.ZodType @@ -67,6 +71,29 @@ const pressureSchema = z.object({ const pressureFrom = (usage: TokenUsage): number => usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) +/** The usage a chunk or finalized message reports for its step, if any. */ +const usageOf = (event: SessionEvent): TokenUsage | undefined => + event.type === 'assistant/chunk' && event.data.chunk.type === 'usage' + ? event.data.chunk.usage + : event.type === 'assistant/message' + ? event.data.usage + : undefined + +/** + * Context-occupancy state: the two independent last-wins records plus the + * O(1) running surface total needed to carry the newest sample forward. + */ +interface ContextPressureState { + contextWindow?: number + pressureTokens?: number + /** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */ + surfaceTokens: number + /** {@link surfaceTokens} at the newest usage sample; absent until one lands. */ + sampledSurfaceTokens?: number + /** Shadow price armed by the immediately preceding metering event. */ + claim?: ShadowPriceClaim +} + /** * Token-meter's session projection unit. * @@ -115,39 +142,65 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = { /** * Token-meter's context-occupancy projection unit. * - * Two independent last-wins slots: the newest usage sample supplies the + * Independent last-wins slots: the newest usage sample supplies the provider * numerator, the newest `request/context` record the denominator. Both are * whole values, so replay order alone decides the result and no cross-field * consistency is claimed — the pair is explicitly not one atomic request * observation (see {@link ContextPressureProjection}). * - * The numerator is prompt-side only, so it holds still while a turn streams - * and steps forward once the next request reports its usage. + * `pressureTokens` is prompt-side only, so it holds still while a turn streams + * and steps forward once the next request reports its usage. Because nothing + * but a request reports usage, it also cannot see a compaction: the fold + * therefore carries a running surface total alongside it and publishes + * `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 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', ContextPressureProjection> = { +ProjectionDefinition<'contextPressure', ContextPressureState> = { key: 'contextPressure', schema: pressureSchema, - init: () => ({}), + init: () => ({ surfaceTokens: 0 }), apply: (state, event) => { + const fold = foldSurfaceProjection(state.claim, event) + let next = state if (event.type === 'request/context') { const contextWindow = event.data.contextWindow - if (contextWindow === state.contextWindow) return state - if (contextWindow !== undefined) return { ...state, contextWindow } - const { contextWindow: _removed, ...withoutContextWindow } = state - return withoutContextWindow + if (contextWindow !== state.contextWindow) { + if (contextWindow !== undefined) { + next = { ...next, contextWindow } + } else { + const { contextWindow: _removed, ...withoutContextWindow } = next + next = withoutContextWindow + } + } } - const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage' - ? event.data.chunk.usage - : event.type === 'assistant/message' - ? event.data.usage - : undefined - if (usage === undefined) return state - const pressureTokens = pressureFrom(usage) - return pressureTokens === state.pressureTokens - ? state - : { ...state, pressureTokens } + const usage = usageOf(event) + if (usage !== undefined) { + const pressureTokens = pressureFrom(usage) + if (pressureTokens !== next.pressureTokens || next.sampledSurfaceTokens !== next.surfaceTokens) { + next = { ...next, pressureTokens, sampledSurfaceTokens: next.surfaceTokens } + } + } + if (fold.deltaTokens !== 0) { + next = { ...next, surfaceTokens: next.surfaceTokens + fold.deltaTokens } + } + // A defined fold.claim is always freshly built, so presence decides claim + // bookkeeping: no claim before or after this event leaves `next` as is. + if (state.claim === undefined && fold.claim === undefined) return next + const { claim: _expired, ...withoutClaim } = next + return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim } }, - view: state => state, - stateVersion: 2, + view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({ + ...contextWindow === undefined ? {} : { contextWindow }, + ...pressureTokens === undefined ? {} : { pressureTokens }, + ...pressureTokens === undefined || sampledSurfaceTokens === undefined + ? {} + : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) }, + }), + stateVersion: 4, } diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts new file mode 100644 index 0000000000..20cb2cc819 --- /dev/null +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -0,0 +1,309 @@ +// contextBreakdown projection: heuristic system/tools/message composition, +// plus the shared estimator's pricing branches. + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' +import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' +import { + estimateContent, + estimateHeader, + estimateMessage, + estimateSystemTokens, + estimateToolsTokens, +} from '../src/estimate.ts' + +const CONFIG = { provider: 'test', model: 'test-model' } + +const TOOLS: ToolSchema[] = [{ + name: 'bash', + description: 'run a command', + parameters: { type: 'object', properties: {} }, +}] + +async function harness(): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(TokenMeterService) + return { ctx, session: ctx.sessions.create() } +} + +const projected = (ctx: Context, session: Session): ContextBreakdownProjection => { + const value = ctx.sessionProjections.snapshot(session).values.contextBreakdown + if (value === undefined) throw new Error('contextBreakdown projection is not registered') + return value +} + +function appendUser(session: Session, text: string): number { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }).seq +} + +/** + * Meter one upcoming replacement the way compact-basic does: price the + * replaced span from the measurement service's own nodes and log the + * shadow-price event directly before the replace. + */ +function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void { + const nodes = ctx.tokenMeter.measure(session).nodes + const startIdx = nodes.findIndex(node => node.seq === start) + const endIdx = nodes.findIndex(node => node.seq === end) + const shadowed = nodes.slice(startIdx, endIdx + 1) + session.append('compact/summary', { + summary: [{ type: 'text', text: 'summary' }], + shadowedRange: { start, end }, + shadowedSeqs: shadowed.map(node => node.seq), + shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0), + provider: 'mock', + model: 'mock', + }) +} + +describe('contextBreakdown session projection', () => { + it('serves zeros for an empty log', async () => { + const { ctx, session } = await harness() + expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }) + }) + + it('prices the newest envelope last-wins and pushes no change for a restated one', async () => { + const { ctx, session } = await harness() + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.', tools: TOOLS }, + reason: 'initial', + }) + expect(projected(ctx, session)).toEqual({ + systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }), + toolsTokens: estimateToolsTokens({ config: CONFIG, tools: TOOLS }), + messageTokens: 0, + }) + + const changed: string[] = [] + ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) }) + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.', tools: TOOLS }, + reason: 'change', + }) + session.append('todo/write', { todos: [] }) + expect(changed).not.toContain('contextBreakdown') + + // A system-less, tool-less envelope prices back to zero. + session.append('request/header', { header: { config: CONFIG }, reason: 'change' }) + expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }) + }) + + it('sums surface appends and skips an empty-content assistant message', async () => { + const { ctx, session } = await harness() + appendUser(session, 'abcd') + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + usage: { inputTokens: 9, outputTokens: 0 }, + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + session.append('step/end', { turn: 1, step: 1 }) + // 'abcd' prices to 9 (1 text + 4 block + 4 role); the usage-only assistant + // message derives to no transcript entry and adds nothing. + expect(projected(ctx, session).messageTokens).toBe(9) + }) + + it('shrinks the message figure when a metered replacement compacts the surface', async () => { + const { ctx, session } = await harness() + const first = appendUser(session, 'before compaction, a longer message') + const second = appendUser(session, 'and a second entry') + const summary = createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }) + appendSummaryMeter(ctx, session, first, second) + session.append('user/message', summary, { + surfaceOp: { op: 'replace', start: first, end: second }, + sourceEventSeqs: [first, second], + }) + expect(projected(ctx, session).messageTokens).toBe(estimateMessage(summary)) + }) + + it('keeps the message figure equal to the service surface across appends and a compaction', async () => { + const { ctx, session } = await harness() + // The panel's composition rows and `measure()` answer the same question in + // the same vocabulary; one shared fold is what makes that true. + const agree = (): number => { + const messageTokens = projected(ctx, session).messageTokens + expect(messageTokens).toBe(ctx.tokenMeter.measure(session).surfaceTokens) + return messageTokens + } + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.', tools: TOOLS }, + reason: 'initial', + }) + expect(agree()).toBe(0) + + const question = appendUser(session, 'a first question, long enough to price above zero') + session.append('step/start', { turn: 1, step: 1 }) + const answer = session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'a considered answer' }], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + usage: { inputTokens: 40, outputTokens: 7 }, + }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq + session.append('step/end', { turn: 1, step: 1 }) + const grown = agree() + expect(grown).toBeGreaterThan(0) + + appendSummaryMeter(ctx, session, question, answer) + // The armed shadow price must not move the published figure by itself. + expect(agree()).toBe(grown) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: answer }, + sourceEventSeqs: [question, answer], + }) + expect(agree()).toBeLessThan(grown) + }) + + 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', + seq: 9, + time: 0, + data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), + surfaceOp: { op: 'replace', start, end }, + sourceEventSeqs: [start, end], + } as unknown as SessionEvent) + const append = (seq: number): SessionEvent => ({ + type: 'user/message', + seq, + time: 0, + data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), + surfaceOp: 'append', + } as unknown as SessionEvent) + const meter = (start: number, end: number, seq: number): SessionEvent => ({ + type: 'compact/prune', + seq, + time: 0, + data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 }, + } as unknown as SessionEvent) + let state = definition.init() + state = definition.apply(state, append(1)) + state = definition.apply(state, append(3)) + // 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, 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.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) + .toBe(definition.view(state).messageTokens - 5 + estimateMessage( + createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }), + )) + }) + + it('keeps the persisted checkpoint O(1) as the surface grows and compacts', async () => { + const { ctx, session } = await harness() + const first = appendUser(session, 'the first of many messages') + for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`) + const last = appendUser(session, 'the last message before compaction') + const stateKeys = (): string[] => { + const row = ctx.sessionProjections.checkpoint(session)['contextBreakdown'] + if (row === undefined) throw new Error('contextBreakdown checkpoint row is missing') + return Object.keys(row.val as Record).sort() + } + // Growth adds no per-node bookkeeping to the durable state. + expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens']) + const shadowed = session.surface.nodes.slice( + session.surface.nodes.indexOf(first), + session.surface.nodes.indexOf(last) + 1, + ) + appendSummaryMeter(ctx, session, first, last) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: first, end: last }, + sourceEventSeqs: [...shadowed], + }) + expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens']) + expect(projected(ctx, session).messageTokens) + .toBe(ctx.tokenMeter.measure(session).surfaceTokens) + }) + + it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const meterFiber = await ctx.plugin(TokenMeterService) + const session = ctx.sessions.create() + session.append('request/header', { + header: { config: CONFIG, system: 'You are terse.' }, + reason: 'initial', + }) + appendUser(session, 'abcd') + const checkpoint = JSON.parse(JSON.stringify( + ctx.sessionProjections.checkpoint(session), + )) as ReturnType + + await meterFiber.dispose() + expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextBreakdown') + + await ctx.plugin(TokenMeterService) + expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual({ + systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }), + toolsTokens: 0, + messageTokens: 9, + }) + }) +}) + +describe('shared estimator', () => { + it('prices every content-block shape under the fixed heuristic', () => { + expect(estimateContent([{ type: 'text', text: 'abcd' }])).toBe(5) + expect(estimateContent([{ type: 'reasoning', text: 'abcdefgh' }] as ContentBlock[])).toBe(6) + expect(estimateContent([{ type: 'tool-call', id: 'c' as never, name: 'bash', arguments: '{"a":1}' }])).toBe(7) + expect(estimateContent([{ + type: 'tool-result', toolCallId: 'c' as never, + content: [{ type: 'text', text: 'abcd' }], + }])).toBe(9) + const unknown = { type: 'mystery', payload: 'abc' } as unknown as ContentBlock + expect(estimateContent([unknown])).toBe(4 + Math.ceil(JSON.stringify(unknown).length / 4)) + }) + + it('prices envelope parts independently and absent parts to zero', () => { + expect(estimateSystemTokens(undefined)).toBe(0) + expect(estimateSystemTokens({ config: CONFIG })).toBe(0) + expect(estimateSystemTokens({ config: CONFIG, system: 'abcdefgh' })).toBe(6) + expect(estimateToolsTokens(undefined)).toBe(0) + expect(estimateToolsTokens({ config: CONFIG, tools: [] })).toBe(0) + expect(estimateToolsTokens({ config: CONFIG, tools: TOOLS })) + .toBe(Math.ceil(JSON.stringify(TOOLS).length / 4) + 4) + expect(estimateHeader(undefined)).toBe(0) + expect(estimateHeader({ config: CONFIG, system: 'abcdefgh', tools: TOOLS })) + .toBe(6 + Math.ceil(JSON.stringify(TOOLS).length / 4) + 4) + }) +}) 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 4079916a8c..0307b96f46 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -70,6 +70,26 @@ const projected = (ctx: Context, session: Session): TokenUsageProjection => { return value } +/** + * Meter one upcoming replacement the way compact-basic does: price the + * replaced span from the measurement service's own nodes and log the + * shadow-price event directly before the replace. + */ +function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void { + const nodes = ctx.tokenMeter.measure(session).nodes + const startIdx = nodes.findIndex(node => node.seq === start) + const endIdx = nodes.findIndex(node => node.seq === end) + const shadowed = nodes.slice(startIdx, endIdx + 1) + session.append('compact/summary', { + summary: [{ type: 'text', text: 'summary' }], + shadowedRange: { start, end }, + shadowedSeqs: shadowed.map(node => node.seq), + shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0), + provider: 'mock', + model: 'mock', + }) +} + describe('tokenUsage session projection', () => { it('serves zero buckets for an empty log', async () => { const { ctx, session } = await harness() @@ -184,6 +204,7 @@ describe('tokenUsage session projection', () => { content: [{ type: 'text', text: 'before compaction' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + appendSummaryMeter(ctx, session, before.seq, before.seq) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted' }], source: { kind: 'plugin', plugin: 'test' }, @@ -235,6 +256,34 @@ function recordContext(session: Session, model: string, contextWindow?: number): }) } +/** Append one model-visible user turn and return its surface seq. */ +function appendUser(session: Session, text: string): number { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }).seq +} + +/** Append one finalized assistant turn carrying its provider usage. */ +function appendAssistant( + session: Session, + text: string, + usage: TokenUsage, + turn: number, + step: number, +): number { + return session.append('assistant/message', { + turn, + step, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + usage, + }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq +} + describe('contextPressure session projection', () => { it('serves no pressure or capacity for an empty log', async () => { const { ctx, session } = await harness() @@ -277,9 +326,13 @@ describe('contextPressure session projection', () => { startStep(session, 1, 1) recordContext(session, 'small', 64_000) usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1) - expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 64_000 }) + expect(pressure(ctx, session)).toEqual({ + pressureTokens: 100, projectedTokens: 100, contextWindow: 64_000, + }) recordContext(session, 'large', 256_000) - expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 }) + expect(pressure(ctx, session)).toEqual({ + pressureTokens: 100, projectedTokens: 100, contextWindow: 256_000, + }) }) it('removes an older capacity when the newest route advertises none', async () => { @@ -288,7 +341,7 @@ describe('contextPressure session projection', () => { recordContext(session, 'small', 64_000) usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1) recordContext(session, 'unknown') - expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 }) + expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, projectedTokens: 100 }) }) it('pushes no change for unrelated events or a restated capacity', async () => { @@ -319,7 +372,7 @@ describe('contextPressure session projection', () => { const checkpoint = JSON.parse(JSON.stringify( ctx.sessionProjections.checkpoint(session), )) as ReturnType - expect(checkpoint.contextPressure?.ver).toBe(2) + expect(checkpoint.contextPressure?.ver).toBe(4) await meterFiber.dispose() expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure') @@ -327,7 +380,81 @@ describe('contextPressure session projection', () => { await ctx.plugin(TokenMeterService) expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextPressure).toEqual({ pressureTokens: 42, + projectedTokens: 42, contextWindow: 64_000, }) }) + + it('carries the sample forward over surface growth and a compaction', async () => { + const { ctx, session } = await harness() + recordContext(session, 'large', 128_000) + const question = appendUser(session, 'a first question worth a few tokens') + startStep(session, 1, 1) + // The provider prices the prompt its request actually carried; the sample + // must anchor against the surface as of that request, not after the + // assistant message joins it. + const answer = appendAssistant(session, 'an answer of some length', { inputTokens: 900, outputTokens: 20 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + const afterTurn = pressure(ctx, session) + expect(afterTurn.pressureTokens).toBe(900) + // The assistant message landed after the sample, so it already shows. + expect(afterTurn.projectedTokens).toBeGreaterThan(900) + + const grown = appendUser(session, 'a follow-up question that grows the surface further') + const beforeCompaction = pressure(ctx, session).projectedTokens + expect(beforeCompaction).toBeGreaterThan(afterTurn.projectedTokens!) + + // Compaction reports no usage of its own, so `pressureTokens` cannot move; + // the projected figure must shrink anyway — the defect this field fixes. + appendSummaryMeter(ctx, session, question, grown) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: grown }, + sourceEventSeqs: [question, answer, grown], + }) + const compacted = pressure(ctx, session) + expect(compacted.pressureTokens).toBe(900) + 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) + const question = appendUser(session, 'a question long enough to outprice the sample'.repeat(4)) + startStep(session, 1, 1) + // A provider sample far below the heuristic price of what it replaced: + // shadowing that span subtracts more than the sample holds. + appendAssistant(session, 'ok', { inputTokens: 3, outputTokens: 1 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + appendSummaryMeter(ctx, session, question, question) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '.' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: question, end: question }, + sourceEventSeqs: [question], + }) + expect(pressure(ctx, session).projectedTokens).toBe(0) + }) }) diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index 92081a860b..b8b32e36fa 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../compact/compact" + }, { "path": "../../session-projection/session-projection" }, diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 835b4b9036..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() @@ -464,7 +462,8 @@ export class PlanModeService extends Service { : 'The user switched this session back to the default mode.' return createUserMessage({ content: [{ type: 'text', text }], - source: { kind: 'plugin', plugin: 'plan-mode' }, + // The narration is already one sentence, so it is its own summary. + source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text }, }) } } 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/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 575f220c0a..b57689d742 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md -README.md: 05e7f7f4d08c52c1bfa0b7f67a618231b29f3e43 -README.zh.md: 23ce58c867d33d433fa69212562a5ccd13ba9d0e +README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 +README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 05e7f7f4d0..8e0bff5d1c 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -10,11 +10,11 @@ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools At every eligible `agent/pre-step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the pre-step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it adds an initial durable user-role `` to a downstream `enter` decision. Catalog messages contain only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -The digest covers the exact rendered text between the `` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, an entering pre-step receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary. +Every catalog message carries the `skill-catalog` source: a `catalog`-form context whose `entries` record exactly the `name` and `description` pairs it published, plus `update` on a replacement. The digest covers those durable entries, not the rendered prose, so the surrounding `` framing cannot decide whether a republish is needed and consumers never re-parse the `` block. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest visible `skill-catalog` message it can read; unreadable and foreign records are skipped. When the digest changes, the downstream `enter` decision receives a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry at the next pre-step. If no prior catalog exists and the current view is empty, no tombstone is necessary. The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. -`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. +`catalogDescriptionMaxLength` controls normalized catalog descriptions; rendering XML-escapes them. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. ## Tool: `skill` diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 23ce58c867..c6b815bef5 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -10,11 +10,11 @@ 每次符合条件的 `agent/pre-step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将 pre-step 中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会向下游 `enter` 决策添加初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -该 digest 覆盖 `` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,进入步骤的 pre-step 会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 +每条目录消息都携带 `skill-catalog` 来源,也就是 `catalog` 形态的上下文。它的 `entries` 精确记录本次发布的 `name` 与 `description` 对,替换目录另带 `update`。digest 覆盖这些持久条目,而不是渲染后的正文,因此 `` 包装不会影响是否需要重新发布,消费方也不需要重新解析 `` 块。插件从后向前扫描持久会话事件且不复制,并以最新一条仍可见且可读的 `skill-catalog` 消息作为比较基线;不可读和外来的记录都会跳过。digest 变化时,下游 `enter` 决策会收到一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,在下一次 pre-step 重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 -`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 +`catalogDescriptionMaxLength` 控制规范化后的目录描述,渲染时会对其执行 XML 转义。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 ## 工具:`skill` diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index b343fee8b0..ddc45d18e9 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -22,9 +22,37 @@ export const name = 'tool-skill' export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 -const CATALOG_ENTRIES_START = '\n' -const CATALOG_ENTRIES_END = '' -const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const +/** + * Durable provenance for one published session skill catalog. The catalog is a + * `catalog`-form context, so it records the entries it published beside the + * model-facing prose: a consumer presenting the list must not re-parse the + * `` block, whose framing exists for the model. + */ +export interface SkillCatalogSource { + readonly kind: 'skill-catalog' + readonly form: 'catalog' + /** Marks a replacement catalog rather than this session's first publication. */ + readonly update?: true + /** Exactly the entries this message published, in catalog order. */ + readonly entries: readonly { readonly name: string; readonly description: string }[] +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'skill-catalog': SkillCatalogSource + } +} + +/** Durable entry list mirroring the rendered catalog lines, for non-model consumers. */ +function catalogSourceEntries( + skills: SkillSummary[], + descriptionMaxLength: number, +): SkillCatalogSource['entries'] { + return skills.map(skill => ({ + name: skill.name, + description: catalogDescription(skill.description, descriptionMaxLength), + })) +} /** Model-facing skill catalog configuration. */ export interface Config { @@ -135,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() @@ -150,28 +176,29 @@ export function apply(ctx: Context, config: Config = {}): void { signal.throwIfAborted() if (!snapshot.complete) return decision const skills = snapshot.skills.filter(isModelInvocable) - const digest = catalogDigest(skills, catalogDescriptionMaxLength) + const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) + const digest = digestCatalogEntries(entries) const history = catalogHistory(agent) const existing = catalogMessage(decision.messages) if (history.visibleDigest === digest) { return existing === undefined ? decision - : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) } + : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) } } - if (existing !== undefined && catalogContentDigest(existing.content) === digest) return decision + if (existing !== undefined && digestCatalogEntries(existing.entries) === digest) return decision if (!history.published && skills.length === 0) { return existing === undefined ? decision - : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.id) } + : { kind: 'enter', messages: decision.messages.filter(message => message.id !== existing.message.id) } } const catalog = history.published - ? renderCatalogUpdate(skills, catalogDescriptionMaxLength) - : renderCatalogMessage(skills, catalogDescriptionMaxLength) + ? renderCatalogUpdate(entries) + : renderCatalogMessage(entries) return { kind: 'enter', messages: existing === undefined ? [...decision.messages, catalog] - : decision.messages.map(message => message.id === existing.id ? catalog : message), + : decision.messages.map(message => message.id === existing.message.id ? catalog : message), } }) } @@ -222,8 +249,7 @@ function renderResourceHint(skill: Pick', - ...entries, + ...renderCatalogEntries(entries), '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", '', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries, + }, }) } -function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage { - const entries = renderCatalogEntries(skills, descriptionMaxLength) - const availability = skills.length === 0 +function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessage { + const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', ] @@ -260,31 +289,70 @@ function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: numbe 'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:', '', '', - ...entries, + ...renderCatalogEntries(entries), '', '', ...availability, '', ].join('\n'), }], - source: PLUGIN_SOURCE, + source: { + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries, + }, }) } -function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] { - return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) +/** + * Model-facing catalog lines, projected from the same entries the source records. + * The pseudo-XML escaping belongs to this frame, not to the published fact, so it + * is applied here and never stored. Names are `isSkillName`-validated and carry + * no escapable character. + */ +function renderCatalogEntries(entries: SkillCatalogSource['entries']): string[] { + return entries.map(entry => `- \`${entry.name}\`: ${escapeText(entry.description)}`) } -function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string { - return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n')) -} - -function digestCatalogEntries(entries: string): string { +/** + * Catalog identity over the durable entry list rather than the rendered prose. + * The entries are what changes; the surrounding `` framing is + * written for the model and must not decide whether a republish is needed. + */ +function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { + // JSON per entry rather than a separator character: every separator is itself + // a legal description character, so only quoting makes the boundary exact. + const canonical = entries.map(entry => JSON.stringify([entry.name, entry.description])).join('\n') return createHash('sha256') - .update(entries) + .update(canonical) .digest('hex') } +/** + * Entries of one durable catalog message, or undefined when the record is not a + * usable catalog. + * + * `agent.session.events` may be a resumed, forked, or externally written seed, + * and seed validation only guarantees a source object with a non-empty `kind`; + * no per-kind field is checked there. An unreadable record is therefore treated + * as "not this plugin's catalog" — the posture the replaced content digest had — + * rather than throwing inside the step listener, which would fail every + * subsequent turn of that session. + */ +function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | undefined { + const entries = (source as { entries?: unknown }).entries + if (!Array.isArray(entries)) return undefined + const readable: { name: string; description: string }[] = [] + for (const entry of entries as readonly unknown[]) { + if (typeof entry !== 'object' || entry === null) return undefined + const { name, description } = entry as { name?: unknown; description?: unknown } + if (typeof name !== 'string' || name === '' || typeof description !== 'string') return undefined + readable.push({ name, description }) + } + return readable +} + function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) const events = agent.session.events @@ -293,43 +361,31 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool // The loop bounds prove the read-only event view contains this index. // oxlint-disable-next-line typescript/no-non-null-assertion const event = events[index]! - if (event.type !== 'user/message' - || event.data.source.kind !== 'plugin' - || event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue - const digest = catalogContentDigest(event.data.content) - if (digest === undefined) continue + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue + const entries = readCatalogEntries(event.data.source) + if (entries === undefined) continue + const digest = digestCatalogEntries(entries) published = true if (visible.has(event.seq)) return { visibleDigest: digest, published } } return { published } } -function catalogMessage(messages: readonly UserMessage[]): UserMessage | undefined { - return messages.find(message => - message.source.kind === 'plugin' - && message.source.plugin === PLUGIN_SOURCE.plugin - && catalogContentDigest(message.content) !== undefined) -} - -function catalogContentDigest(content: UserMessage['content']): string | undefined { - if (content.length !== 1 || content[0]?.type !== 'text') return undefined - const text = content[0].text - const start = text.indexOf(CATALOG_ENTRIES_START) - if (start === -1) return undefined - const entriesStart = start + CATALOG_ENTRIES_START.length - const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart) - if (end === -1) return undefined - const renderedEntries = text.slice(entriesStart, end) - const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries - return digestCatalogEntries(entries) +function catalogMessage( + messages: readonly UserMessage[], +): { message: UserMessage; entries: SkillCatalogSource['entries'] } | undefined { + for (const message of messages) { + if (message.source.kind !== 'skill-catalog') continue + const entries = readCatalogEntries(message.source) + if (entries !== undefined) return { message, entries } + } + return undefined } +/** Normalized, length-bounded description exactly as the catalog publishes it (unescaped). */ function catalogDescription(value: string, maxLength: number): string { const normalized = value.replaceAll(/\s+/g, ' ').trim() - const truncated = normalized.length <= maxLength - ? normalized - : `${normalized.slice(0, maxLength - 3)}...` - return escapeText(truncated) + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...` } function assertPositiveInteger(name: string, value: number, minimum = 1): void { diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index c3562564a0..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,16 +104,22 @@ 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 }), ) } function catalogMessages(session: Session): Extract[] { return session.events.filter((event): event is Extract => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'dsh-tool-skill') + && event.data.source.kind === 'skill-catalog') +} + +function readableCatalog(event: Extract): boolean { + const entries = (event.data.source as { entries?: unknown }).entries + return Array.isArray(entries) + && entries.every(entry => typeof entry === 'object' && entry !== null + && typeof (entry as { name?: unknown }).name === 'string' + && typeof (entry as { description?: unknown }).description === 'string') } function catalogContent(entries: string[]): Message['content'] { @@ -131,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') { @@ -234,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 { @@ -261,7 +265,15 @@ describe('dsh-tool-skill', () => { { id: expect.any(String) as unknown, role: 'user', - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [ + { name: 'a-skill', description: 'Use {{placeholder}} & carefully.' }, + { name: 'model-only-skill', description: 'Model-only skill.' }, + { name: 'z-skill', description: 'Long description Long description Long descript...' }, + ], + }, content: [{ type: 'text', text: [ @@ -394,14 +406,22 @@ describe('dsh-tool-skill', () => { const home = await tempDir('tool-proposed-empty-catalog') const ctx = await setup(home) const session = Session.create(SessionId('proposed-empty-catalog')) + const malformed = createUserMessage({ + content: [{ type: 'text', text: 'preserve unreadable claimed context' }], + source: { kind: 'skill-catalog', form: 'catalog' } as never, + }) const stale = createUserMessage({ content: catalogContent(['- `stale-skill`: Stale skill']), - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'stale-skill', description: 'Stale skill' }], + }, }) - const decision = await proposeStep(ctx, sessionAgent(session), [stale]) + const decision = await proposeStep(ctx, sessionAgent(session), [malformed, stale]) - expect(decision).toEqual({ kind: 'enter', messages: [] }) + expect(decision).toEqual({ kind: 'enter', messages: [malformed] }) }) it('keeps a proposed catalog that already matches the current snapshot', async () => { @@ -416,7 +436,11 @@ describe('dsh-tool-skill', () => { const session = Session.create(SessionId('matching-proposal')) const proposed = createUserMessage({ content: catalogContent(['- `first-skill`: First skill']), - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'first-skill', description: 'First skill' }], + }, }) const decision = await proposeStep(ctx, sessionAgent(session), [proposed]) @@ -468,7 +492,12 @@ describe('dsh-tool-skill', () => { expect(catalogMessages(session)).toHaveLength(3) }) - it('resumes from the latest valid visible catalog content', async () => { + it('resumes from the durable entries of the latest visible catalog', async () => { + // Catalog identity moved onto `source.entries` when the catalog became a + // `catalog`-form context: the model-facing prose no longer decides whether + // a republish is needed, so a seeded message is recognized by its source + // alone and malformed prose can no longer hide (or fake) a published + // catalog. A foreign-sourced message is not this plugin's catalog at all. const home = await tempDir('tool-catalog-resume') const ctx = await setup(home) ctx.skills.register({ @@ -481,30 +510,78 @@ describe('dsh-tool-skill', () => { const agent = sessionAgent(session) openMessageTurn(session) session.append('user/message', createUserMessage({ - content: catalogContent(['- `old-skill`: Old skill']), - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + content: [{ type: 'text', text: 'prose a reader cannot rely on' }], + source: { + kind: 'skill-catalog', + form: 'catalog', + entries: [{ name: 'old-skill', description: 'Old skill' }], + }, }), { surfaceOp: 'append' }) session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'missing catalog markers' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: '\nmissing closing marker' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, - }), { surfaceOp: 'append' }) - session.append('user/message', createUserMessage({ - content: [{ type: 'reasoning', text: 'not a user-role catalog block' }], + content: catalogContent(['- `resumed-skill`: Resumed skill']), source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, }), { surfaceOp: 'append' }) await fireStep(ctx, agent, 1, 1) - expect(catalogMessages(session)).toHaveLength(6) - expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill') + // The seeded entries differ from the live snapshot, so one replacement + // lands; the foreign-sourced lookalike neither counts as published nor + // suppresses it. + expect(catalogMessages(session)).toHaveLength(2) + const latest = catalogMessages(session).at(-1) + expect(latest?.data.source).toMatchObject({ + kind: 'skill-catalog', + form: 'catalog', + update: true, + entries: [{ name: 'resumed-skill', description: 'Resumed skill' }], + }) + expect(JSON.stringify(latest?.data.content)).toContain('resumed-skill') + + // A second step over unchanged entries republishes nothing. + await fireStep(ctx, agent, 1, 2) + expect(catalogMessages(session)).toHaveLength(2) + }) + + it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => { + // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork, + // and seed validation only guarantees a source object with a non-empty + // `kind`. A catalog whose entries are missing or wrongly shaped must be + // skipped like any foreign record; throwing here would fail every later + // step of that session at the latest possible point. + const home = await tempDir('tool-catalog-malformed') + const ctx = await setup(home) + ctx.skills.register({ + name: 'live-skill', + description: 'Live skill', + source: 'runtime', + content: 'Live body.', + }) + const session = Session.create(SessionId('catalog-malformed')) + const agent = sessionAgent(session) + openMessageTurn(session) + for (const source of [ + { kind: 'skill-catalog', form: 'catalog' }, + { kind: 'skill-catalog', form: 'catalog', entries: null }, + { kind: 'skill-catalog', form: 'catalog', entries: 'not-an-array' }, + { kind: 'skill-catalog', form: 'catalog', entries: [null] }, + { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'x' }] }, + { kind: 'skill-catalog', form: 'catalog', entries: [{ description: 'no name' }] }, + ]) { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'unreadable catalog' }], + source: source as never, + }), { surfaceOp: 'append' }) + } + + await expect(fireStep(ctx, agent, 1, 1)).resolves.toBeUndefined() + + // None of the six counted as published, so the live catalog lands as a + // first publication rather than a replacement. + const published = catalogMessages(session).filter(event => readableCatalog(event)) + expect(published).toHaveLength(1) + expect(published[0]?.data.source).toMatchObject({ kind: 'skill-catalog', form: 'catalog' }) + expect(published[0]?.data.source).not.toHaveProperty('update') + expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill') }) it('re-establishes the current catalog after compaction hides its durable message', async () => { 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/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 3644180056..212536e713 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -47,6 +47,8 @@ import type SubagentActivationSetupRegistry from './activation-setup-registry.ts /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { readonly kind: 'coordinator' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the agent whose tool call produced the follow-up. */ readonly senderSessionId: SessionId } @@ -54,6 +56,8 @@ export interface CoordinatorMessageSource { /** Durable attribution for a continuable child's explicit parent report. */ export interface SubagentReportMessageSource { readonly kind: 'subagent-report' + /** A message another agent addressed to this one (`relay` context form). */ + readonly form: 'relay' /** Session id of the reporting child. */ readonly senderSessionId: SessionId } @@ -283,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) { @@ -481,6 +485,7 @@ export class SubagentContinuationManager { ], source: { kind: 'subagent-report' as const, + form: 'relay' as const, senderSessionId: activation.childId, }, }) @@ -854,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/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-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 5406a4dcc4..95cf4e4183 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -66,7 +66,7 @@ export function apply(ctx: Context): void { SessionId(args.subagent_id), message, { - source: { kind: 'coordinator', senderSessionId: parent.id }, + source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id }, signal: exec.signal, }, ) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index bc8d2aa5fd..302e053abe 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -102,6 +102,7 @@ describe('dsh-tool-subagent-control', () => { // Durable provenance records the calling agent without granting authority. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({ kind: 'coordinator', + form: 'relay', senderSessionId: parent.id, }) }) 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/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 5a907de989..5372fffdb2 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -114,6 +114,15 @@ function fitWithSuffix( return `${retainTail(content, maxBytes - fixedBytes)}${fixed}` } +/** + * One-line account of a settled task for the `notice` form's collapsed row. + * @param snapshot - the settled task. + * @returns its kind, label, and status, bounded like every notice summary. + */ +function completionSummary(snapshot: TaskSnapshot): string { + return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`) +} + function fitCompletionNotice(snapshot: TaskSnapshot): string { const prefix = `background task ${snapshot.id}` const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}` @@ -228,7 +237,12 @@ export function apply(ctx: Context, config: Config): void { type: 'text', text: fitCompletionNotice(snapshot), }], - source: { kind: 'plugin', plugin: 'tool-tasks' }, + source: { + kind: 'plugin', + plugin: 'tool-tasks', + form: 'notice', + summary: completionSummary(snapshot), + }, })) }) diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index c04bca5295..0f1bce7ecf 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -460,7 +460,12 @@ describe('completion notices', () => { id: expect.any(String) as unknown, role: 'user', content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }], - source: { kind: 'plugin', plugin: 'tool-tasks' }, + source: { + kind: 'plugin', + plugin: 'tool-tasks', + form: 'notice', + summary: 'bash pnpm test [status: completed, exit code: 0]', + }, }) }) @@ -484,7 +489,14 @@ describe('completion notices', () => { id: expect.any(String) as unknown, role: 'user', content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }], - source: { kind: 'plugin', plugin: 'tool-tasks' }, + // The label and status detail are unbounded caller text, so the durable + // one-line account caps itself rather than committing their full length. + source: { + kind: 'plugin', + plugin: 'tool-tasks', + form: 'notice', + summary: `subagent ${'x'.repeat(110)}…`, + }, }, ) 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/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 6246681c52..b4a6c5d5c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1691,6 +1691,9 @@ importers: '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 anser: specifier: ^2.3.5 version: 2.3.5 @@ -1706,6 +1709,12 @@ importers: mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 + mdast-util-math: + specifier: ^3.0.0 + version: 3.0.0 + micromark-core-commonmark: + specifier: ^2.0.3 + version: 2.0.3 micromark-extension-gfm: specifier: ^3.0.0 version: 3.0.0 @@ -1718,6 +1727,12 @@ importers: micromark-util-character: specifier: ^2.1.1 version: 2.1.1 + micromark-util-classify-character: + specifier: ^2.0.1 + version: 2.0.1 + micromark-util-sanitize-uri: + specifier: ^2.0.1 + version: 2.0.1 micromark-util-symbol: specifier: ^2.0.1 version: 2.0.1 @@ -1730,18 +1745,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - react-markdown: - specifier: ^10.1.0 - version: 10.1.0(@types/react@18.3.31)(react@18.3.1) - rehype-katex: - specifier: ^7.0.1 - version: 7.0.1 - remark-gfm: - specifier: ^4.0.1 - version: 4.0.1 - remark-math: - specifier: ^6.0.0 - version: 6.0.0 shiki: specifier: ^4.3.1 version: 4.3.1 @@ -2369,6 +2372,9 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2378,6 +2384,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -3863,6 +3872,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -9068,9 +9080,6 @@ packages: '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree-jsx@1.0.5': - resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -9145,9 +9154,6 @@ packages: '@types/turndown@5.0.6': resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -9500,9 +9506,6 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -9591,9 +9594,6 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - character-reference-invalid@2.0.1: - resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -9945,10 +9945,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -10056,9 +10052,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-util-is-identifier-name@3.0.0: - resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -10292,39 +10285,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hast-util-from-dom@5.0.1: - resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} - - hast-util-from-html-isomorphic@2.0.0: - resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} - - hast-util-from-html@2.0.3: - resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} - - hast-util-from-parse5@8.0.3: - resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} - - hast-util-is-element@3.0.0: - resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - - hast-util-parse-selector@4.0.0: - resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} - hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - hast-util-to-jsx-runtime@2.3.6: - resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} - - hast-util-to-text@4.0.2: - resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} - hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hastscript@9.0.1: - resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - hono@4.12.29: resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} engines: {node: '>=16.9.0'} @@ -10342,9 +10308,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-url-attributes@3.0.1: - resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -10396,9 +10359,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inline-style-parser@0.2.7: - resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -10414,15 +10374,6 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - - is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - - is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -10435,9 +10386,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -10838,15 +10786,6 @@ packages: mdast-util-math@3.0.0: resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} - mdast-util-mdx-expression@2.0.1: - resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - - mdast-util-mdx-jsx@3.2.0: - resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} - - mdast-util-mdxjs-esm@2.0.1: - resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -11194,16 +11133,10 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - parse-entities@4.0.2: - resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -11351,12 +11284,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-markdown@10.1.0: - resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} - peerDependencies: - '@types/react': '>=18' - react: '>=18' - react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -11393,24 +11320,6 @@ packages: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - rehype-katex@7.0.1: - resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} - - remark-gfm@4.0.1: - resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} - - remark-math@6.0.0: - resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} - - remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - - remark-rehype@11.1.2: - resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} - - remark-stringify@11.0.0: - resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -11638,12 +11547,6 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} - style-to-js@1.1.21: - resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} - - style-to-object@1.0.14: - resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -11706,9 +11609,6 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -11826,12 +11726,6 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} - unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - - unist-util-find-after@5.0.0: - resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -11879,9 +11773,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vfile-location@5.0.3: - resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} - vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -12096,9 +11987,6 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} - web-namespaces@2.0.1: - resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -14142,10 +14030,6 @@ snapshots: '@types/esrecurse@4.3.1': {} - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.9 - '@types/estree@1.0.9': {} '@types/geojson@7946.0.16': {} @@ -14216,8 +14100,6 @@ snapshots: '@types/turndown@5.0.6': {} - '@types/unist@2.0.11': {} - '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} @@ -14617,8 +14499,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - bail@2.0.2: {} - balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -14699,8 +14579,6 @@ snapshots: character-entities@2.0.2: {} - character-reference-invalid@2.0.1: {} - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -15034,8 +14912,6 @@ snapshots: encodeurl@2.0.0: {} - entities@6.0.1: {} - entities@7.0.1: {} entities@8.0.0: {} @@ -15233,8 +15109,6 @@ snapshots: estraverse@5.3.0: {} - estree-util-is-identifier-name@3.0.0: {} - estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -15518,47 +15392,6 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-from-dom@5.0.1: - dependencies: - '@types/hast': 3.0.5 - hastscript: 9.0.1 - web-namespaces: 2.0.1 - - hast-util-from-html-isomorphic@2.0.0: - dependencies: - '@types/hast': 3.0.5 - hast-util-from-dom: 5.0.1 - hast-util-from-html: 2.0.3 - unist-util-remove-position: 5.0.0 - - hast-util-from-html@2.0.3: - dependencies: - '@types/hast': 3.0.5 - devlop: 1.1.0 - hast-util-from-parse5: 8.0.3 - parse5: 7.3.0 - vfile: 6.0.3 - vfile-message: 4.0.3 - - hast-util-from-parse5@8.0.3: - dependencies: - '@types/hast': 3.0.5 - '@types/unist': 3.0.3 - devlop: 1.1.0 - hastscript: 9.0.1 - property-information: 7.2.0 - vfile: 6.0.3 - vfile-location: 5.0.3 - web-namespaces: 2.0.1 - - hast-util-is-element@3.0.0: - dependencies: - '@types/hast': 3.0.5 - - hast-util-parse-selector@4.0.0: - dependencies: - '@types/hast': 3.0.5 - hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.5 @@ -15573,45 +15406,10 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: - dependencies: - '@types/estree': 1.0.9 - '@types/hast': 3.0.5 - '@types/unist': 3.0.3 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.2.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.21 - unist-util-position: 5.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - hast-util-to-text@4.0.2: - dependencies: - '@types/hast': 3.0.5 - '@types/unist': 3.0.3 - hast-util-is-element: 3.0.0 - unist-util-find-after: 5.0.0 - hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.5 - hastscript@9.0.1: - dependencies: - '@types/hast': 3.0.5 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 4.0.0 - property-information: 7.2.0 - space-separated-tokens: 2.0.2 - hono@4.12.29: {} hookable@5.5.3: {} @@ -15626,8 +15424,6 @@ snapshots: html-escaper@2.0.2: {} - html-url-attributes@3.0.1: {} - html-void-elements@3.0.0: {} http-errors@2.0.1: @@ -15676,8 +15472,6 @@ snapshots: inherits@2.0.4: {} - inline-style-parser@0.2.7: {} - internmap@1.0.1: {} internmap@2.0.3: {} @@ -15686,15 +15480,6 @@ snapshots: ipaddr.js@1.9.1: {} - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - - is-decimal@2.0.1: {} - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -15703,8 +15488,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-hexadecimal@2.0.1: {} - is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -16140,45 +15923,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@3.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.2 - stringify-entities: 4.0.4 - unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -16701,22 +16445,8 @@ snapshots: pako@1.0.11: {} - parse-entities@4.0.2: - dependencies: - '@types/unist': 2.0.11 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.3.0 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 - parse-ms@4.0.0: {} - parse5@7.3.0: - dependencies: - entities: 6.0.1 - parse5@8.0.1: dependencies: entities: 8.0.0 @@ -16847,24 +16577,6 @@ snapshots: react-is@17.0.2: {} - react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1): - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@types/react': 18.3.31 - devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.6 - html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.1 - react: 18.3.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - unified: 11.0.5 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - react-refresh@0.17.0: {} react@18.3.1: @@ -16904,59 +16616,6 @@ snapshots: '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 - rehype-katex@7.0.1: - dependencies: - '@types/hast': 3.0.5 - '@types/katex': 0.16.8 - hast-util-from-html-isomorphic: 2.0.0 - hast-util-to-text: 4.0.2 - katex: 0.16.47 - unist-util-visit-parents: 6.0.2 - vfile: 6.0.3 - - remark-gfm@4.0.1: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 - micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-math@6.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-math: 3.0.0 - micromark-extension-math: 3.1.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - micromark-util-types: 2.0.2 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.2: - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.1 - unified: 11.0.5 - vfile: 6.0.3 - - remark-stringify@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.2 - unified: 11.0.5 - require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -17260,14 +16919,6 @@ snapshots: dependencies: anynum: 1.0.0 - style-to-js@1.1.21: - dependencies: - style-to-object: 1.0.14 - - style-to-object@1.0.14: - dependencies: - inline-style-parser: 0.2.7 - stylis@4.4.0: {} superjson@2.2.6: @@ -17315,8 +16966,6 @@ snapshots: trim-lines@3.0.1: {} - trough@2.2.0: {} - ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -17405,21 +17054,6 @@ snapshots: unicorn-magic@0.3.0: {} - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unist-util-find-after@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -17470,11 +17104,6 @@ snapshots: vary@1.1.2: {} - vfile-location@5.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile: 6.0.3 - vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -17755,8 +17384,6 @@ snapshots: walk-up-path@4.0.0: {} - web-namespaces@2.0.1: {} - web-streams-polyfill@3.3.3: {} webidl-conversions@8.0.1: {} 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/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/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5d85466347..603ad20d8e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -26,6 +26,21 @@ "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/message.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextForm", + "source": "packages/llm/llm/src/message.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextSnapshotSection", + "source": "packages/llm/llm/src/message.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContextFormed", + "source": "packages/llm/llm/src/message.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", @@ -46,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", @@ -116,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/tsconfig.host.json b/tsconfig.host.json index c13d480a46..4fcf71b680 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,6 +38,8 @@ "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", + "apps/web/tests/markdown-cjk-strong.e2e.ts", + "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts",