@vellumai/assistant 0.8.7-dev.202606052232.2ddc989 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/bun.lock +2 -2
  2. package/docs/plugins.md +832 -0
  3. package/examples/plugins/echo/README.md +60 -61
  4. package/examples/plugins/echo/package.json +2 -1
  5. package/examples/plugins/echo/register.ts +143 -0
  6. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +6 -7
  7. package/openapi.yaml +5 -15
  8. package/package.json +2 -2
  9. package/src/__tests__/agent-loop-exit-reason.test.ts +56 -3
  10. package/src/__tests__/anthropic-provider.test.ts +1 -1
  11. package/src/__tests__/app-control-flow.test.ts +1 -1
  12. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  13. package/src/__tests__/approval-routes-http.test.ts +1 -4
  14. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  15. package/src/__tests__/channel-approvals.test.ts +1 -1
  16. package/src/__tests__/circuit-breaker-pipeline.test.ts +405 -0
  17. package/src/__tests__/compaction-pipeline.test.ts +210 -0
  18. package/src/__tests__/compaction-timeout-recovery.test.ts +251 -0
  19. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
  21. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -3
  22. package/src/__tests__/conversation-agent-loop.test.ts +39 -42
  23. package/src/__tests__/conversation-clean-command.test.ts +2 -5
  24. package/src/__tests__/conversation-provider-retry-repair.test.ts +5 -4
  25. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -140
  26. package/src/__tests__/conversation-runtime-workspace.test.ts +27 -108
  27. package/src/__tests__/conversation-starter-routes.test.ts +6 -14
  28. package/src/__tests__/conversation-workspace-cache-state.test.ts +16 -17
  29. package/src/__tests__/conversation-workspace-injection.test.ts +1 -61
  30. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +6 -7
  31. package/src/__tests__/db-acp-history.test.ts +0 -101
  32. package/src/__tests__/dynamic-page-surface.test.ts +0 -31
  33. package/src/__tests__/file-write-tool.test.ts +0 -63
  34. package/src/__tests__/gateway-only-guard.test.ts +2 -12
  35. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  36. package/src/__tests__/guardian-routing-invariants.test.ts +4 -2
  37. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  38. package/src/__tests__/heartbeat-disk-pressure.test.ts +0 -1
  39. package/src/__tests__/heartbeat-service.test.ts +0 -1
  40. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  41. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  42. package/src/__tests__/injector-background-turn.test.ts +1 -1
  43. package/src/__tests__/injector-chain.test.ts +6 -34
  44. package/src/__tests__/injector-disk-pressure.test.ts +34 -77
  45. package/src/__tests__/injector-document-comments.test.ts +1 -1
  46. package/src/__tests__/list-messages-hidden-metadata.test.ts +0 -38
  47. package/src/__tests__/memory-v2-static-injector.test.ts +1 -1
  48. package/src/__tests__/{overflow-reduction-loop.test.ts → overflow-reduce-pipeline.test.ts} +284 -64
  49. package/src/__tests__/pipeline-runner.test.ts +554 -0
  50. package/src/__tests__/plugin-api-shim.test.ts +6 -3
  51. package/src/__tests__/plugin-bootstrap.test.ts +23 -12
  52. package/src/__tests__/plugin-registry.test.ts +49 -3
  53. package/src/__tests__/plugin-types.test.ts +70 -0
  54. package/src/__tests__/reaction-persistence.test.ts +1 -1
  55. package/src/__tests__/send-endpoint-busy.test.ts +1 -4
  56. package/src/__tests__/skill-feature-flags-integration.test.ts +0 -33
  57. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  58. package/src/__tests__/subagent-fork-notifications.test.ts +3 -1
  59. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  60. package/src/__tests__/subagent-manager-notify.test.ts +3 -1
  61. package/src/__tests__/subagent-notify-parent.test.ts +3 -1
  62. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  63. package/src/__tests__/user-plugin-loader.test.ts +286 -54
  64. package/src/acp/__tests__/client-handler.test.ts +0 -40
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +0 -137
  66. package/src/acp/__tests__/session-manager-persistence.test.ts +28 -95
  67. package/src/acp/agent-process.ts +1 -61
  68. package/src/acp/client-handler.ts +0 -31
  69. package/src/acp/prepare-agent-env.ts +29 -83
  70. package/src/acp/resolve-agent.test.ts +7 -320
  71. package/src/acp/resolve-agent.ts +18 -182
  72. package/src/acp/session-manager.ts +73 -495
  73. package/src/acp/types.ts +0 -8
  74. package/src/agent/compaction-circuit.ts +102 -60
  75. package/src/agent/loop.ts +59 -32
  76. package/src/api/responses/conversation-message.ts +1 -7
  77. package/src/approvals/guardian-request-resolvers.ts +1 -1
  78. package/src/background-wake/next-wake.ts +0 -1
  79. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  80. package/src/config/acp-defaults.test.ts +0 -10
  81. package/src/config/acp-defaults.ts +0 -6
  82. package/src/config/bundled-skills/acp/SKILL.md +31 -83
  83. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  84. package/src/config/bundled-skills/app-builder/SKILL.md +381 -224
  85. package/src/config/bundled-skills/app-builder/TOOLS.json +0 -29
  86. package/src/config/bundled-skills/document-editor/SKILL.md +23 -28
  87. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  88. package/src/config/bundled-tool-registry.ts +0 -2
  89. package/src/config/feature-flag-registry.json +5 -14
  90. package/src/config/schemas/heartbeat.ts +0 -9
  91. package/src/context/strip-injections.ts +2 -8
  92. package/src/context/window-manager.ts +1 -2
  93. package/src/daemon/conversation-agent-loop-handlers.ts +11 -0
  94. package/src/daemon/conversation-agent-loop.ts +279 -62
  95. package/src/daemon/conversation-runtime-assembly.ts +69 -106
  96. package/src/daemon/conversation-store.ts +90 -9
  97. package/src/daemon/conversation-workspace.ts +0 -17
  98. package/src/daemon/conversation.ts +6 -0
  99. package/src/daemon/external-plugins-bootstrap.ts +11 -11
  100. package/src/daemon/handlers/conversations.ts +1 -3
  101. package/src/daemon/handlers/skills.ts +1 -4
  102. package/src/daemon/lifecycle.ts +0 -21
  103. package/src/daemon/server.ts +0 -2
  104. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -3
  105. package/src/heartbeat/heartbeat-run-store.ts +1 -23
  106. package/src/heartbeat/heartbeat-service.ts +0 -26
  107. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  108. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  109. package/src/ipc/skill-routes/__tests__/memory.test.ts +0 -15
  110. package/src/ipc/skill-routes/memory.ts +2 -4
  111. package/src/memory/conversation-starter-checkpoints.ts +0 -1
  112. package/src/memory/db-init.ts +0 -2
  113. package/src/memory/job-handlers/conversation-starters.ts +2 -13
  114. package/src/memory/jobs-worker.ts +1 -1
  115. package/src/memory/migrations/index.ts +0 -1
  116. package/src/memory/schema/acp.ts +0 -4
  117. package/src/memory/v2/__tests__/consolidation-job.test.ts +3 -3
  118. package/src/memory/v2/consolidation-job.ts +4 -13
  119. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/assign.test.ts +4 -4
  120. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/live-integration.test.ts +4 -4
  121. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/maintain-job.test.ts +5 -5
  122. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/orchestrate.test.ts +3 -3
  123. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/reconcile.test.ts +2 -2
  124. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/render-injection.test.ts +1 -1
  125. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/router.test.ts +3 -3
  126. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/selection-log-store.test.ts +8 -8
  127. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/selector.test.ts +3 -3
  128. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/shadow-plugin.test.ts +12 -12
  129. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/assign.ts +5 -5
  130. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/capabilities.ts +2 -2
  131. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/maintain-job.ts +8 -8
  132. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/page-content.ts +2 -2
  133. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/provider-blocks.ts +1 -1
  134. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/reconcile.ts +3 -3
  135. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/render-injection.ts +1 -1
  136. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/router.ts +3 -3
  137. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/selection-log-store.ts +4 -4
  138. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/selector.ts +4 -4
  139. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/shadow-plugin.ts +90 -28
  140. package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/tree.ts +1 -1
  141. package/src/plugin-api/index.ts +5 -0
  142. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +93 -0
  143. package/src/plugins/defaults/{memory-v3-shadow → circuit-breaker}/package.json +2 -2
  144. package/src/plugins/defaults/circuit-breaker/register.ts +39 -0
  145. package/src/plugins/defaults/compaction/middlewares/compaction.ts +25 -0
  146. package/src/plugins/defaults/compaction/package.json +1 -1
  147. package/src/plugins/defaults/compaction/register.ts +19 -8
  148. package/src/plugins/defaults/compaction/terminal.ts +73 -0
  149. package/src/plugins/defaults/index.ts +5 -3
  150. package/src/plugins/defaults/{memory-retrieval/injectors.ts → injectors/register.ts} +7 -45
  151. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +7 -11
  152. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  153. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +126 -0
  154. package/src/plugins/defaults/overflow-reduce/package.json +15 -0
  155. package/src/plugins/defaults/overflow-reduce/register.ts +42 -0
  156. package/src/plugins/external-api.ts +2 -2
  157. package/src/plugins/pipeline.ts +293 -6
  158. package/src/plugins/registry.ts +37 -9
  159. package/src/plugins/types.ts +336 -32
  160. package/src/plugins/user-loader.ts +127 -30
  161. package/src/proactive-artifact/aux-message-injector.ts +1 -1
  162. package/src/proactive-artifact/job.test.ts +1 -1
  163. package/src/prompts/__tests__/system-prompt.test.ts +0 -6
  164. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +2 -4
  165. package/src/runtime/__tests__/agent-wake.test.ts +5 -5
  166. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  167. package/src/runtime/agent-wake.ts +3 -0
  168. package/src/runtime/assistant-event-hub.ts +1 -1
  169. package/src/runtime/channel-approvals.ts +1 -1
  170. package/src/runtime/interactive-ui.ts +1 -1
  171. package/src/runtime/routes/__tests__/acp-routes.test.ts +55 -283
  172. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  173. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +4 -5
  174. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +1 -4
  175. package/src/runtime/routes/acp-routes.test.ts +25 -89
  176. package/src/runtime/routes/acp-routes.ts +29 -81
  177. package/src/runtime/routes/approval-routes.ts +1 -1
  178. package/src/runtime/routes/browser-routes.ts +1 -1
  179. package/src/runtime/routes/browser-tabs-routes.ts +10 -6
  180. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  181. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  182. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  183. package/src/runtime/routes/conversation-routes.ts +2 -15
  184. package/src/runtime/routes/conversation-starter-routes.ts +7 -13
  185. package/src/runtime/routes/conversations-import-routes.ts +7 -24
  186. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  187. package/src/runtime/routes/host-cu-routes.ts +1 -1
  188. package/src/runtime/routes/identity-routes.ts +3 -18
  189. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  190. package/src/runtime/routes/memory-v3-routes.ts +6 -16
  191. package/src/runtime/routes/playground/helpers.ts +1 -1
  192. package/src/runtime/routes/surface-conversation-resolver.ts +3 -4
  193. package/src/runtime/routes/work-items-routes.ts +4 -2
  194. package/src/runtime/services/conversation-serializer.ts +1 -1
  195. package/src/signals/cancel.ts +4 -2
  196. package/src/subagent/manager.ts +5 -17
  197. package/src/tools/acp/list-agents.test.ts +1 -7
  198. package/src/tools/acp/spawn.test.ts +55 -158
  199. package/src/tools/acp/spawn.ts +72 -47
  200. package/src/tools/acp/steer.test.ts +8 -105
  201. package/src/tools/acp/steer.ts +17 -48
  202. package/src/tools/apps/executors.ts +8 -13
  203. package/src/tools/filesystem/write.ts +0 -34
  204. package/src/tools/subagent/spawn.ts +4 -2
  205. package/src/tools/ui-surface/definitions.ts +4 -25
  206. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +5 -4
  207. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +45 -69
  208. package/examples/plugins/echo/hooks/post-tool-use.ts +0 -18
  209. package/examples/plugins/echo/hooks/stop.ts +0 -16
  210. package/examples/plugins/echo/hooks/user-prompt-submit.ts +0 -18
  211. package/examples/plugins/echo/src/emit.ts +0 -19
  212. package/src/__tests__/compaction-circuit.test.ts +0 -258
  213. package/src/__tests__/compaction-direct.test.ts +0 -132
  214. package/src/__tests__/conversations-import-system-filter.test.ts +0 -101
  215. package/src/acp/__tests__/agent-process.test.ts +0 -161
  216. package/src/acp/__tests__/helpers/acp-history-db.ts +0 -82
  217. package/src/acp/__tests__/helpers/exec-file-stub.ts +0 -101
  218. package/src/acp/__tests__/session-manager-resume.test.ts +0 -736
  219. package/src/acp/auto-install.test.ts +0 -196
  220. package/src/acp/auto-install.ts +0 -177
  221. package/src/acp/feature-gate.test.ts +0 -48
  222. package/src/acp/feature-gate.ts +0 -34
  223. package/src/acp/resume-hint.ts +0 -25
  224. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +0 -48
  225. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +0 -57
  226. package/src/config/bundled-skills/app-builder/references/SLIDES.md +0 -38
  227. package/src/config/bundled-skills/app-builder/tools/app-list.ts +0 -62
  228. package/src/daemon/conversation-registry.ts +0 -159
  229. package/src/daemon/overflow-reduction-loop.ts +0 -230
  230. package/src/memory/migrations/272-acp-session-history-cwd.ts +0 -36
  231. package/src/plugins/defaults/compaction/compact.ts +0 -59
  232. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +0 -14
  233. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +0 -19
  234. package/src/plugins/defaults/memory-v3-shadow/injector.ts +0 -75
  235. package/src/plugins/defaults/memory-v3-shadow/register.ts +0 -26
  236. package/src/tools/acp/context.ts +0 -20
  237. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/capabilities.test.ts +0 -0
  238. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/core.test.ts +0 -0
  239. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/fixtures/eval-turns.json +0 -0
  240. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/fixtures/live-turns.json +0 -0
  241. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/health.test.ts +0 -0
  242. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/needle.test.ts +0 -0
  243. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/provider-blocks.test.ts +0 -0
  244. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/snapshot.test.ts +0 -0
  245. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/tree.test.ts +0 -0
  246. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/types.test.ts +0 -0
  247. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/working-set-eviction.test.ts +0 -0
  248. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/__tests__/working-set-skeleton.test.ts +0 -0
  249. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/core.ts +0 -0
  250. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/data/README.md +0 -0
  251. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/data/assignments.json +0 -0
  252. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/data/core.json +0 -0
  253. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/data/leaves/domain-a/topic-x.md +0 -0
  254. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/data/leaves/domain-a/topic-y.md +0 -0
  255. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/data/leaves/domain-b/topic-z.md +0 -0
  256. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/health.ts +0 -0
  257. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/llm-retry.ts +0 -0
  258. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/needle.ts +0 -0
  259. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/orchestrate.ts +0 -0
  260. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/snapshot.ts +0 -0
  261. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/types.ts +0 -0
  262. /package/src/{plugins/defaults/memory-v3-shadow → memory/v3}/working-set.ts +0 -0
