@vellumai/assistant 0.8.11 → 0.8.12-staging.2

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 (244) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +633 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -10,6 +10,8 @@ import { Readable, Writable } from "node:stream";
10
10
 
11
11
  import type {
12
12
  Agent,
13
+ AuthMethod,
14
+ AuthMethodEnvVar,
13
15
  Client,
14
16
  InitializeResponse,
15
17
  NewSessionResponse,
@@ -22,6 +24,31 @@ import type { AcpAgentConfig } from "./types.js";
22
24
 
23
25
  const log = getLogger("acp");
24
26
 
27
+ /**
28
+ * JSON-RPC error code agents use to signal that authentication is required
29
+ * (matches the SDK's RequestError.authRequired()).
30
+ */
31
+ const AUTH_REQUIRED_CODE = -32000;
32
+
33
+ /**
34
+ * Detects the ACP auth-required error. Checks the `code` property rather than
35
+ * `instanceof acp.RequestError` so plain JSON-RPC error objects are also
36
+ * recognized.
37
+ */
38
+ function isAuthRequiredError(err: unknown): boolean {
39
+ return (
40
+ typeof err === "object" &&
41
+ err !== null &&
42
+ (err as { code?: unknown }).code === AUTH_REQUIRED_CODE
43
+ );
44
+ }
45
+
46
+ function isEnvVarMethod(
47
+ method: AuthMethod,
48
+ ): method is AuthMethodEnvVar & { type: "env_var" } {
49
+ return "type" in method && method.type === "env_var";
50
+ }
51
+
25
52
  /**
26
53
  * Factory function type for creating ACP client handlers.
27
54
  * PR 5 will provide the real VellumAcpClientHandler implementation.
@@ -35,6 +62,12 @@ export class AcpAgentProcess {
35
62
  private proc: ChildProcess | null = null;
36
63
  private connection: acp.ClientSideConnection | null = null;
37
64
  private initializeResponse: InitializeResponse | null = null;
65
+ /**
66
+ * Merged env captured at spawn() so auth satisfiability checks match the
67
+ * env the child process actually received, even if process.env changes
68
+ * afterwards.
69
+ */
70
+ private spawnedEnv: NodeJS.ProcessEnv | null = null;
38
71
 
39
72
  constructor(
40
73
  public readonly agentId: string,
@@ -51,10 +84,11 @@ export class AcpAgentProcess {
51
84
  "Spawning ACP agent process",
52
85
  );
53
86
 
87
+ this.spawnedEnv = { ...process.env, ...this.config.env };
54
88
  this.proc = spawn(this.config.command, this.config.args, {
55
89
  cwd,
56
90
  stdio: ["pipe", "pipe", "pipe"],
57
- env: { ...process.env, ...this.config.env },
91
+ env: this.spawnedEnv,
58
92
  });
59
93
 
60
94
  const stream = acp.ndJsonStream(
@@ -94,13 +128,11 @@ export class AcpAgentProcess {
94
128
  * Initializes the ACP connection by negotiating protocol version and capabilities.
95
129
  */
96
130
  async initialize(): Promise<InitializeResponse> {
97
- if (!this.connection) {
98
- throw new Error(`ACP agent "${this.agentId}" is not spawned`);
99
- }
131
+ const connection = this.requireConnection();
100
132
 
101
133
  log.info({ agentId: this.agentId }, "Initializing ACP connection");
102
134
 
103
- const response = await this.connection.initialize({
135
+ const response = await connection.initialize({
104
136
  protocolVersion: acp.PROTOCOL_VERSION,
105
137
  clientInfo: { name: "vellum", version: "1.0.0" },
106
138
  clientCapabilities: {
@@ -133,20 +165,119 @@ export class AcpAgentProcess {
133
165
  }
134
166
 
135
167
  /**
136
- * Creates a new ACP session in the specified working directory.
137
- * Returns the session ID.
168
+ * Authentication methods the agent advertised at initialize.
169
+ * Returns an empty array before initialize() resolves.
138
170
  */
139
- async createSession(cwd: string): Promise<string> {
171
+ private get authMethods(): AuthMethod[] {
172
+ return this.initializeResponse?.authMethods ?? [];
173
+ }
174
+
175
+ /**
176
+ * Selects the first advertised env_var auth method whose required variables
177
+ * are all present (non-empty) in the env the agent process was spawned with.
178
+ *
179
+ * Terminal-type and agent-driven (untyped) methods are never selected:
180
+ * auto-triggering an interactive login would hang the headless daemon.
181
+ */
182
+ private selectEnvVarAuthMethod(): AuthMethod | undefined {
183
+ const env = this.spawnedEnv;
184
+ if (!env) return undefined;
185
+
186
+ return this.authMethods.find((method) => {
187
+ if (!isEnvVarMethod(method)) return false;
188
+
189
+ // `vars` is required by the SDK type, but agent responses aren't
190
+ // runtime-validated — tolerate an out-of-spec agent omitting it so the
191
+ // caller gets the friendly auth error instead of a TypeError.
192
+ const requiredVars = (method.vars ?? []).filter((v) => !v.optional);
193
+ if (requiredVars.length === 0) return false;
194
+
195
+ return requiredVars.every((v) => {
196
+ const value = env[v.name];
197
+ return typeof value === "string" && value.length > 0;
198
+ });
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Returns the live connection, throwing the standard not-spawned error if
204
+ * the agent was never spawned or its process has since exited.
205
+ */
206
+ private requireConnection(): acp.ClientSideConnection {
140
207
  if (!this.connection) {
141
208
  throw new Error(`ACP agent "${this.agentId}" is not spawned`);
142
209
  }
210
+ return this.connection;
211
+ }
212
+
213
+ /**
214
+ * Runs an operation, and if the agent rejects with the ACP auth-required
215
+ * error, authenticates via a satisfiable env_var auth method and retries
216
+ * the operation exactly once.
217
+ */
218
+ private async withAuthRetry<T>(op: () => Promise<T>): Promise<T> {
219
+ try {
220
+ return await op();
221
+ } catch (err) {
222
+ if (!isAuthRequiredError(err)) throw err;
223
+
224
+ // The agent may have exited between the auth_required rejection and
225
+ // this retry path; fail with the standard not-spawned error.
226
+ const connection = this.requireConnection();
227
+
228
+ const method = this.selectEnvVarAuthMethod();
229
+ if (!method) {
230
+ throw new Error(
231
+ `ACP agent "${this.agentId}" requires authentication. ` +
232
+ `Advertised methods: ${this.describeAuthMethods()}. ` +
233
+ "Set the required env var under acp.agents.<id>.env in config.json, " +
234
+ "store it via 'assistant credentials set --service acp --field <field>', " +
235
+ "or complete the agent's own login flow in the workspace.",
236
+ );
237
+ }
238
+
239
+ log.info(
240
+ { agentId: this.agentId, methodId: method.id },
241
+ "ACP agent returned auth_required; authenticating with env_var method",
242
+ );
143
243
 
244
+ await connection.authenticate({ methodId: method.id });
245
+ return await op();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Renders the agent's advertised auth methods for error messages, e.g.
251
+ * `"Login with ChatGPT" (chatgpt), "Use OPENAI_API_KEY" (env var OPENAI_API_KEY)`.
252
+ */
253
+ private describeAuthMethods(): string {
254
+ if (this.authMethods.length === 0) return "none";
255
+
256
+ return this.authMethods
257
+ .map((method) => {
258
+ // `vars ?? []`: tolerate out-of-spec agents omitting the field —
259
+ // this renders inside the friendly auth error, which must not
260
+ // itself throw a TypeError.
261
+ const varNames = isEnvVarMethod(method)
262
+ ? (method.vars ?? []).map((v) => v.name).join(", ")
263
+ : "";
264
+ return varNames
265
+ ? `"${method.name}" (env var ${varNames})`
266
+ : `"${method.name}" (${method.id})`;
267
+ })
268
+ .join(", ");
269
+ }
270
+
271
+ /**
272
+ * Creates a new ACP session in the specified working directory.
273
+ * Returns the session ID.
274
+ */
275
+ async createSession(cwd: string): Promise<string> {
144
276
  log.info({ agentId: this.agentId, cwd }, "Creating ACP session");
145
277
 
146
- const result: NewSessionResponse = await this.connection.newSession({
147
- cwd,
148
- mcpServers: [],
149
- });
278
+ const result: NewSessionResponse = await this.withAuthRetry(() =>
279
+ this.requireConnection().newSession({ cwd, mcpServers: [] }),
280
+ );
150
281
 
151
282
  return result.sessionId;
152
283
  }
@@ -160,13 +291,11 @@ export class AcpAgentProcess {
160
291
  * VellumAcpClientHandler.beginReplaySuppression).
161
292
  */
162
293
  async loadSession(sessionId: string, cwd: string): Promise<void> {
163
- if (!this.connection) {
164
- throw new Error(`ACP agent "${this.agentId}" is not spawned`);
165
- }
166
-
167
294
  log.info({ agentId: this.agentId, sessionId, cwd }, "Loading ACP session");
168
295
 
169
- await this.connection.loadSession({ sessionId, cwd, mcpServers: [] });
296
+ await this.withAuthRetry(() =>
297
+ this.requireConnection().loadSession({ sessionId, cwd, mcpServers: [] }),
298
+ );
170
299
  }
171
300
 
172
301
  /**
@@ -177,13 +306,15 @@ export class AcpAgentProcess {
177
306
  * (see supportsSessionResume).
178
307
  */
179
308
  async resumeSession(sessionId: string, cwd: string): Promise<void> {
180
- if (!this.connection) {
181
- throw new Error(`ACP agent "${this.agentId}" is not spawned`);
182
- }
183
-
184
309
  log.info({ agentId: this.agentId, sessionId, cwd }, "Resuming ACP session");
185
310
 
186
- await this.connection.resumeSession({ sessionId, cwd, mcpServers: [] });
311
+ await this.withAuthRetry(() =>
312
+ this.requireConnection().resumeSession({
313
+ sessionId,
314
+ cwd,
315
+ mcpServers: [],
316
+ }),
317
+ );
187
318
  }
188
319
 
189
320
  /**
@@ -191,35 +322,31 @@ export class AcpAgentProcess {
191
322
  * Returns the prompt response (includes stopReason).
192
323
  */
193
324
  async prompt(sessionId: string, text: string): Promise<PromptResponse> {
194
- if (!this.connection) {
195
- throw new Error(`ACP agent "${this.agentId}" is not spawned`);
196
- }
197
-
198
325
  log.info(
199
326
  { agentId: this.agentId, sessionId },
200
327
  "Sending prompt to ACP agent",
201
328
  );
202
329
 
203
- return this.connection.prompt({
204
- sessionId,
205
- prompt: [{ type: "text", text }],
206
- });
330
+ return this.withAuthRetry(() =>
331
+ this.requireConnection().prompt({
332
+ sessionId,
333
+ prompt: [{ type: "text", text }],
334
+ }),
335
+ );
207
336
  }
208
337
 
209
338
  /**
210
339
  * Cancels an ongoing prompt in the specified session.
211
340
  */
212
341
  async cancel(sessionId: string): Promise<void> {
213
- if (!this.connection) {
214
- throw new Error(`ACP agent "${this.agentId}" is not spawned`);
215
- }
342
+ const connection = this.requireConnection();
216
343
 
217
344
  log.info(
218
345
  { agentId: this.agentId, sessionId },
219
346
  "Cancelling ACP session prompt",
220
347
  );
221
348
 
222
- await this.connection.cancel({ sessionId });
349
+ await connection.cancel({ sessionId });
223
350
  }
224
351
 
225
352
  /**
@@ -234,6 +361,7 @@ export class AcpAgentProcess {
234
361
  }
235
362
  this.connection = null;
236
363
  this.initializeResponse = null;
364
+ this.spawnedEnv = null;
237
365
  }
238
366
 
239
367
  /**
@@ -265,5 +393,6 @@ export class AcpAgentProcess {
265
393
  this.proc = null;
266
394
  this.connection = null;
267
395
  this.initializeResponse = null;
396
+ this.spawnedEnv = null;
268
397
  }
269
398
  }
@@ -93,6 +93,32 @@ async function injectCredential(
93
93
  return result.success ? undefined : result.reason;
94
94
  }
95
95
 
96
+ /**
97
+ * Inject an OPTIONAL credential: skip when the env var is already set
98
+ * (config.json override wins), and treat a vault miss as non-fatal — the
99
+ * adapter has its own login fallback, so spawning without the key is fine.
100
+ */
101
+ async function injectOptionalCredential(
102
+ env: Record<string, string>,
103
+ field: string,
104
+ envVar: string,
105
+ usageDescription: string,
106
+ ): Promise<void> {
107
+ if (env[envVar]) return;
108
+ const missReason = await injectCredential(
109
+ env,
110
+ field,
111
+ envVar,
112
+ usageDescription,
113
+ );
114
+ if (missReason !== undefined) {
115
+ log.debug(
116
+ { reason: missReason },
117
+ `${envVar} unavailable from the vault; spawning without it`,
118
+ );
119
+ }
120
+ }
121
+
96
122
  /**
97
123
  * Returns a NEW config with any required credentials merged into `env`.
98
124
  * Does NOT mutate the input. Throws `FailedDependencyError` if a required
@@ -123,6 +149,13 @@ async function injectCredential(
123
149
  * ways (config.json override wins, vault field `gemini_api_key` second),
124
150
  * but it is OPTIONAL: the Gemini CLI supports its own OAuth login, so a
125
151
  * vault miss proceeds without the key instead of failing the spawn.
152
+ *
153
+ * For `codex-acp` the env vars are `OPENAI_API_KEY` (vault field
154
+ * `acp/openai_api_key`) and `CODEX_API_KEY` (vault field
155
+ * `acp/codex_api_key`), provisioned the same two ways (config.json
156
+ * override wins, vault second). Both are OPTIONAL: codex also supports
157
+ * ChatGPT login (`codex login` pre-seeding `auth.json` in the workspace),
158
+ * so a vault miss proceeds without the key instead of failing the spawn.
126
159
  */
127
160
  export async function prepareAgentEnv(
128
161
  agentConfig: AcpAgentConfig,
@@ -150,22 +183,29 @@ export async function prepareAgentEnv(
150
183
  );
151
184
  }
152
185
  } else if (adapterCommand === "gemini") {
153
- if (!env.GEMINI_API_KEY) {
154
- const missReason = await injectCredential(
186
+ await injectOptionalCredential(
187
+ env,
188
+ "gemini_api_key",
189
+ "GEMINI_API_KEY",
190
+ "Gemini API key for ACP agent authentication",
191
+ );
192
+ } else if (adapterCommand === "codex-acp") {
193
+ // The two reads target independent vault fields and write disjoint env
194
+ // keys, so running them concurrently is safe.
195
+ await Promise.all([
196
+ injectOptionalCredential(
155
197
  env,
156
- "gemini_api_key",
157
- "GEMINI_API_KEY",
158
- "Gemini API key for ACP agent authentication",
159
- );
160
- if (missReason !== undefined) {
161
- // Optional credential: Gemini CLI can authenticate via its own
162
- // OAuth login, so a vault miss must not fail the spawn.
163
- log.debug(
164
- { reason: missReason },
165
- "Gemini API key unavailable from the vault; spawning without GEMINI_API_KEY",
166
- );
167
- }
168
- }
198
+ "openai_api_key",
199
+ "OPENAI_API_KEY",
200
+ "OpenAI API key for Codex ACP agent authentication",
201
+ ),
202
+ injectOptionalCredential(
203
+ env,
204
+ "codex_api_key",
205
+ "CODEX_API_KEY",
206
+ "Codex API key for Codex ACP agent authentication",
207
+ ),
208
+ ]);
169
209
  }
170
210
 
171
211
  return { ...agentConfig, env };
package/src/agent/loop.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  estimateToolsTokens,
12
12
  getCalibrationProviderKey,
13
13
  } from "../context/token-estimator.js";
14
+ import { spoolAndStubOversizedToolResults } from "../context/tool-result-spool.js";
14
15
  import type { ToolActivityMetadata } from "../daemon/message-types/web-activity.js";
15
16
  import { parseActualTokensFromError } from "../daemon/parse-actual-tokens-from-error.js";
16
17
  import type { TrustContext } from "../daemon/trust-context.js";
@@ -298,25 +299,27 @@ export type AgentEvent =
298
299
  /** The running history before injection stripping and compaction. */
299
300
  messages: Message[];
300
301
  }
301
- | {
302
+ | ({
302
303
  /**
303
304
  * Emitted after the loop's inline mid-loop compaction pipeline runs,
304
305
  * immediately before re-injection — whether or not the pipeline actually
305
- * compacted. The daemon's event dispatcher always commits `messages` (the
306
- * stripped pre-compaction history) as the conversation's durable message
307
- * state, so re-injection (the post-compaction hook) re-applies
308
- * injections onto the stripped base rather than stacking on top of the
309
- * still-injected messages. When `result.compacted` is set it
310
- * additionally commits the durable compaction result (DB-record fields,
311
- * graph-memory side effects, SSE) and flips the per-turn re-injection
312
- * guards on the handler state.
306
+ * compacted. Carries the pipeline's `ContextWindowResult` unnested into
307
+ * the event, so `messages` here is the pipeline's output history. The
308
+ * pre-compaction history lives on the paired `context_compacting` start
309
+ * event (correlated by `compactionId`); consumers that need the
310
+ * stripped pre-compaction base re-derive it from the start event via
311
+ * `stripInjectionsForCompaction`.
312
+ *
313
+ * The daemon's event dispatcher commits the stripped pre-compaction
314
+ * base as the conversation's durable message state, so re-injection
315
+ * (the post-compaction hook) re-applies injections onto the stripped
316
+ * base rather than stacking on top of the still-injected messages.
317
+ * When `compacted` is set it additionally commits the durable
318
+ * compaction result (DB-record fields, graph-memory side effects, SSE)
319
+ * and projects Slack provenance from the pre-compaction base.
313
320
  *
314
321
  * Treated as a critical event: a failed durable commit re-throws so the
315
322
  * turn aborts rather than re-injecting against half-applied state.
316
- *
317
- * `messages` is the stripped pre-compaction history the summary was
318
- * built from; the dispatcher uses it to project Slack provenance onto
319
- * the compacted result.
320
323
  */
321
324
  type: "compaction_completed";
322
325
  /** Correlates this end event with its `context_compacting` pair. */
@@ -330,9 +333,7 @@ export type AgentEvent =
330
333
  startedAt: number;
331
334
  /** Epoch ms when the compaction pipeline returned. */
332
335
  finishedAt: number;
333
- result: ContextWindowResult;
334
- messages: Message[];
335
- }
336
+ } & ContextWindowResult)
336
337
  | {
337
338
  /**
338
339
  * Emitted right after the loop strips runtime injections from the
@@ -595,6 +596,16 @@ export interface AgentLoopConstructorOptions {
595
596
  * post-compaction re-injection resolve the live conversation through.
596
597
  */
597
598
  conversationId: string;
599
+ /**
600
+ * Resolve the conversation's on-disk directory, used to spool oversized
601
+ * tool results to `.tool-results/` and swap the inline copy for the
602
+ * post-turn pass's stub at result time — before the result joins history,
603
+ * so the provider-bound prefix stays append-only for prompt caching.
604
+ * Returns `null` while the directory cannot be resolved (e.g. the
605
+ * conversation row is not yet persisted); the loop then skips the
606
+ * result-time pass and the post-turn truncation covers the turn instead.
607
+ */
608
+ resolveConversationDir?: () => string | null;
598
609
  }
599
610
 
600
611
  export class AgentLoop {
@@ -616,6 +627,9 @@ export class AgentLoop {
616
627
  */
617
628
  private readonly conversationId: string;
618
629
 
630
+ /** See {@link AgentLoopConstructorOptions.resolveConversationDir}. */
631
+ private readonly resolveConversationDir: (() => string | null) | null;
632
+
619
633
  /**
620
634
  * Loop-held compaction circuit breaker. The loop has a 1:1 lifetime with its
621
635
  * conversation, so it is the source of truth for the cross-turn failure
@@ -635,6 +649,7 @@ export class AgentLoop {
635
649
  resolveTools,
636
650
  resolveSystemPrompt,
637
651
  conversationId,
652
+ resolveConversationDir,
638
653
  } = options;
639
654
  this.provider = provider;
640
655
  this.systemPrompt = systemPrompt;
@@ -644,6 +659,7 @@ export class AgentLoop {
644
659
  this.resolveSystemPrompt = resolveSystemPrompt ?? null;
645
660
  this.toolExecutor = toolExecutor ?? null;
646
661
  this.conversationId = conversationId;
662
+ this.resolveConversationDir = resolveConversationDir ?? null;
647
663
  this.compactionCircuit = new CompactionCircuit(this.conversationId);
648
664
  }
649
665
 
@@ -782,10 +798,10 @@ export class AgentLoop {
782
798
  onEvent,
783
799
  );
784
800
  }
785
- // Emit unconditionally: the dispatcher commits the stripped `messages` as the
786
- // durable message base whether or not the pipeline compacted (re-injection
787
- // reads it), and runs the durable compaction commit only when
788
- // `result.compacted`.
801
+ // Emit unconditionally: the dispatcher commits the stripped pre-compaction
802
+ // base (re-derived from the start event) as the durable message base
803
+ // whether or not the pipeline compacted (re-injection reads it), and runs
804
+ // the durable compaction commit only when `compacted`.
789
805
  await onEvent({
790
806
  type: "compaction_completed",
791
807
  compactionId,
@@ -793,8 +809,7 @@ export class AgentLoop {
793
809
  trigger,
794
810
  startedAt,
795
811
  finishedAt: Date.now(),
796
- result: compactResult,
797
- messages: rawHistory,
812
+ ...compactResult,
798
813
  });
799
814
  const exhausted = compactResult.exhausted ?? false;
800
815
  const autoCompressApplied = compactResult.autoCompressApplied ?? false;
@@ -886,6 +901,19 @@ export class AgentLoop {
886
901
  let overflowAutoCompressApplied = false;
887
902
  const rlog = log.child({ requestId });
888
903
 
904
+ // Conversation directory for the result-time tool-result spool/stub pass.
905
+ // Resolved once per run; `null` disables the pass for this run and the
906
+ // post-turn truncation covers the turn instead.
907
+ let conversationDir: string | null = null;
908
+ try {
909
+ conversationDir = this.resolveConversationDir?.() ?? null;
910
+ } catch (err) {
911
+ rlog.warn(
912
+ { err },
913
+ "Resolving conversation dir for tool-result spooling failed (non-fatal)",
914
+ );
915
+ }
916
+
889
917
  // Resolve the inference-profile override that applies right now. The
890
918
  // optional resolver lets a turn observe a confirmed mid-turn profile switch
891
919
  // before the next model call; absent a resolver the turn-start value holds.
@@ -1724,11 +1752,39 @@ export class AgentLoop {
1724
1752
  }),
1725
1753
  );
1726
1754
 
1755
+ // Spool oversized results to `.tool-results/` and swap the inline
1756
+ // copy for the post-turn pass's stub now — on the raw blocks, before
1757
+ // the post-tool-use hooks, event emission, and history append. Running
1758
+ // ahead of the hooks means the spooled file holds the tool's full
1759
+ // output rather than the truncate plugin's tail-dropped copy, and the
1760
+ // hooks then see the stub. Stubbing before the first send keeps the
1761
+ // provider-bound history strictly append-only (rewriting an earlier
1762
+ // message between calls would invalidate the prompt-cache prefix on
1763
+ // every iteration).
1764
+ if (conversationDir) {
1765
+ const toolNameByUseId = new Map(
1766
+ toolUseBlocks.map((tu) => [tu.id, tu.name]),
1767
+ );
1768
+ try {
1769
+ spoolAndStubOversizedToolResults(rawResultBlocks, {
1770
+ conversationDir,
1771
+ toolNameById: (id) => toolNameByUseId.get(id),
1772
+ });
1773
+ } catch (err) {
1774
+ rlog.warn(
1775
+ { err, turn: toolUseTurns },
1776
+ "Spooling oversized tool results to disk failed (non-fatal)",
1777
+ );
1778
+ }
1779
+ }
1780
+
1727
1781
  // Run the `post-tool-use` hook once per tool result, after the tool
1728
1782
  // returns and before the result joins the provider-bound history.
1729
1783
  // The default tool-result-truncate plugin tail-drops oversized output
1730
- // to fit the context window; user hooks can swap in a smarter strategy
1731
- // (e.g. a summariser) or observe results for side effects.
1784
+ // to fit the context window (spool-stubbed results are already tiny;
1785
+ // spool-exempt ones still rely on it); user hooks can swap in a
1786
+ // smarter strategy (e.g. a summariser) or observe results for side
1787
+ // effects.
1732
1788
  const contextWindowTokens =
1733
1789
  resolveContextWindow?.().maxInputTokens ??
1734
1790
  this.config.maxInputTokens ??
@@ -1746,6 +1802,7 @@ export class AgentLoop {
1746
1802
  toolResponse: block as ToolResultContent,
1747
1803
  messages: history,
1748
1804
  additionalContext: null,
1805
+ model: response.model,
1749
1806
  maxInputTokens: contextWindowTokens,
1750
1807
  logger: rlog,
1751
1808
  };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `usage_progress` SSE event.
3
+ *
4
+ * Emitted after each LLM call with per-call token deltas and estimated
5
+ * cost. Clients accumulate these additively for live-updating usage
6
+ * metrics (e.g. inline subagent token counters). A UI-only hint — it
7
+ * does not persist to the DB or affect billing.
8
+ *
9
+ * Unlike `usage_update`, which carries the conversation's running
10
+ * totals, this event carries only the deltas for a single LLM call.
11
+ *
12
+ * Canonical wire-contract source. Daemon code imports the type
13
+ * directly from this file; external consumers import via
14
+ * `@vellumai/assistant-api`.
15
+ */
16
+
17
+ import { z } from "zod";
18
+
19
+ export const UsageProgressEventSchema = z.object({
20
+ type: z.literal("usage_progress"),
21
+ conversationId: z.string(),
22
+ inputTokens: z.number(),
23
+ outputTokens: z.number(),
24
+ estimatedCost: z.number(),
25
+ model: z.string(),
26
+ });
27
+
28
+ export type UsageProgressEvent = z.infer<typeof UsageProgressEventSchema>;
package/src/api/index.ts CHANGED
@@ -49,6 +49,7 @@ import { UISurfaceCompleteEventSchema } from "./events/ui-surface-complete.js";
49
49
  import { UISurfaceDismissEventSchema } from "./events/ui-surface-dismiss.js";
50
50
  import { UISurfaceShowEventSchema } from "./events/ui-surface-show.js";
51
51
  import { UISurfaceUpdateEventSchema } from "./events/ui-surface-update.js";
52
+ import { UsageProgressEventSchema } from "./events/usage-progress.js";
52
53
  import { UsageUpdateEventSchema } from "./events/usage-update.js";
53
54
  import { UserMessageEchoEventSchema } from "./events/user-message-echo.js";
54
55
 
@@ -310,6 +311,10 @@ export {
310
311
  type UISurfaceUpdateEvent,
311
312
  UISurfaceUpdateEventSchema,
312
313
  } from "./events/ui-surface-update.js";
314
+ export {
315
+ type UsageProgressEvent,
316
+ UsageProgressEventSchema,
317
+ } from "./events/usage-progress.js";
313
318
  export {
314
319
  type UsageUpdateEvent,
315
320
  UsageUpdateEventSchema,
@@ -497,6 +502,7 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [
497
502
  UISurfaceDismissEventSchema,
498
503
  UISurfaceShowEventSchema,
499
504
  UISurfaceUpdateEventSchema,
505
+ UsageProgressEventSchema,
500
506
  UsageUpdateEventSchema,
501
507
  UserMessageEchoEventSchema,
502
508
  ]);
@@ -52,6 +52,8 @@ mock.module("../util/platform.js", () => ({
52
52
  getWorkspaceRoutesDir: () => workspacePath("routes"),
53
53
  vellumRoot: () => testWorkspaceDir,
54
54
  getDataDir: () => workspacePath("data"),
55
+ getConfigQuarantineNoticePath: () =>
56
+ workspacePath("data", "config-quarantine-notice.json"),
55
57
  getDbPath: () => workspacePath("data", "db", "assistant.db"),
56
58
  ensureDataDir: () => {
57
59
  mkdirSync(workspacePath("data", "db"), { recursive: true });