@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.3

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 (111) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/activation-early-marking.test.ts +120 -0
  4. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  5. package/src/__tests__/approval-cascade.test.ts +1 -1
  6. package/src/__tests__/compaction-direct.test.ts +32 -18
  7. package/src/__tests__/compaction-events.test.ts +2 -2
  8. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  9. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  10. package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
  11. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  12. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  13. package/src/__tests__/conversation-error.test.ts +15 -1
  14. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  15. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  16. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  17. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  18. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +1 -1
  20. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  21. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  23. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  24. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  25. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  26. package/src/__tests__/conversation-undo.test.ts +2 -2
  27. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  30. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  31. package/src/__tests__/history-repair-hook.test.ts +2 -0
  32. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  33. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  34. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  35. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  36. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  37. package/src/__tests__/title-generate-hook.test.ts +2 -0
  38. package/src/__tests__/web-fetch.test.ts +45 -0
  39. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  40. package/src/acp/resolve-agent.test.ts +0 -56
  41. package/src/acp/resolve-agent.ts +10 -38
  42. package/src/agent/loop.ts +13 -27
  43. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  44. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  45. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  47. package/src/cli/lib/install-from-github.ts +258 -41
  48. package/src/cli/lib/plugin-details.ts +20 -13
  49. package/src/cli/lib/plugin-marketplace.ts +23 -5
  50. package/src/cli/lib/search-plugins.ts +14 -8
  51. package/src/config/acp-defaults.ts +3 -3
  52. package/src/config/acp-schema.ts +1 -7
  53. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  54. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  55. package/src/config/feature-flag-registry.json +3 -18
  56. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  57. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  58. package/src/daemon/conversation-agent-loop.ts +78 -78
  59. package/src/daemon/conversation-error.ts +31 -4
  60. package/src/daemon/conversation-history.ts +1 -1
  61. package/src/daemon/conversation-media-retry.ts +19 -6
  62. package/src/daemon/conversation-messaging.ts +17 -0
  63. package/src/daemon/conversation-process.ts +14 -5
  64. package/src/daemon/conversation-queue-manager.ts +8 -0
  65. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  66. package/src/daemon/conversation-surfaces.ts +141 -3
  67. package/src/daemon/conversation.ts +48 -13
  68. package/src/daemon/persist-unsendable-image.ts +62 -25
  69. package/src/daemon/process-message.ts +1 -1
  70. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  71. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  72. package/src/memory/activation-session-store.ts +43 -0
  73. package/src/memory/db-init.ts +4 -0
  74. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  75. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  76. package/src/memory/migrations/index.ts +2 -0
  77. package/src/memory/onboarding-events-store.ts +66 -18
  78. package/src/memory/schema/infrastructure.ts +13 -0
  79. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  80. package/src/notifications/adapters/telegram.ts +7 -1
  81. package/src/plugin-api/constants.ts +2 -2
  82. package/src/plugin-api/index.ts +2 -2
  83. package/src/plugin-api/types.ts +19 -5
  84. package/src/plugins/defaults/compaction/compact.ts +24 -15
  85. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  86. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  87. package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
  88. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  89. package/src/prompts/system-prompt.ts +61 -10
  90. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  91. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  92. package/src/providers/openai/chat-completions-provider.ts +25 -0
  93. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  94. package/src/runtime/routes/acp-routes.test.ts +3 -20
  95. package/src/runtime/routes/conversation-routes.ts +2 -0
  96. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  97. package/src/runtime/routes/stt-routes.ts +45 -12
  98. package/src/runtime/routes/workspace-routes.ts +50 -15
  99. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  100. package/src/telemetry/activation-funnel.ts +167 -0
  101. package/src/telemetry/types.ts +13 -0
  102. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  103. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  104. package/src/tools/acp/list-agents.test.ts +2 -18
  105. package/src/tools/acp/list-agents.ts +3 -15
  106. package/src/tools/acp/spawn.test.ts +0 -10
  107. package/src/tools/browser/browser-execution.ts +12 -2
  108. package/src/tools/network/web-fetch.ts +65 -24
  109. package/src/tools/ui-surface/definitions.ts +7 -0
  110. package/src/acp/feature-gate.test.ts +0 -48
  111. package/src/acp/feature-gate.ts +0 -34
@@ -12,8 +12,12 @@
12
12
  * Like the first-party plugin listing, the manifest is fetched from the repo
13
13
  * at a git ref (via the GitHub Contents API) rather than bundled into the
14
14
  * assistant build — so the whitelist can grow without shipping a new release.