@@ -47,8 +47,6 @@ import { resolve } from "node:path";
47
47
 
48
48
  import { getConfig } from "../../../config/loader.js";
49
49
  import type { InjectionMatcher } from "../../../context/strip-injections.js";
50
- import { findConversationOrSubagent } from "../../../daemon/conversation-registry.js";
51
- import { resolveWorkspaceTopLevelContext } from "../../../daemon/conversation-workspace.js";
52
50
  import { readNowScratchpad } from "../../../daemon/now-scratchpad.js";
53
51
  import { getInContextPkbPaths } from "../../../daemon/pkb-context-tracker.js";
54
52
  import { buildPkbReminder } from "../../../daemon/pkb-reminder-builder.js";
@@ -127,22 +125,12 @@ Then help the user clean up storage. Prefer safe inspection steps first, such as
127
125
  Do not work on unrelated tasks until enough space is freed to clear the lock or the user explicitly overrides it. Background processes and messages from trusted contacts are blocked while this cleanup mode is active.
128
126
  </disk_pressure_warning>`;
129
127
 
130
- /**
131
- * `disk-pressure-warning` injector — order 5, prepend-user-tail.
132
- *
133
- * Emits the storage cleanup-mode warning at the very top of the user tail when
134
- * the turn is restricted to disk-pressure cleanup. Reads the cleanup-mode flag
135
- * off the live `Conversation` looked up by conversation id — the agent loop
136
- * sets `diskPressureCleanupModeActive` when it classifies the turn's
137
- * disk-pressure policy — rather than having the loop thread it as an injection
138
- * input.
139
- */
140
128
  const diskPressureWarningInjector: Injector = {
141
129
  name: "disk-pressure-warning",
142
130
  order: DEFAULT_INJECTOR_ORDER.diskPressureWarning,
143
131
  async produce(ctx: TurnContext): Promise<InjectionBlock | null> {
144
- const conversation = findConversationOrSubagent(ctx.conversationId);
145
- if (!conversation?.diskPressureCleanupModeActive) return null;
132
+ const inputs = readInjectionInputs(ctx);
133
+ if (!inputs.diskPressureContext?.cleanupModeActive) return null;
146
134
  return {
147
135
  id: "disk-pressure-warning",
148
136
  text: DISK_PRESSURE_WARNING_PROMPT,
@@ -162,52 +150,26 @@ function isPkbInjectionSilencedByV2(): boolean {
162
150
  return getConfig().memory.v2.enabled;
163
151
  }
164
152
 
165
- /**
166
- * Matchers that mark a persisted `<workspace>` top-level injection. Uses the
167
- * `{ prefix, suffix }` wrapper shape so user-authored text merely starting with
168
- * `<workspace>\n` is never mistaken for an injection — matching the full-wrapper
169
- * requirement the compaction strip uses for this block. The legacy
170
- * `<workspace_top_level>` tag (pre-rename history) counts as present too.
171
- */
172
- const WORKSPACE_BLOCK_MATCHERS: readonly InjectionMatcher[] = [
173
- { prefix: "<workspace>\n", suffix: "\n</workspace>" },
174
- "<workspace_top_level>",
175
- ];
176
-
177
153
  /**
178
154
  * `workspace-context` injector — order 10, prepend-user-tail.
179
155
  *
180
156
  * Injects the workspace top-level directory context at the very top of the
181
- * user tail's content so the assistant sees a workspace grounding block before
182
- * any other per-turn context.
183
- *
184
- * Sources the dirty-guarded top-level cache itself via
185
- * {@link resolveWorkspaceTopLevelContext} (keyed by conversation id) rather than
186
- * having the agent loop compute and thread it. Decides inject/skip by presence
187
- * detection: the block is (re)injected only when it is absent from the working
188
- * messages — true on the first turn and right after compaction strips it — and
189
- * skipped on normal cached turns where it already persists in history. This
190
- * keeps the conversation prefix stable for Anthropic's prefix caching.
157
+ * user tail's content so the assistant sees a workspace grounding block
158
+ * before any other per-turn context.
191
159
  *
192
160
  * Gating:
193
161
  * - `mode === "full"` (skipped in minimal mode).
194
- * - the rendered workspace context is a non-null, non-empty string.
195
- * - no `<workspace>` block is already present in `runMessages`.
162
+ * - `workspaceTopLevelContext` is a non-null, non-empty string.
196
163
  */
197
164
  const workspaceContextInjector: Injector = {
198
165
  name: "workspace-context",
199
166
  order: DEFAULT_INJECTOR_ORDER.workspaceContext,
200
- async produce(
201
- ctx: TurnContext,
202
- runMessages?: Message[],
203
- ): Promise<InjectionBlock | null> {
167
+ async produce(ctx: TurnContext): Promise<InjectionBlock | null> {
204
168
  const inputs = readInjectionInputs(ctx);
205
169
  const mode = inputs.mode ?? "full";
206
170
  if (mode !== "full") return null;
207
- const text = resolveWorkspaceTopLevelContext(ctx.conversationId);
171
+ const text = inputs.workspaceTopLevelContext;
208
172
  if (!text) return null;
209
- if (hasInjectedUserTextBlock(runMessages, WORKSPACE_BLOCK_MATCHERS))
210
- return null;
211
173
  return {
212
174
  id: "workspace-context",
213
175
  text,
@@ -29,7 +29,6 @@ import {
29
29
  type RuntimeInjectionOptions,
30
30
  type RuntimeInjectionResult,
31
31
  } from "../../../../daemon/conversation-runtime-assembly.js";
32
- import { resolveTrustClass } from "../../../../daemon/trust-context.js";
33
32
  import { stripHistoricalWebSearchResults } from "../../../../daemon/web-search-history.js";
34
33
  import { getLiveGraphMemory } from "../../../../memory/graph/conversation-graph-memory.js";
35
34
  import type { PluginLogger } from "../../../../plugin-api/types.js";
@@ -52,14 +51,15 @@ export interface PostCompactionHookInput {
52
51
  /**
53
52
  * Everything the hook needs in a single context: the loop-supplied
54
53
  * {@link PostCompactionHookInput}, the resolved {@link RuntimeInjectionOptions}
55
- * (spread top-level so each field stays individually addressable), and a
56
- * turn-scoped logger. The memory graph handle is not part of this context —
57
- * the hook sources it internally via {@link getLiveGraphMemory} and the
58
- * actor's trust class is derived from {@link PostCompactionHookInput.turnContext}
59
- * rather than threaded in.
54
+ * (spread top-level so each field stays individually addressable), and the
55
+ * conversation-scoped state the options bag cannot carry (actor trust and a
56
+ * turn-scoped logger). The memory graph handle is not part of this context —
57
+ * the hook sources it internally via {@link getLiveGraphMemory}.
60
58
  */
61
59
  export interface PostCompactContext
62
60
  extends RuntimeInjectionOptions, PostCompactionHookInput {
61
+ /** True when the actor for this turn is trusted (guardian-class). */
62
+ isTrustedActor: boolean;
63
63
  /** Turn-scoped logger for diagnostics emitted while re-injecting. */
64
64
  logger: PluginLogger;
65
65
  }
@@ -67,18 +67,14 @@ export interface PostCompactContext
67
67
  export default async function postCompactReinject(
68
68
  ctx: PostCompactContext,
69
69
  ): Promise<RuntimeInjectionResult> {
70
- const { history, logger, ...options } = ctx;
70
+ const { history, isTrustedActor, logger, ...options } = ctx;
71
71
  const result = await applyRuntimeInjections(history, options);
72
72
  // Re-track the nodes the memory graph last injected so they survive against
73
73
  // the re-injected history. Untrusted actors and minimal-mode turns never
74
74
  // received a memory-graph injection, so there is nothing to re-track. The
75
- // actor's trust class is derived from the turn's own trust context (the same
76
- // value the injector chain resolves), not threaded in from the loop. The
77
75
  // live graph handle is looked up from the plugin's own registry by the
78
76
  // turn's conversation id — the same instance the turn's retrieval mutated,
79
77
  // so re-tracking sees the real cached-node state.
80
- const isTrustedActor =
81
- resolveTrustClass(options.turnContext?.trust) === "guardian";
82
78
  if (isTrustedActor && options.mode !== "minimal") {
83
79
  getLiveGraphMemory(
84
80
  options.turnContext?.conversationId,
@@ -18,9 +18,9 @@
18
18
  * ordering assumptions about when `defaultInjectors` finishes initializing.
19
19
  */
20
20
 
21
+ import { memoryV3Injector } from "../../../memory/v3/shadow-plugin.js";
21
22
  import type { Injector } from "../../types.js";
22
- import { memoryV3Injector } from "../memory-v3-shadow/injector.js";
23
- import { defaultInjectors } from "./injectors.js";
23
+ import { defaultInjectors } from "../injectors/register.js";
24
24
 
25
25
  let cachedChain: Injector[] | null = null;
26
26
 
@@ -0,0 +1,126 @@
1
+ import type { ContextWindowCompactOptions } from "../../../../context/window-manager.js";
2
+ import {
3
+ createInitialReducerState,
4
+ reduceContextOverflow,
5
+ type ReducerState,
6
+ } from "../../../../daemon/context-overflow-reducer.js";
7
+ import type {
8
+ Middleware,
9
+ OverflowReduceArgs,
10
+ OverflowReduceResult,
11
+ } from "../../../types.js";
12
+
13
+ /**
14
+ * Default middleware for the `overflowReduce` pipeline — implements the
15
+ * historical tier-loop semantics (forced compaction, tool-result truncation,
16
+ * media stubbing, injection downgrade) plus the post-step re-injection /
17
+ * re-estimation dance.
18
+ *
19
+ * The middleware intentionally ignores `next`. Overflow reduction is a
20
+ * *terminal* behavior: there is no downstream implementation to defer to when
21
+ * a user-supplied middleware short-circuits. Later plugins may still wrap this
22
+ * one (outer middleware can observe each reduction iteration via their own
23
+ * `next` callback) but the default never delegates to a hypothetical base
24
+ * handler — the inline loop was the base.
25
+ */
26
+ const defaultOverflowReduceMiddleware: Middleware<
27
+ OverflowReduceArgs,
28
+ OverflowReduceResult
29
+ > = async function defaultOverflowReduceMiddleware(args, _next, _ctx) {
30
+ let messages = args.messages;
31
+ let runMessages = args.runMessages;
32
+ let injectionMode: "full" | "minimal" = "full";
33
+ let reducerState: ReducerState = createInitialReducerState();
34
+ let reducerCompacted = false;
35
+ let attempts = 0;
36
+
37
+ while (attempts < args.maxAttempts && !reducerState.exhausted) {
38
+ // Abort check at the top of every iteration. When the pipeline runner
39
+ // arms a timeout (or the caller aborts externally), `args.abortSignal`
40
+ // is linked to that trigger via `linkAbortSignal`, so this check lets
41
+ // us bail out BETWEEN iterations rather than letting another round of
42
+ // compaction / re-injection mutate `ctx.messages` after the turn has
43
+ // already failed. Individual `reduceContextOverflow` calls also honor
44
+ // the signal, but without this gate a fresh iteration could still
45
+ // start after the signal fires, since the previous one returned
46
+ // normally before the abort propagated.
47
+ args.abortSignal?.throwIfAborted();
48
+
49
+ attempts++;
50
+ args.emitActivityState();
51
+
52
+ const basisMessages = messages;
53
+ const step = await reduceContextOverflow(
54
+ basisMessages,
55
+ {
56
+ providerName: args.providerName,
57
+ systemPrompt: args.systemPrompt,
58
+ contextWindow: args.contextWindow,
59
+ targetTokens: args.preflightBudget,
60
+ toolTokenBudget: args.toolTokenBudget,
61
+ },
62
+ reducerState,
63
+ (msgs, signal, opts: ContextWindowCompactOptions) =>
64
+ args.compactFn(msgs, signal, opts),
65
+ args.abortSignal,
66
+ );
67
+
68
+ reducerState = step.state;
69
+ messages = step.messages;
70
+ injectionMode = step.state.injectionMode;
71
+
72
+ // Per-iteration compaction flag: whether THIS step just produced a
73
+ // fresh compaction. PKB / NOW re-injection is gated on this — see the
74
+ // reinjectForMode JSDoc for why the two signals differ.
75
+ const stepCompacted = step.compactionResult?.compacted === true;
76
+
77
+ // Let the orchestrator apply compaction side effects (circuit-breaker
78
+ // tracking, event emission, ctx mutation) before we re-inject.
79
+ if (step.compactionResult) {
80
+ await args.onCompactionResult(step.compactionResult, basisMessages);
81
+ if (stepCompacted) {
82
+ reducerCompacted = true;
83
+ }
84
+ }
85
+
86
+ // Second abort gate — if the side effects or the step itself took us
87
+ // past the deadline, don't rebuild runMessages or iterate again.
88
+ args.abortSignal?.throwIfAborted();
89
+
90
+ // Rebuild runMessages via the orchestrator-supplied helper (which
91
+ // re-runs `applyRuntimeInjections` with potentially downgraded mode
92
+ // and freshly re-hydrated PKB/NOW blocks after compaction). We pass
93
+ // the current reduced `messages` explicitly so the orchestrator never
94
+ // has to read from mutable shared state to rebuild runMessages — a
95
+ // tier that doesn't trigger compaction (tool-result truncation, media
96
+ // stubbing) won't update `ctx.messages` on its own.
97
+ //
98
+ // `stepCompacted` and `reducerCompacted` are both passed so the
99
+ // orchestrator can gate PKB / NOW re-injection per-iteration while
100
+ // keeping `slackChronologicalMessages` suppressed once any iteration
101
+ // has compacted.
102
+ runMessages = await args.reinjectForMode(
103
+ messages,
104
+ injectionMode,
105
+ stepCompacted,
106
+ reducerCompacted,
107
+ );
108
+
109
+ // Re-estimate with injections included — `step.estimatedTokens` was
110
+ // computed on bare history and doesn't account for tokens added by
111
+ // runtime injections.
112
+ const postInjectionTokens = args.estimatePostInjection(runMessages);
113
+ if (postInjectionTokens <= args.preflightBudget) break;
114
+ }
115
+
116
+ return {
117
+ messages,
118
+ runMessages,
119
+ injectionMode,
120
+ reducerState,
121
+ reducerCompacted,
122
+ attempts,
123
+ };
124
+ };
125
+
126
+ export default defaultOverflowReduceMiddleware;
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "default-overflow-reduce",
3
+ "version": "1.0.0",
4
+ "description": "First-party default plugin wrapping the assistant's built-in overflow-reduce pipeline with a passthrough implementation.",
5
+ "private": true,
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./register.ts",
9
+ "engines": {
10
+ "node": ">=20.12.0"
11
+ },
12
+ "peerDependencies": {
13
+ "@vellumai/plugin-api": "^0.8.0"
14
+ }
15
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Default `overflowReduce` plugin — extracted verbatim from the inline
3
+ * preflight reducer loop that previously lived in
4
+ * `daemon/conversation-agent-loop.ts` (the `while (preflightAttempts < …)`
5
+ * block around lines 1045–1156 before PR 23).
6
+ *
7
+ * The plugin owns the reducer tier-loop (forced compaction, tool-result
8
+ * truncation, media stubbing, injection downgrade) and the post-step
9
+ * re-injection / re-estimation dance. Orchestrator-specific coupling
10
+ * (activity emission, circuit-breaker tracking, compaction-result
11
+ * application, runtime injection reassembly) is threaded in through the
12
+ * callbacks carried on {@link OverflowReduceArgs}; the plugin itself has no
13
+ * access to the agent-loop context object.
14
+ *
15
+ * The forced-compaction tier runs through the orchestrator-supplied
16
+ * `compactFn`, which routes into the `compaction` plugin pipeline so
17
+ * registered compaction middleware observes reducer-initiated invocations
18
+ * alongside the orchestrator-owned call sites. Non-compaction tiers
19
+ * (tool-result truncation, media stubbing, injection downgrade) remain
20
+ * in-process: they mutate message arrays directly without crossing a
21
+ * pipeline boundary. The reducer itself runs under the `overflowReduce`
22
+ * pipeline, so the full layering is `overflowReduce` → reducer tier loop
23
+ * → (for the forced-compaction tier only) nested `compaction` pipeline.
24
+ */
25
+
26
+ import { type Plugin } from "../../types.js";
27
+ import defaultOverflowReduceMiddleware from "./middlewares/overflowReduce.js";
28
+ import pkg from "./package.json" with { type: "json" };
29
+
30
+ /**
31
+ * The default plugin registered at bootstrap. No `init`/`onShutdown` —
32
+ * registering the middleware is the only behavior.
33
+ */
34
+ export const defaultOverflowReducePlugin: Plugin = {
35
+ manifest: {
36
+ name: pkg.name,
37
+ version: pkg.version,
38
+ },
39
+ middleware: {
40
+ overflowReduce: defaultOverflowReduceMiddleware,
41
+ },
42
+ };
@@ -31,8 +31,8 @@
31
31
  * absolute paths or workspace-local copies — the TypeScript compiler erases
32
32
  * them and they have no module-identity effect at runtime.
33
33
  *
34
- * See `experimental/plugins/README.md` for the full authoring contract and
35
- * `assistant/examples/plugins/echo/` for a worked example.
34
+ * See `assistant/docs/plugins.md` for the full authoring contract and
35
+ * `assistant/examples/plugins/echo/register.ts` for a worked example.
36
36
  */
37
37
 
38
38
  import { assistantEventHub } from "../runtime/assistant-event-hub.js";
@@ -1,16 +1,297 @@
1
1
  /**
2
- * Plugin hook runner.
2
+ * Plugin pipeline runner.
3
3
  *
4
- * A "hook" is a named lifecycle event (`user-prompt-submit`, `post-tool-use`,
5
- * …) that every registered plugin may handle. The runner walks each plugin's
6
- * hook for a given event in registration order, threading a context value
7
- * through the chain so hooks can observe and transform it.
4
+ * A "pipeline" is a named chain of {@link Middleware}s wrapped around a
5
+ * terminal handler (the original behavior that existed before plugins). The
6
+ * runner composes the chain in onion order, runs it under an optional
7
+ * per-pipeline timeout, and emits one structured log record per invocation
8
+ * covering success, error, and timeout uniformly.
8
9
  *
9
- * Design doc: `.private/plans/agent-plugin-system.md`.
10
+ * Design doc: `.private/plans/agent-plugin-system.md` (PR 12 notes).
11
+ *
12
+ * Semantics:
13
+ * - Onion composition — the first element of `middlewares` is the outermost
14
+ * wrapper; it sees the request first and the response last. Terminal runs
15
+ * in the middle.
16
+ * - Strict-fail — there is NO `try/catch` around user middleware. Errors,
17
+ * including `PluginTimeoutError` from a breached budget, propagate to the
18
+ * caller. The `finally` block only handles logging, never error recovery.
19
+ * - Timeout — when `timeoutMs` is a finite number, the invocation races a
20
+ * timer. A timeout rejection is a `PluginTimeoutError` carrying the
21
+ * pipeline name, the best-known offending plugin (from `ctx.pluginName`),
22
+ * and elapsed ms. When `timeoutMs` is `null`/`undefined`, no timer is
23
+ * armed — pipelines like `compaction` rely on downstream timeouts instead.
10
24
  */
11
25
 
26
+ import type { Logger } from "pino";
27
+
12
28
  import type { HookName } from "../plugin-api/constants.js";
29
+ import { getLogger } from "../util/logger.js";
13
30
  import { getHooksFor } from "./registry.js";
31
+ import {
32
+ type Middleware,
33
+ type PipelineName,
34
+ PluginTimeoutError,
35
+ type TurnContext,
36
+ } from "./types.js";
37
+
38
+ const moduleLogger = getLogger("plugin-pipeline");
39
+
40
+ // ─── Default timeouts ───────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Default per-pipeline timeout budgets in milliseconds. A value of `null`
44
+ * means the runner does NOT arm a timer — the pipeline relies on its
45
+ * downstream for budgeting (e.g. `compaction` defers to the provider-level
46
+ * HTTP timeouts of the summary call it wraps).
47
+ *
48
+ * Callers pass the appropriate entry as `runPipeline`'s `timeoutMs` argument.
49
+ * The design doc locks these numbers in; do not tweak without coordinating
50
+ * a design update.
51
+ */
52
+ export const DEFAULT_TIMEOUTS: Record<PipelineName, number | null> = {
53
+ compaction: null,
54
+ overflowReduce: null,
55
+ circuitBreaker: null,
56
+ };
57
+
58
+ // ─── Composition ────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Compose an ordered list of {@link Middleware}s around a terminal handler
62
+ * using onion semantics. The first element of `middlewares` is the outermost
63
+ * layer.
64
+ *
65
+ * The returned function accepts `(args, ctx)` and runs the entire chain:
66
+ * ```
67
+ * middlewares[0] → middlewares[1] → ... → terminal → ... → middlewares[1] → middlewares[0]
68
+ * ```
69
+ *
70
+ * Middlewares that never call `next` short-circuit the chain — the terminal
71
+ * and any deeper middleware are never invoked. Middlewares that throw abort
72
+ * the chain; the error flows back out through any outer middleware unchanged
73
+ * (no internal try/catch).
74
+ */
75
+ export function composeMiddleware<A, R>(
76
+ middlewares: ReadonlyArray<Middleware<A, R>>,
77
+ terminal: (args: A) => Promise<R>,
78
+ ): (args: A, ctx: TurnContext) => Promise<R> {
79
+ return (args, ctx) => {
80
+ // Walk back-to-front, wrapping each middleware around `next`. The last
81
+ // middleware in the array wraps `terminal`; earlier entries wrap the
82
+ // accumulated chain so they execute first.
83
+ let next: (args: A) => Promise<R> = terminal;
84
+ for (let i = middlewares.length - 1; i >= 0; i--) {
85
+ const mw = middlewares[i]!;
86
+ const downstream = next;
87
+ next = (innerArgs: A) => mw(innerArgs, downstream, ctx);
88
+ }
89
+ return next(args);
90
+ };
91
+ }
92
+
93
+ // ─── Abort-signal linking ───────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Return a shallow-cloned `args` object where every `AbortSignal`-typed
97
+ * top-level property is swapped for a signal linked to `internalController`.
98
+ *
99
+ * "Linked" here means: the returned signal aborts when either the caller's
100
+ * original signal aborts OR `internalController` aborts (e.g. the pipeline
101
+ * timer fires). The caller's args are never mutated.
102
+ *
103
+ * When `args` carries no `AbortSignal` property, the original object is
104
+ * returned unchanged — pipelines whose terminals don't consume a signal
105
+ * (e.g. `circuitBreaker`) see identical behavior to before.
106
+ * The return value's `cleanup()` tears down any `addEventListener("abort",
107
+ * ...)` handlers attached to the caller's signal so a pipeline that
108
+ * completes successfully doesn't leak listeners on the caller's controller.
109
+ */
110
+ function linkAbortSignal<A>(
111
+ args: A,
112
+ internalController: AbortController,
113
+ ): { args: A; cleanup: () => void } {
114
+ if (args === null || typeof args !== "object") {
115
+ return { args, cleanup: () => {} };
116
+ }
117
+ const abortListeners: Array<{
118
+ signal: AbortSignal;
119
+ listener: () => void;
120
+ }> = [];
121
+ const record = args as Record<string, unknown>;
122
+ const patched: Record<string, unknown> = { ...record };
123
+ let swappedAny = false;
124
+ for (const key of Object.keys(record)) {
125
+ const value = record[key];
126
+ if (value instanceof AbortSignal) {
127
+ swappedAny = true;
128
+ if (value.aborted) {
129
+ // Caller already aborted — propagate immediately so the inner call
130
+ // sees the abort without waiting for the listener to fire.
131
+ internalController.abort();
132
+ } else {
133
+ const listener = () => internalController.abort();
134
+ value.addEventListener("abort", listener, { once: true });
135
+ abortListeners.push({ signal: value, listener });
136
+ }
137
+ patched[key] = internalController.signal;
138
+ }
139
+ }
140
+ const cleanup = () => {
141
+ for (const { signal, listener } of abortListeners) {
142
+ signal.removeEventListener("abort", listener);
143
+ }
144
+ };
145
+ return { args: (swappedAny ? patched : args) as A, cleanup };
146
+ }
147
+
148
+ // ─── Runner ─────────────────────────────────────────────────────────────────
149
+
150
+ type PipelineLogRecord = {
151
+ event: "plugin.pipeline";
152
+ pipeline: PipelineName;
153
+ chain: ReadonlyArray<string>;
154
+ durationMs: number;
155
+ outcome: "success" | "error" | "timeout";
156
+ pluginName?: string;
157
+ errorName?: string;
158
+ errorMessage?: string;
159
+ errorStack?: string;
160
+ timeoutMs?: number;
161
+ requestId: string;
162
+ conversationId: string;
163
+ turnIndex?: number;
164
+ };
165
+
166
+ /**
167
+ * Best-effort detection of a pino-compatible logger on the `TurnContext`.
168
+ *
169
+ * `TurnContext` intentionally doesn't require a logger slot — most code paths
170
+ * use the module-level logger. Some callers (notably plugin-scoped pipelines
171
+ * in later PRs) may attach a child logger via `(ctx as any).logger`. We type
172
+ * the field loosely to avoid coupling `TurnContext` to pino.
173
+ */
174
+ function selectLogger(ctx: TurnContext): Logger {
175
+ const maybe = (ctx as { logger?: unknown }).logger;
176
+ if (maybe && typeof maybe === "object" && "info" in maybe) {
177
+ return maybe as Logger;
178
+ }
179
+ return moduleLogger;
180
+ }
181
+
182
+ /**
183
+ * Execute a named pipeline: compose middleware, run it under an optional
184
+ * timeout, and emit one structured log record covering success, error, or
185
+ * timeout uniformly.
186
+ *
187
+ * @param name The pipeline identifier. Used for telemetry and error
188
+ * attribution.
189
+ * @param middlewares Ordered list of middlewares to wrap around `terminal`.
190
+ * Names for the log `chain` are pulled from `Function.name`
191
+ * (empty strings are kept so length always matches).
192
+ * @param terminal The original behavior. Runs when all middlewares call
193
+ * `next`.
194
+ * @param args Pipeline-specific input payload.
195
+ * @param ctx Per-turn context. May carry an optional `logger` slot;
196
+ * otherwise the module logger is used.
197
+ * @param timeoutMs Deadline in milliseconds. `null`/`undefined` disables
198
+ * the timer entirely (inherits downstream timeouts).
199
+ *
200
+ * @throws {PluginTimeoutError} When `timeoutMs` is non-null and the chain
201
+ * exceeds the budget. `ctx.pluginName` (if set) is attached as the
202
+ * offending plugin.
203
+ * @throws Any error produced by user middleware or the terminal — flows
204
+ * through unchanged.
205
+ */
206
+ export async function runPipeline<A, R>(
207
+ name: PipelineName,
208
+ middlewares: ReadonlyArray<Middleware<A, R>>,
209
+ terminal: (args: A) => Promise<R>,
210
+ args: A,
211
+ ctx: TurnContext,
212
+ timeoutMs?: number | null,
213
+ ): Promise<R> {
214
+ const logger = selectLogger(ctx);
215
+ const chain = middlewares.map((m) => m.name || "anonymous");
216
+ const composed = composeMiddleware(middlewares, terminal);
217
+ const start = performance.now();
218
+
219
+ let outcome: "success" | "error" | "timeout" = "success";
220
+ let thrown: unknown = undefined;
221
+ let timer: ReturnType<typeof setTimeout> | undefined;
222
+
223
+ try {
224
+ if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
225
+ const budget = timeoutMs;
226
+ // Internal controller: fires on either (a) the timer, so the inner call
227
+ // actually observes the budget breach instead of running forever after
228
+ // `Promise.race` rejects; or (b) the caller's own signal, so external
229
+ // cancellation still reaches the inner call transparently. Any
230
+ // `AbortSignal`-typed property on `args` (e.g. `signal` on
231
+ // `CompactionArgs`, `abortSignal` on `OverflowReduceArgs`) is swapped
232
+ // for this linked signal on a shallow-cloned args object — we never
233
+ // mutate the caller's args.
234
+ const internalController = new AbortController();
235
+ const { args: effectiveArgs, cleanup } = linkAbortSignal(
236
+ args,
237
+ internalController,
238
+ );
239
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
240
+ timer = setTimeout(() => {
241
+ internalController.abort();
242
+ reject(
243
+ new PluginTimeoutError(
244
+ name,
245
+ ctx.pluginName,
246
+ Math.round(performance.now() - start),
247
+ ),
248
+ );
249
+ }, budget);
250
+ });
251
+ try {
252
+ return await Promise.race([
253
+ composed(effectiveArgs, ctx),
254
+ timeoutPromise,
255
+ ]);
256
+ } finally {
257
+ cleanup();
258
+ }
259
+ }
260
+ return await composed(args, ctx);
261
+ } catch (err) {
262
+ thrown = err;
263
+ outcome = err instanceof PluginTimeoutError ? "timeout" : "error";
264
+ throw err;
265
+ } finally {
266
+ if (timer !== undefined) clearTimeout(timer);
267
+ const durationMs = Math.round(performance.now() - start);
268
+ const record: PipelineLogRecord = {
269
+ event: "plugin.pipeline",
270
+ pipeline: name,
271
+ chain,
272
+ durationMs,
273
+ outcome,
274
+ requestId: ctx.requestId,
275
+ conversationId: ctx.conversationId,
276
+ };
277
+ if (ctx.turnIndex !== undefined) record.turnIndex = ctx.turnIndex;
278
+ if (ctx.pluginName !== undefined) record.pluginName = ctx.pluginName;
279
+ if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
280
+ record.timeoutMs = timeoutMs;
281
+ }
282
+ if (thrown !== undefined) {
283
+ if (thrown instanceof Error) {
284
+ record.errorName = thrown.name;
285
+ record.errorMessage = thrown.message;
286
+ if (thrown.stack) record.errorStack = thrown.stack;
287
+ } else {
288
+ record.errorName = "NonError";
289
+ record.errorMessage = String(thrown);
290
+ }
291
+ }
292
+ logger.info(record, "plugin.pipeline");
293
+ }
294
+ }
14
295
 
15
296
  // ─── Hook runner ────────────────────────────────────────────────────────────
16
297
 
@@ -21,6 +302,12 @@ import { getHooksFor } from "./registry.js";
21
302
  * that replaces the threaded value for subsequent hooks. The final context
22
303
  * after the chain settles is returned.
23
304
  *
305
+ * `runHook` is the hook-side counterpart to {@link runPipeline}:
306
+ * `runPipeline` composes middleware around a terminal handler for stateful
307
+ * request/response pipelines (compaction, overflow reduction, etc.);
308
+ * `runHook` walks ordered hook functions for declarative chain-style
309
+ * context transformations (`user-prompt-submit`, `post-tool-use`).
310
+ *
24
311
  * @param name The hook identifier — pick one from {@link HOOKS}.
25
312
  * @param initialCtx Context the first hook receives.
26
313
  * @returns The final context after the chain settles. Same reference as