15
- * Every external source pins an explicit `ref` (tag/SHA/branch): the catalog
16
- * is curated and the fetched code is version-locked, not a floating branch.
15
+ * Every external source pins an explicit `ref` that MUST be a full commit SHA:
16
+ * the fetched code is locked to an immutable revision. Tags and branches are
17
+ * rejected because they are mutable — an upstream owner could retag/repoint
18
+ * them to attacker code that the daemon would then `import()` (the install
19
+ * tree is dynamically loaded). A SHA cannot be repointed, so the reviewed
20
+ * manifest fully determines what gets executed.
17
21
  *
18
22
  * Designed for direct programmatic use with an injected `fetch`, mirroring
19
23
  * {@link ./search-plugins} and {@link ./install-from-github}.
@@ -39,6 +43,13 @@ const MARKETPLACE_FILE_PATH = "experimental/plugins/marketplace.json";
39
43
  const REPO_SLUG_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
40
44
  /** Install name: a single kebab-case path segment (same rule as the CLI). */
41
45
  const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
46
+ /**
47
+ * Full Git commit SHA — 40 hex chars (SHA-1) or 64 (SHA-256). External
48
+ * marketplace refs must be a complete object name so the install is pinned to
49
+ * an immutable revision; abbreviated SHAs, tags, and branches are all mutable
50
+ * or ambiguous and are rejected.
51
+ */
52
+ const COMMIT_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
42
53
 
43
54
  const githubSourceSchema = z.object({
44
55
  /** Discriminator. Only GitHub sources are resolved today. */
@@ -57,10 +68,17 @@ const githubSourceSchema = z.object({
57
68
  )
58
69
  .optional(),
59
70
  /**
60
- * Git ref (tag, SHA, or branch) to fetch the plugin from. Required so every
61
- * whitelisted external plugin is version-pinned.
71
+ * Immutable revision to fetch the plugin from. Must be a full commit SHA:
72
+ * tags and branches are mutable, so allowing them would let an upstream
73
+ * owner retarget a curated entry at code the daemon later `import()`s (RCE).
74
+ * A full SHA pins the install to exactly the reviewed bytes.
62
75
  */
63
- ref: z.string().min(1),
76
+ ref: z
77
+ .string()
78
+ .regex(
79
+ COMMIT_SHA_RE,
80
+ "expected a full commit SHA (40 or 64 hex chars); tags and branches are mutable and not allowed",
81
+ ),
64
82
  });
65
83
 
66
84
  const marketplaceEntrySchema = z.object({
@@ -200,8 +200,22 @@ export async function loadPluginCatalog(
200
200
 
201
201
  const matches: PluginSearchMatch[] = [];
202
202
  const seen = new Set<string>();
203
+
204
+ // Whitelisted external entries own their name. A same-named
205
+ // `experimental/plugins/<name>/` directory is that plugin's curated adapter
206
+ // stub (overlaid onto the clone at install time), not a standalone
207
+ // first-party plugin — so it must surface once, as the external entry.
208
+ for (const entry of marketplace) {
209
+ if (seen.has(entry.name)) continue;
210
+ matches.push(marketplaceMatch(entry));
211
+ seen.add(entry.name);
212
+ }
213
+
214
+ // First-party plugins are the in-repo directories whose name the marketplace
215
+ // does not claim.
203
216
  for (const entry of entries) {
204
217
  if (entry.type !== "dir") continue;
218
+ if (seen.has(entry.name)) continue;
205
219
  matches.push({
206
220
  name: entry.name,
207
221
  path: entry.path,
@@ -210,14 +224,6 @@ export async function loadPluginCatalog(
210
224
  seen.add(entry.name);
211
225
  }
212
226
 
213
- for (const entry of marketplace) {
214
- // First-party plugins win a name collision — the curated manifest is
215
- // additive, never an override of what ships in-repo.
216
- if (seen.has(entry.name)) continue;
217
- matches.push(marketplaceMatch(entry));
218
- seen.add(entry.name);
219
- }
220
-
221
227
  matches.sort((a, b) => a.name.localeCompare(b.name));
222
228
 
223
229
  return { ref, matches };
@@ -9,9 +9,9 @@ const FROZEN_EMPTY_ARGS = Object.freeze([] as string[]) as unknown as string[];
9
9
  /**
10
10
  * Default ACP agent profiles that ship with the assistant.
11
11
  *
12
- * When `acp.enabled: true` and the user has not provided a config entry for an
13
- * agent id, the resolver falls back to this map so common agents like `claude`
14
- * and `codex` Just Work without requiring per-user config.
12
+ * When the user has not provided a config entry for an agent id, the resolver
13
+ * falls back to this map so common agents like `claude` and `codex` Just Work
14
+ * without requiring per-user config.
15
15
  *
16
16
  * Keyed by agent id. Deeply frozen — the outer object, each profile, and the
17
17
  * `args` arrays — so mutation throws in strict mode rather than silently
@@ -20,12 +20,6 @@ const AcpAgentConfigSchema = z
20
20
 
21
21
  export const AcpConfigSchema = z
22
22
  .object({
23
- enabled: z
24
- .boolean()
25
- .default(false)
26
- .describe(
27
- "Whether the Agent Communication Protocol (ACP) system is enabled",
28
- ),
29
23
  maxConcurrentSessions: z
30
24
  .number()
31
25
  .int()
@@ -40,7 +34,7 @@ export const AcpConfigSchema = z
40
34
  .describe("Map of agent names to their configurations"),
41
35
  })
42
36
  .describe(
43
- "Agent Communication Protocol (ACP) — enables inter-agent communication and delegation",
37
+ "Agent Communication Protocol (ACP) — inter-agent communication and delegation",
44
38
  );
45
39
 
46
40
  export type AcpConfig = z.infer<typeof AcpConfigSchema>;
@@ -26,24 +26,11 @@ Users can refer to agents by natural names: "claude code", "codex cli", "openai
26
26
 
27
27
  ## First-time setup
28
28
 
29
- When the user first tries to use ACP and it's not enabled, set it up automatically:
29
+ ACP is always available - default profiles for `claude`, `codex`, and `gemini` ship out-of-box, so no config edit is needed to start. First-time setup is just making the adapter binary available, then spawning:
30
30
 
31
- 1. **Enable the `acp` feature flag** (the primary enablement path). Either PATCH it via the gateway feature-flags endpoint or direct the user to toggle "ACP Coding Agents" in the client's feature flags UI. Flag changes are hot-refreshed in the assistant - no restart needed.
31
+ 1. Install the adapter binary if it's missing. This happens automatically: when `acp_spawn` finds the agent's binary missing from PATH, the assistant installs it once via a sandboxed bun global install and proceeds in the same call (see "Automatic adapter availability" below).
32
32
 
33
- As a supported alternative, edit the workspace config file to add the `acp` section. Default profiles for `claude`, `codex`, and `gemini` ship out-of-box, so the minimal config is just:
34
- ```json
35
- {
36
- "acp": {
37
- "enabled": true,
38
- "maxConcurrentSessions": 4
39
- }
40
- }
41
- ```
42
- If you go the config route, **wait a few seconds** for the config watcher to pick up the change (it hot-reloads automatically - no restart needed).
43
-
44
- 2. Then retry the `acp_spawn` call. Do NOT run `vellum sleep && vellum wake` - that kills the conversation.
45
-
46
- No manual binary installation is needed first: missing adapter binaries are installed automatically (see below).
33
+ 2. Call `acp_spawn`. Do NOT run `vellum sleep && vellum wake` - that kills the conversation.
47
34
 
48
35
  ## Automatic adapter availability
49
36
 
@@ -140,7 +127,7 @@ Then retry the `acp_spawn` call.
140
127
 
141
128
  ## Discoverability
142
129
 
143
- Use `acp_list_agents` to see what's set up and what's missing. It returns each available agent profile, whether ACP is enabled, whether the agent's binary is on PATH (missing binaries are installed automatically on first spawn), and an install hint if not. This is the right tool to call when deciding between `claude`, `codex`, and `gemini`, or when the user asks "what coding agents do I have?"
130
+ Use `acp_list_agents` to see what's set up and what's missing. It returns each available agent profile, whether the agent's binary is on PATH (missing binaries are installed automatically on first spawn), and an install hint if not. This is the right tool to call when deciding between `claude`, `codex`, and `gemini`, or when the user asks "what coding agents do I have?"
144
131
 
145
132
  ## Working directory
146
133
 
@@ -3,7 +3,7 @@
3
3
  "tools": [
4
4
  {
5
5
  "name": "acp_spawn",
6
- "description": "Spawn an external coding agent (e.g. Claude Code, Codex, Gemini) via ACP to work on a task. Default profiles ship for `claude` (`claude-agent-acp`), `codex` (`codex-acp`), and `gemini` (`gemini --acp`); the assistant resolves the agent id to the right binary. The agent runs as a subprocess and streams results back. Use this when you want to delegate a coding task to an external agent that has its own tools, file editing, and terminal access. If a default agent's binary is missing, the assistant installs it once via a sandboxed bun global install and proceeds in the same call; if that fails (e.g. bun unavailable), an actionable install hint is returned - do NOT alter `agents.<id>.command` to swap binaries. If ACP is disabled, follow the setup instructions in SKILL.md.",
6
+ "description": "Spawn an external coding agent (e.g. Claude Code, Codex, Gemini) via ACP to work on a task. Default profiles ship for `claude` (`claude-agent-acp`), `codex` (`codex-acp`), and `gemini` (`gemini --acp`); the assistant resolves the agent id to the right binary. The agent runs as a subprocess and streams results back. Use this when you want to delegate a coding task to an external agent that has its own tools, file editing, and terminal access. If a default agent's binary is missing, the assistant installs it once via a sandboxed bun global install and proceeds in the same call; if that fails (e.g. bun unavailable), an actionable install hint is returned - do NOT alter `agents.<id>.command` to swap binaries.",
7
7
  "category": "orchestration",
8
8
  "risk": "high",
9
9
  "input_schema": {
@@ -87,7 +87,7 @@
87
87
  },
88
88
  {
89
89
  "name": "acp_list_agents",
90
- "description": "Lists ACP coding agents available to spawn. Each entry includes whether ACP is enabled, whether the agent's binary is on PATH (missing binaries are installed automatically on first spawn), and an install command if not. Use this to decide between 'claude', 'codex', and 'gemini' or to surface setup steps to the user.",
90
+ "description": "Lists ACP coding agents available to spawn. Each entry includes whether the agent's binary is on PATH (missing binaries are installed automatically on first spawn), and an install command if not. Use this to decide between 'claude', 'codex', and 'gemini' or to surface setup steps to the user.",
91
91
  "category": "orchestration",
92
92
  "risk": "low",
93
93
  "input_schema": {
@@ -39,8 +39,9 @@
39
39
  "scope": "client",
40
40
  "key": "experiment-activation-flow-2026-06-03",
41
41
  "label": "Activation Flow Experiment 2026-06-03",
42
- "description": "Route allowlisted users to the activation-rail bootstrap template after pre-chat. Off by default and targeted through LaunchDarkly.",
43
- "defaultEnabled": false
42
+ "description": "Multivariate activation-flow experiment. control = standard flow; variant-a = activation rail. Targeted via LaunchDarkly.",
43
+ "defaultEnabled": "control",
44
+ "values": ["control", "variant-a"]
44
45
  },
45
46
  {
46
47
  "id": "local-docker-enabled",
@@ -481,22 +482,6 @@
481
482
  "label": "Self-intro first message",
482
483
  "description": "On the first conversation, send a natural self-introduction (e.g. \"Hi Vela, I'm alex. Nice to meet you.\") on the user's behalf and route it through real LLM inference, instead of serving the canned first greeting. Names come from the onboarding context; falls back to the canned greeting when no name is known. Exposed to clients so pre-hatch onboarding can compute the source-of-truth initial message, and to the assistant so older clients can still be gated server-side. See assistant/src/daemon/first-greeting.ts (buildSelfIntroMessage).",
483
484
  "defaultEnabled": false
484
- },
485
- {
486
- "id": "provider-first-profile-creation",
487
- "scope": "client",
488
- "key": "provider-first-profile-creation",
489
- "label": "Provider-First Profile Creation",
490
- "description": "New profile creation flow: pick (or inline-create) a provider first, then a model, with pre-filled provider and profile name/key, plus a quick-add \"+\" in the chat composer's Model Profile menu. When off, profile creation uses the previous field order and there is no composer quick-add.",
491
- "defaultEnabled": false
492
- },
493
- {
494
- "id": "acp",
495
- "scope": "assistant",
496
- "key": "acp",
497
- "label": "ACP Coding Agents",
498
- "description": "Enable spawning and steering external coding agents (Claude Code, Codex, Gemini) via the Agent Client Protocol. Alternative gate to the acp.enabled workspace config field; either enables the subsystem.",
499
- "defaultEnabled": false
500
485
  }
501
486
  ]
502
487
  }
@@ -22,6 +22,34 @@ export const TOOL_RESULT_DIR = ".tool-results";
22
22
  /** Marker used to detect already-truncated results (idempotency guard). */
23
23
  export const TRUNCATION_MARKER = "\u2014 full result:";
24
24
 
25
+ /**
26
+ * Tools whose results carry durable operating instructions the model relies on
27
+ * across later turns rather than one-off data it only needs in the moment.
28
+ * Their results must never be middle-truncated: paging out the middle silently
29
+ * strips the workflow (e.g. a `skill_load` body losing its "## Available Tools"
30
+ * section), leaving the model to fall back to generic priors. Skill bodies are
31
+ * bounded and authored to live in context, so exempting them is safe.
32
+ */
33
+ export const TRUNCATION_EXEMPT_TOOLS = new Set<string>(["skill_load"]);
34
+
35
+ /**
36
+ * Build a map of tool_use_id -> originating tool name by walking the tool_use
37
+ * blocks in assistant messages. A tool_result only carries `tool_use_id`, so
38
+ * this is the only way to recover which tool produced a given result.
39
+ */
40
+ function buildToolNameById(messages: Message[]): Map<string, string> {
41
+ const byId = new Map<string, string>();
42
+ for (const msg of messages) {
43
+ if (msg.role !== "assistant") continue;
44
+ for (const block of msg.content) {
45
+ if (block.type !== "tool_use") continue;
46
+ const tu = block as ToolUseContent;
47
+ byId.set(tu.id, tu.name);
48
+ }
49
+ }
50
+ return byId;
51
+ }
52
+
25
53
  /**
26
54
  * Deterministic file path for a tool result's full content on disk.
27
55
  * Uses the first 12 hex chars of the SHA-256 of the tool_use_id.
@@ -59,7 +87,8 @@ export function buildTruncatedContent(
59
87
  * - The in-context content is replaced with a prefix/suffix stub.
60
88
  *
61
89
  * Results are skipped if they are below threshold, are error results,
62
- * or have already been truncated (contain `TRUNCATION_MARKER`).
90
+ * have already been truncated (contain `TRUNCATION_MARKER`), or were produced
91
+ * by a tool in `TRUNCATION_EXEMPT_TOOLS` (durable instructions like skill bodies).
63
92
  *
64
93
  * Returns a shallow-copied messages array (only modified messages are cloned)
65
94
  * and the count of results that were truncated.
@@ -70,6 +99,8 @@ export function postTurnTruncateToolResults(
70
99
  ): { messages: Message[]; truncatedCount: number } {
71
100
  let truncatedCount = 0;
72
101
 
102
+ const toolNameById = buildToolNameById(messages);
103
+
73
104
  const mapped = messages.map((msg) => {
74
105
  let changed = false;
75
106
  const nextContent: ContentBlock[] = msg.content.map((block) => {
@@ -82,6 +113,13 @@ export function postTurnTruncateToolResults(
82
113
  // Skip error results.
83
114
  if (tr.is_error) return block;
84
115
 
116
+ // Skip results from tools whose output is durable operating instructions
117
+ // (e.g. skill_load); middle-truncating them strips the workflow.
118
+ const toolName = toolNameById.get(tr.tool_use_id);
119
+ if (toolName !== undefined && TRUNCATION_EXEMPT_TOOLS.has(toolName)) {
120
+ return block;
121
+ }
122
+
85
123
  // Skip already-truncated results (idempotency).
86
124
  if (tr.content.includes(TRUNCATION_MARKER)) return block;
87
125
 
@@ -17,7 +17,6 @@ import type {
17
17
  import { getConfig } from "../config/loader.js";
18
18
  import { recordEstimate } from "../context/estimator-calibration.js";
19
19
  import { getCalibrationProviderKey } from "../context/token-estimator.js";
20
- import type { ContextWindowResult } from "../context/window-manager.js";
21
20
  import { projectAssistantMessage } from "../memory/conversation-attention-store.js";
22
21
  import {
23
22
  deleteMessageById,
@@ -46,6 +45,7 @@ import {
46
45
  type SlackMessageMetadata,
47
46
  writeSlackMetadata,
48
47
  } from "../messaging/providers/slack/message-metadata.js";
48
+ import type { ContextWindowResult } from "../plugins/defaults/compaction/window-manager.js";
49
49
  import type {
50
50
  ContentBlock,
51
51
  ImageContent,
@@ -1482,6 +1482,13 @@ function annotatePersistedAssistantMessage(
1482
1482
  display: surface.display,
1483
1483
  ...(surface.persistent ? { persistent: true } : {}),
1484
1484
  ...(surface.toolCallId ? { toolCallId: surface.toolCallId } : {}),
1485
+ // Daemon-only commit-timing activation tag, persisted so
1486
+ // restoreSurfaceStateFromHistory can rehydrate it after a reload. This
1487
+ // block lives only in server-side conversation history, never in the
1488
+ // client `ui_surface_show` message.
1489
+ ...(surface.activationMoment
1490
+ ? { activationMoment: surface.activationMoment }
1491
+ : {}),
1485
1492
  } as unknown as ContentBlock);
1486
1493
  }
1487
1494
  modified = true;
@@ -9,7 +9,6 @@
9
9
 
10
10
  import { v4 as uuid } from "uuid";
11
11
 
12
- import { optimizeImageForTransport } from "../agent/image-optimize.js";
13
12
  import type {
14
13
  AgentEvent,
15
14
  AgentLoopExitReason,
@@ -43,7 +42,6 @@ import {
43
42
  estimatePromptTokens,
44
43
  getCalibrationProviderKey,
45
44
  } from "../context/token-estimator.js";
46
- import type { ContextWindowCompactOptions } from "../context/window-manager.js";
47
45
  import { writeRelationshipState } from "../home/relationship-state-writer.js";
48
46
  import {
49
47
  clearSentryConversationContext,
@@ -81,6 +79,7 @@ import {
81
79
  reduceContextOverflow,
82
80
  type ReducerState,
83
81
  } from "../plugins/defaults/compaction/context-overflow-reducer.js";
82
+ import type { ContextWindowCompactOptions } from "../plugins/defaults/compaction/window-manager.js";
84
83
  import { deepRepairHistory } from "../plugins/defaults/history-repair/terminal.js";
85
84
  import userPromptSubmitMemoryRetrieval, {
86
85
  type MemoryRetrievalHookContext,
@@ -88,13 +87,11 @@ import userPromptSubmitMemoryRetrieval, {
88
87
  import { runHook } from "../plugins/pipeline.js";
89
88
  import type { ContentBlock, Message } from "../providers/types.js";
90
89
  import type { Provider } from "../providers/types.js";
91
- import {
92
- isUntrustedTrustClass,
93
- resolveActorTrust,
94
- } from "../runtime/actor-trust-resolver.js";
90
+ import { isUntrustedTrustClass } from "../runtime/actor-trust-resolver.js";
95
91
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
96
92
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
97
93
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
94
+ import type { ActivationMomentParam } from "../telemetry/activation-funnel.js";
98
95
  import type { UsageActor } from "../usage/actors.js";
99
96
  import { getLogger } from "../util/logger.js";
100
97
  import { timeAgo } from "../util/time.js";
@@ -122,16 +119,12 @@ import {
122
119
  isUserCancellation,
123
120
  } from "./conversation-error.js";
124
121
  import { raceWithTimeout } from "./conversation-media-retry.js";
125
- import type {
126
- InboundActorContext,
127
- InjectionMode,
128
- } from "./conversation-runtime-assembly.js";
122
+ import type { InjectionMode } from "./conversation-runtime-assembly.js";
129
123
  import {
130
124
  applyRuntimeInjections,
131
125
  getSlackCompactionWatermarkForPrefix,
132
- inboundActorContextFromTrust,
133
- inboundActorContextFromTrustContext,
134
126
  loadSlackChronologicalContext,
127
+ resolveTurnInboundActorContext,
135
128
  type SlackChronologicalContext,
136
129
  stripInjectionsForCompaction,
137
130
  } from "./conversation-runtime-assembly.js";
@@ -148,8 +141,8 @@ import type {
148
141
  } from "./message-protocol.js";
149
142
  import { parseActualTokensFromError } from "./parse-actual-tokens-from-error.js";
150
143
  import {
144
+ oversizedImageReplacement,
151
145
  persistUnsendableImageDowngrades,
152
- UNSENDABLE_IMAGE_NOTE,
153
146
  } from "./persist-unsendable-image.js";
154
147
  import { resolveTrustClass, type TrustContext } from "./trust-context.js";
155
148
 
@@ -176,6 +169,34 @@ function formatDiskPressureBlockedMessage(): string {
176
169
  return "Storage is critically low, so background processes are paused and remote messages are ignored until the guardian frees enough space. Remote senders should try again later.";
177
170
  }
178
171
 
172
+ // ── Image-recovery helpers ───────────────────────────────────────────
173
+
174
+ /**
175
+ * True when a message's content holds an image the provider may have rejected
176
+ * for being oversized — either a top-level image block (user upload) or one
177
+ * nested inside a tool_result's contentBlocks (e.g. a browser screenshot).
178
+ */
179
+ function messageHasImageBlock(content: ContentBlock[]): boolean {
180
+ return content.some(
181
+ (b) =>
182
+ b.type === "image" ||
183
+ (b.type === "tool_result" &&
184
+ (b.contentBlocks?.some((cb) => cb.type === "image") ?? false)),
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Replace an oversized image with its downscaled form or an unsendable note,
190
+ * leaving still-sendable images untouched. Delegates to the shared
191
+ * {@link oversizedImageReplacement} so the in-memory recovery and the durable
192
+ * persist pass apply the identical provider-cap gate.
193
+ */
194
+ function recoverImageBlock(
195
+ block: Extract<ContentBlock, { type: "image" }>,
196
+ ): ContentBlock {
197
+ return oversizedImageReplacement(block) ?? block;
198
+ }
199
+
179
200
  // ── Plugin pipeline helpers ──────────────────────────────────────────
180
201
 
181
202
  /**
@@ -223,6 +244,14 @@ export interface AssistantSurface {
223
244
  persistent?: boolean;
224
245
  /** Id of the tool call that produced this surface (the `ui_show` proxy tool). Persisted so app previews can gate on the tool result's arrival rather than whole-turn streaming state. */
225
246
  toolCallId?: string;
247
+ /**
248
+ * Commit-timing activation-rail tag (daemon-only). Persisted into the
249
+ * server-side `ui_surface` history block — NOT the client `ui_surface_show`
250
+ * message — so `restoreSurfaceStateFromHistory` can rehydrate the tag and a
251
+ * post-reload commit still records its funnel milestone. Show-timing moments
252
+ * record at render and are never stored here.
253
+ */
254
+ activationMoment?: ActivationMomentParam;
226
255
  }
227
256
 
228
257
  // ── runAgentLoop ─────────────────────────────────────────────────────
@@ -771,41 +800,16 @@ export async function runAgentLoopImpl(
771
800
  hostTimeZone,
772
801
  });
773
802
 
774
- // Resolve the inbound actor context for the unified <turn_context> block.
775
- // When the conversation carries enough identity info, use the unified
776
- // actor trust resolver so member status/policy and guardian binding details
777
- // are fresh for this turn. The conversation runtime context remains the source
778
- // for policy gating; this block is model-facing grounding metadata.
779
- let resolvedInboundActorContext: InboundActorContext | null = null;
780
- if (ctx.trustContext) {
781
- const gc = ctx.trustContext;
782
- if (gc.requesterExternalUserId && gc.requesterChatId) {
783
- const actorTrust = resolveActorTrust({
784
- assistantId: ctx.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
785
- sourceChannel: gc.sourceChannel,
786
- conversationExternalId: gc.requesterChatId,
787
- actorExternalId: gc.requesterExternalUserId,
788
- actorDisplayName: gc.requesterSenderDisplayName,
789
- });
790
- resolvedInboundActorContext = inboundActorContextFromTrust(actorTrust);
791
- } else {
792
- resolvedInboundActorContext = inboundActorContextFromTrustContext(gc);
793
- }
794
- }
795
-
796
- // Resolve the guardian flag for this turn. It derives only from the
797
- // resolved actor trust class — never from retrieval — so it settles before
798
- // context assembly.
799
- const isGuardian =
800
- resolvedInboundActorContext?.trustClass === "guardian" ||
801
- !resolvedInboundActorContext;
802
-
803
- // Unified `<turn_context>` actor input, included only for non-guardian
804
- // turns. Resolved once at turn start and threaded per call site (like
805
- // `modelProfile`) so post-compaction re-injection receives it as an
806
- // explicit hook input rather than re-deriving it from live state that can
807
- // flip mid-turn.
808
- const actorContext = isGuardian ? null : resolvedInboundActorContext;
803
+ // Unified `<turn_context>` actor input for this turn (model-facing grounding
804
+ // metadata; the conversation runtime context remains the source for policy
805
+ // gating). Resolved once at turn start and threaded per call site (like
806
+ // `modelProfile`) so post-compaction re-injection receives it as an explicit
807
+ // hook input rather than re-deriving it from live state that can flip
808
+ // mid-turn.
809
+ const actorContext = resolveTurnInboundActorContext(
810
+ ctx.trustContext,
811
+ ctx.assistantId,
812
+ );
809
813
 
810
814
  // Surface long gaps between user messages so the model can acknowledge
811
815
  // the absence naturally. Gated at >12h to avoid noisy injection during
@@ -873,10 +877,9 @@ export async function runAgentLoopImpl(
873
877
  // `user-prompt-submit-temp` hook handler but invoked directly for now,
874
878
  // separate from the canonical late `user-prompt-submit` hook (history
875
879
  // repair, title) that fires just before the loop.
876
- // The injection inputs (`mode`, `isNonInteractive`, `modelProfile`,
877
- // `actorContext`) are resolved once at turn start and threaded in so
878
- // post-compaction re-injection reuses the same snapshot rather than live
879
- // state that can flip mid-turn.
880
+ // The injection inputs (`isNonInteractive`, `modelProfile`) are resolved
881
+ // once at turn start and threaded in so post-compaction re-injection reuses
882
+ // the same snapshot rather than live state that can flip mid-turn.
880
883
  const isTrustedActor = resolveTrustClass(ctx.trustContext) === "guardian";
881
884
  let currentInjectionMode: InjectionMode = "full";
882
885
  const memoryCtx: MemoryRetrievalHookContext = {
@@ -886,10 +889,8 @@ export async function runAgentLoopImpl(
886
889
  logger: rlog,
887
890
  latestMessages: ctx.messages,
888
891
  requestId: reqId,
889
- mode: currentInjectionMode,
890
892
  isNonInteractive,
891
893
  modelProfile: modelProfileStr,
892
- actorContext,
893
894
  };
894
895
  await userPromptSubmitMemoryRetrieval(memoryCtx);
895
896
 
@@ -913,6 +914,8 @@ export async function runAgentLoopImpl(
913
914
  // `AgentLoopRunResult.newMessages`, which is what persistence consumes.
914
915
  const userPromptCtx: UserPromptSubmitContext = {
915
916
  conversationId: ctx.conversationId,
917
+ userMessageId,
918
+ requestId: reqId,
916
919
  prompt: options?.titleText ?? content,
917
920
  originalMessages: ctx.messages,
918
921
  latestMessages: runMessages,
@@ -1076,44 +1079,41 @@ export async function runAgentLoopImpl(
1076
1079
 
1077
1080
  // ── Image-dimension overflow recovery ──────────────────────────
1078
1081
  // When the provider rejects because an image block exceeds its pixel
1079
- // cap, strip every image block from ctx.messages and retry once.
1080
- // optimizeImageForTransport already ran at upload time; if sips was
1081
- // unavailable (non-macOS) it returns the same bytes unchanged. In
1082
- // that case we swap the block for a text note so the model can tell
1083
- // the user what happened instead of hard-failing with a red banner.
1082
+ // or payload cap, recover every oversized image in ctx.messages and
1083
+ // retry once. recoverImageBlock downscales an oversized image, or swaps
1084
+ // it for a text note when resize is a no-op (e.g. sips unavailable
1085
+ // off macOS), while leaving still-sendable images untouched. This covers
1086
+ // both top-level image blocks (user uploads) and images nested inside a
1087
+ // tool_result's contentBlocks (e.g. a browser screenshot), which is where
1088
+ // the rejected block usually lives.
1084
1089
  if (state.imageTooLargeDetected) {
1085
1090
  state.imageTooLargeDetected = false;
1086
1091
  rlog.warn(
1087
1092
  { phase: "image-recovery" },
1088
- "Image too large — stripping oversized image blocks and retrying",
1093
+ "Image too large — recovering oversized image blocks and retrying",
1089
1094
  );
1090
1095
  ctx.messages = ctx.messages.map((msg) => {
1091
1096
  if (!Array.isArray(msg.content)) return msg;
1092
- if (!msg.content.some((b) => b.type === "image")) return msg;
1097
+ if (!messageHasImageBlock(msg.content)) return msg;
1093
1098
  return {
1094
1099
  ...msg,
1095
1100
  content: msg.content.flatMap((b): ContentBlock[] => {
1096
- if (b.type !== "image") return [b];
1097
- const resized = optimizeImageForTransport(
1098
- b.source.data,
1099
- b.source.media_type,
1100
- );
1101
- if (resized.data !== b.source.data) {
1102
- // sips managed to downscale — use the smaller version
1101
+ if (b.type === "image") return [recoverImageBlock(b)];
1102
+ // Images returned by a tool (e.g. browser_screenshot) live in
1103
+ // the tool_result's contentBlocks, not as top-level blocks.
1104
+ // Recover them in place so the tool_use/tool_result pairing
1105
+ // stays intact rather than dropping the whole tool_result.
1106
+ if (b.type === "tool_result" && b.contentBlocks?.length) {
1103
1107
  return [
1104
1108
  {
1105
1109
  ...b,
1106
- source: {
1107
- type: "base64" as const,
1108
- media_type: resized.mediaType,
1109
- data: resized.data,
1110
- },
1110
+ contentBlocks: b.contentBlocks.map((cb) =>
1111
+ cb.type === "image" ? recoverImageBlock(cb) : cb,
1112
+ ),
1111
1113
  },
1112
1114
  ];
1113
1115
  }
1114
- // Can't resize — replace with a text annotation so the model
1115
- // can explain the situation rather than silently dropping context
1116
- return [{ type: "text" as const, text: UNSENDABLE_IMAGE_NOTE }];
1116
+ return [b];
1117
1117
  }),
1118
1118
  };
1119
1119
  });
@@ -1302,7 +1302,7 @@ export async function runAgentLoopImpl(
1302
1302
  reducerState,
1303
1303
  (msgs, signal, opts) =>
1304
1304
  defaultCompact({
1305
- manager: ctx.contextWindowManager,
1305
+ conversationId: ctx.conversationId,
1306
1306
  messages: msgs,
1307
1307
  signal,
1308
1308
  ...((opts ?? {}) as ContextWindowCompactOptions),
@@ -1399,7 +1399,7 @@ export async function runAgentLoopImpl(
1399
1399
  requestId: reqId,
1400
1400
  });
1401
1401
  const emergencyCompact = await defaultCompact({
1402
- manager: ctx.contextWindowManager,
1402
+ conversationId: ctx.conversationId,
1403
1403
  messages: ctx.messages,
1404
1404
  signal: abortController.signal,
1405
1405
  force: true,