@vellumai/assistant 0.9.0 → 0.9.1-staging.1

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 (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Default `post-model-call` hook: when a user-facing turn is about to end with a
3
+ * progress surface the model showed but never closed, nudge the model — once
4
+ * per run — to complete or dismiss it, then re-query so it can act.
5
+ *
6
+ * Motivation: the model can call `ui_show` with a `task_progress` card (or a
7
+ * `work_result` in an `in_progress` state) to show live progress, but weaker
8
+ * models often never advance it to a terminal status or `ui_dismiss` it. The
9
+ * surface then renders a spinner forever, long after the work finished. Static
10
+ * prompt guidance is easy to ignore; a reminder injected at the moment the turn
11
+ * would otherwise end — with a concrete "you left this open" signal — is far
12
+ * more salient.
13
+ *
14
+ * The nudge is strictly best-effort and self-targeting:
15
+ * - It fires at most once per run (no looping if the model declines).
16
+ * - It is advisory — the model may leave the surface open if the work it
17
+ * represents is genuinely still running.
18
+ * - A model that already completed or dismissed its surfaces is never nudged,
19
+ * so capable models that close their surfaces pay nothing.
20
+ *
21
+ * Only a finalized, no-tool, main-agent reply is actionable:
22
+ * - A provider rejection carries no turn content to assess (a recovery hook
23
+ * like history-repair owns that).
24
+ * - A tool-bearing turn continues naturally — the loop runs the tools and the
25
+ * model gets another chance to close the surface — so we leave it alone.
26
+ * - Background call sites (wake, title-gen, memory) have no live user watching
27
+ * a spinner, so the nudge would only burn a model round.
28
+ *
29
+ * No subagent guard is needed: the `ui-surface` tools are gated on a connected
30
+ * client (see `conversation-tool-setup.ts`), and subagents have none — so a
31
+ * subagent can never create a surface and so can never trigger this hook.
32
+ *
33
+ * The dangling-surface signal is derived from the current response cycle (the
34
+ * messages after the last genuine user prompt) by correlating each progress
35
+ * `ui_show` with its `surface_id` result and folding in later `ui_update` /
36
+ * `ui_dismiss` calls. Deriving the cycle boundary from history content rather
37
+ * than an index means mid-run compaction (which rewrites the array in place)
38
+ * can't invalidate it.
39
+ *
40
+ * The one-shot bound is split across two hooks: this hook marks the
41
+ * conversation when it nudges, and the sibling `stop` hook clears the mark when
42
+ * the turn terminates, so the next run nudges afresh.
43
+ *
44
+ * Defaults register before any user plugin, so this hook runs at the front of
45
+ * the `post-model-call` chain — later hooks see (and may override) its decision.
46
+ */
47
+
48
+ import type { PluginHookFn, PostModelCallContext } from "@vellumai/plugin-api";
49
+
50
+ import type { ContentBlock, Message } from "../../../../providers/types.js";
51
+ import {
52
+ isSurfaceCompletionNudged,
53
+ markSurfaceCompletionNudged,
54
+ } from "../nudge-state-store.js";
55
+
56
+ /**
57
+ * Canonical nudge text. Shown to the model as provider-only context, never to
58
+ * the user. Kept verbatim so a plugin that wraps the default sees a stable
59
+ * string. Deliberately soft: the model may leave the surface open if the work
60
+ * is genuinely still running.
61
+ */
62
+ export const SURFACE_COMPLETION_NUDGE_TEXT =
63
+ '<system_notice>You showed the user a progress surface this turn (a task_progress card or a work_result) and are about to end the turn with it still marked in_progress. If that work is finished, advance it to a terminal state now — call ui_update to set its status to "completed" (or "failed"), or ui_dismiss it — so the user is not left watching a card spin forever. Do this only if the work it represents is actually done; if it is genuinely still running, leave it. Then give your final reply.</system_notice>';
64
+
65
+ /**
66
+ * Surface statuses that mean the progress surface has reached a terminal state
67
+ * and needs no completion nudge. Covers both `task_progress`
68
+ * (`completed`/`failed`) and `work_result` (`completed`/`partial`/`failed`),
69
+ * plus `cancelled` for tolerance.
70
+ */
71
+ const TERMINAL_STATUSES = new Set([
72
+ "completed",
73
+ "failed",
74
+ "partial",
75
+ "cancelled",
76
+ ]);
77
+
78
+ function hasToolUse(content: ReadonlyArray<ContentBlock>): boolean {
79
+ return content.some((block) => block.type === "tool_use");
80
+ }
81
+
82
+ /** A user-role message carrying only tool results, not a fresh prompt. */
83
+ function isToolResultMessage(message: Message): boolean {
84
+ return (
85
+ message.role === "user" &&
86
+ message.content.length > 0 &&
87
+ message.content.every((block) => block.type === "tool_result")
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Messages belonging to the current response cycle: everything after the last
93
+ * genuine user prompt. Falls back to the whole history when none is found.
94
+ */
95
+ function currentCycleMessages(
96
+ messages: ReadonlyArray<Message>,
97
+ ): ReadonlyArray<Message> {
98
+ for (let i = messages.length - 1; i >= 0; i--) {
99
+ const message = messages[i];
100
+ if (message.role === "user" && !isToolResultMessage(message)) {
101
+ return messages.slice(i + 1);
102
+ }
103
+ }
104
+ return messages;
105
+ }
106
+
107
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
108
+ return value !== null && typeof value === "object"
109
+ ? (value as Record<string, unknown>)
110
+ : undefined;
111
+ }
112
+
113
+ /**
114
+ * Pull a surface status out of a `ui_show` / `ui_update` input, tolerating the
115
+ * shapes the server-side normalization accepts: nested under
116
+ * `data.templateData` (task_progress), under `data` (work_result), or at the
117
+ * top level. Returns a lowercased status, or `undefined` when none is present.
118
+ */
119
+ function extractStatus(input: Record<string, unknown>): string | undefined {
120
+ const data = asRecord(input.data);
121
+ const templateData =
122
+ asRecord(data?.templateData) ?? asRecord(input.templateData);
123
+ const raw = templateData?.status ?? data?.status ?? input.status;
124
+ return typeof raw === "string" ? raw.trim().toLowerCase() : undefined;
125
+ }
126
+
127
+ /**
128
+ * Whether a `ui_show` input describes a surface with a progress lifecycle: a
129
+ * `task_progress` card or a `work_result`. Returns the initial status when so.
130
+ */
131
+ function progressShowInfo(input: Record<string, unknown>): {
132
+ isProgress: boolean;
133
+ status: string | undefined;
134
+ } {
135
+ const surfaceType = input.surface_type;
136
+ if (surfaceType === "work_result") {
137
+ return { isProgress: true, status: extractStatus(input) };
138
+ }
139
+ if (surfaceType === "card") {
140
+ const data = asRecord(input.data);
141
+ const template = data?.template ?? input.template;
142
+ if (template === "task_progress") {
143
+ return { isProgress: true, status: extractStatus(input) };
144
+ }
145
+ }
146
+ return { isProgress: false, status: undefined };
147
+ }
148
+
149
+ function surfaceIdOf(input: Record<string, unknown>): string | undefined {
150
+ return typeof input.surface_id === "string" ? input.surface_id : undefined;
151
+ }
152
+
153
+ /** Parse the `{ surfaceId }` JSON a successful `ui_show` returns. */
154
+ function parseSurfaceId(content: string): string | undefined {
155
+ try {
156
+ const parsed = JSON.parse(content) as unknown;
157
+ const record = asRecord(parsed);
158
+ return typeof record?.surfaceId === "string" ? record.surfaceId : undefined;
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ interface SurfaceState {
165
+ /** Latest known status, lowercased; `undefined` when never set explicitly. */
166
+ status: string | undefined;
167
+ dismissed: boolean;
168
+ }
169
+
170
+ function isNonTerminal(state: SurfaceState): boolean {
171
+ if (state.dismissed) return false;
172
+ return state.status === undefined || !TERMINAL_STATUSES.has(state.status);
173
+ }
174
+
175
+ /**
176
+ * True when the current response cycle left at least one progress surface
177
+ * (a `task_progress` card or `work_result`) open: shown but neither advanced to
178
+ * a terminal status nor dismissed.
179
+ *
180
+ * Each progress `ui_show` is correlated to its `surface_id` via the matching
181
+ * tool result (the result lands in the next message; updates and dismisses
182
+ * arrive in later messages, after the model has the id in hand). Later
183
+ * `ui_update` / `ui_dismiss` calls fold their status / dismissal onto the
184
+ * tracked surface.
185
+ */
186
+ function hasDanglingProgressSurface(messages: ReadonlyArray<Message>): boolean {
187
+ const surfaces = new Map<string, SurfaceState>();
188
+ // tool_use_id -> initial status of a progress ui_show awaiting its result id.
189
+ const pendingShows = new Map<string, string | undefined>();
190
+
191
+ for (const message of currentCycleMessages(messages)) {
192
+ if (message.role === "assistant") {
193
+ for (const block of message.content) {
194
+ if (block.type !== "tool_use") continue;
195
+ if (block.name === "ui_show") {
196
+ const info = progressShowInfo(block.input);
197
+ if (info.isProgress) pendingShows.set(block.id, info.status);
198
+ } else if (block.name === "ui_update") {
199
+ const id = surfaceIdOf(block.input);
200
+ const status = extractStatus(block.input);
201
+ if (id && status !== undefined) {
202
+ const existing = surfaces.get(id);
203
+ if (existing) existing.status = status;
204
+ else surfaces.set(id, { status, dismissed: false });
205
+ }
206
+ } else if (block.name === "ui_dismiss") {
207
+ const id = surfaceIdOf(block.input);
208
+ if (id) {
209
+ const existing = surfaces.get(id);
210
+ if (existing) existing.dismissed = true;
211
+ else surfaces.set(id, { status: undefined, dismissed: true });
212
+ }
213
+ }
214
+ }
215
+ continue;
216
+ }
217
+ if (message.role !== "user") continue;
218
+ for (const block of message.content) {
219
+ // guard:allow-tool-result-only — only the local tool executor's
220
+ // `tool_result` carries a `ui_show` `surfaceId` to correlate. A
221
+ // `web_search_tool_result` comes from a `server_tool_use`, never a
222
+ // `ui_show`, so it can never match a pending show and is correctly skipped.
223
+ if (block.type !== "tool_result") continue;
224
+ if (!pendingShows.has(block.tool_use_id)) continue;
225
+ const initialStatus = pendingShows.get(block.tool_use_id);
226
+ pendingShows.delete(block.tool_use_id);
227
+ const id = parseSurfaceId(block.content);
228
+ if (!id) continue;
229
+ const existing = surfaces.get(id);
230
+ // A later update/dismiss can register the id before its show result is
231
+ // scanned only if history was reordered; guard so we never clobber a
232
+ // known terminal/dismissed state with the initial status.
233
+ if (existing) {
234
+ if (existing.status === undefined && !existing.dismissed) {
235
+ existing.status = initialStatus;
236
+ }
237
+ } else {
238
+ surfaces.set(id, { status: initialStatus, dismissed: false });
239
+ }
240
+ }
241
+ }
242
+
243
+ for (const state of surfaces.values()) {
244
+ if (isNonTerminal(state)) return true;
245
+ }
246
+ return false;
247
+ }
248
+
249
+ const postModelCall: PluginHookFn<PostModelCallContext> = async (ctx) => {
250
+ // A provider rejection carries no turn content to assess (a recovery hook
251
+ // owns the rejection).
252
+ if (ctx.error) return;
253
+ // A tool-bearing turn continues mid-run — the loop runs the tools and the
254
+ // model gets another chance to close the surface — so leave it alone.
255
+ if (hasToolUse(ctx.content)) return;
256
+ // Only nudge the user-facing reply: background call sites have no live user
257
+ // watching a spinner.
258
+ if (ctx.callSite !== "mainAgent") return;
259
+ // One nudge per run; the sibling `stop` hook clears the mark on terminal stop.
260
+ if (isSurfaceCompletionNudged(ctx.conversationId)) return;
261
+
262
+ if (!hasDanglingProgressSurface(ctx.messages)) return;
263
+
264
+ markSurfaceCompletionNudged(ctx.conversationId);
265
+ ctx.messages.push({
266
+ role: "user",
267
+ content: [{ type: "text", text: SURFACE_COMPLETION_NUDGE_TEXT }],
268
+ });
269
+ ctx.decision = "continue";
270
+ ctx.logger.info(
271
+ { plugin: "surface-completion-nudge", conversationId: ctx.conversationId },
272
+ "Turn ending with an open progress surface — nudging the model to complete or dismiss it",
273
+ );
274
+ };
275
+
276
+ export default postModelCall;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Default `stop` hook: clears the per-conversation surface-completion nudge
3
+ * bound when a turn terminates.
4
+ *
5
+ * The `post-model-call` hook (see `./post-model-call.ts`) marks the bound when
6
+ * it nudges the model to close a dangling progress surface. `stop` is the
7
+ * definitive terminal hook — it fires exactly once when the turn is truly
8
+ * ending, after every retry decision has been made — so clearing the bound here
9
+ * unconditionally guarantees the next run nudges afresh, no matter how the turn
10
+ * ended (a finalized reply, an abort, or a retry the loop's per-run backstop
11
+ * refused).
12
+ */
13
+
14
+ import type { PluginHookFn, StopContext } from "@vellumai/plugin-api";
15
+
16
+ import { clearSurfaceCompletionNudged } from "../nudge-state-store.js";
17
+
18
+ const stop: PluginHookFn<StopContext> = async (ctx) => {
19
+ clearSurfaceCompletionNudged(ctx.conversationId);
20
+ };
21
+
22
+ export default stop;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Per-conversation surface-completion nudge state.
3
+ *
4
+ * The `post-model-call` hook nudges the model — once per run — to complete or
5
+ * dismiss a progress surface it left `in_progress` when the turn was about to
6
+ * end, asking the loop to re-query so the model can act on it. That nudge is
7
+ * bounded to one pass per run: if the model declines or fails to close the
8
+ * surface, the hook lets the turn end rather than looping on it.
9
+ *
10
+ * The two hooks split this state's lifecycle: `post-model-call` marks a
11
+ * conversation when it issues the nudge, and the sibling `stop` hook clears the
12
+ * mark when the turn terminates. A conversation therefore only holds an entry
13
+ * while a nudge is in flight, and the next run nudges afresh.
14
+ *
15
+ * This module is side-effect free: importing it only initializes an empty store
16
+ * and registers no plugin.
17
+ */
18
+
19
+ /** Conversations with a surface-completion nudge in flight for the current run. */
20
+ const nudgeInFlight = new Set<string>();
21
+
22
+ /** Whether the conversation already issued its one nudge this run. */
23
+ export function isSurfaceCompletionNudged(conversationId: string): boolean {
24
+ return nudgeInFlight.has(conversationId);
25
+ }
26
+
27
+ /** Record that the conversation issued its one nudge this run. */
28
+ export function markSurfaceCompletionNudged(conversationId: string): void {
29
+ nudgeInFlight.add(conversationId);
30
+ }
31
+
32
+ /**
33
+ * Clear the conversation's nudge mark so the next run nudges afresh. The
34
+ * sibling `stop` hook calls this when the turn terminates.
35
+ */
36
+ export function clearSurfaceCompletionNudged(conversationId: string): void {
37
+ nudgeInFlight.delete(conversationId);
38
+ }
39
+
40
+ /**
41
+ * Test-only: drop every conversation's nudge state so a suite that drives the
42
+ * hook directly starts each case from an empty store.
43
+ */
44
+ export function resetSurfaceCompletionNudgeStoreForTests(): void {
45
+ nudgeInFlight.clear();
46
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "default-surface-completion-nudge",
3
+ "version": "1.0.0",
4
+ "description": "First-party default plugin that nudges the model to complete or dismiss a progress surface it left in_progress when the turn ends.",
5
+ "private": true,
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=20.12.0"
10
+ },
11
+ "peerDependencies": {
12
+ "@vellumai/plugin-api": "^0.8.0"
13
+ }
14
+ }
@@ -56,7 +56,7 @@ import type { ContentBlock, Message } from "../../../../providers/types.js";
56
56
  * are fine and the model may skip it when wrapping up.
57
57
  */
58
58
  export const TASK_PROGRESS_NUDGE_TEXT =
59
- '<system_notice>You are several tool calls into this turn and have not shown the user a progress card. If you are doing multi-step work, call ui_show now with surface_type "card" and template "task_progress" (coarse steps are fine a rough "Working on X" beats no signal) so the user can see what is happening, and keep it updated with ui_update as you go. Skip this if you are about to finish; never let it interrupt the actual work.</system_notice>';
59
+ '<system_notice>You are several tool calls into this turn with no progress card shown. A card is optional, not required: if the turn is wrapping up, is not really multi-step, or you cannot form clean steps, skip it and keep working — a one-line note of what you are doing is a fine substitute, and proceeding with no card is also fine. Only if a live step tracker would genuinely help the user, show it with a SINGLE self-contained ui_show call that already contains the steps: ui_show({ surface_type: "card", data: { template: "task_progress", templateData: { title: "<what you are doing>", status: "in_progress", steps: [{ label: "<step 1>", status: "in_progress" }, { label: "<step 2>", status: "pending" }] } } }). Coarse steps are fine. Do not call ui_show with an empty `data: {}` and fill it in afterward an empty card renders as a blank box; either include the steps now or skip the card. Advance it later with ui_update under `data.templateData`. Never let the card interrupt the actual work; if one ever looks wrong, just dismiss it and move on. You will not be nudged about this again this turn.</system_notice>';
60
60
 
61
61
  /**
62
62
  * Number of tool-use rounds in a turn, with no task_progress card shown, that
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
3
 
4
4
  import {
5
- findContactByChannelExternalId,
5
+ findContactByAddress,
6
6
  findGuardianForChannel,
7
7
  listGuardianChannels,
8
8
  } from "../contacts/contact-store.js";
@@ -88,7 +88,7 @@ function resolveUserFilename(
88
88
  }
89
89
  } else if (trustContext.requesterExternalUserId) {
90
90
  // Channel-routed request — look up contact by channel identity
91
- const contactWithChannels = findContactByChannelExternalId(
91
+ const contactWithChannels = findContactByAddress(
92
92
  trustContext.sourceChannel,
93
93
  trustContext.requesterExternalUserId,
94
94
  );
@@ -22,7 +22,6 @@ GET handlers must be safe and side-effect-free — they must not enqueue backgro
22
22
 
23
23
  Accepted exceptions (stale-while-revalidate caches): a GET handler may kick off a bounded, fire-and-forget background refresh of a generated-content cache when no fresh cache exists, provided the handler itself stays read-only and returns immediately with cached/fallback copy, the refresh is single-flight (concurrent GETs share one regeneration), and a TTL bounds regeneration frequency. Current instances:
24
24
 
25
- - `GET /v1/identity/intro` — refreshes the generated greeting cache; the background prompt may only depend on static identity/soul context plus caller-supplied local hour/minute.
26
25
  - `GET /v1/home/feed` — refreshes the personalized home greeting and suggested-prompt caches via `revalidateHomeContentInBackground()`, which publishes `home_feed_updated` when fresh content lands so clients refetch. This is intentional: home content is generated on demand (when a user actually views Home), never at daemon startup or on a timer.
27
26
  - `GET /v1/conversation-starters` — enqueues a `generate_conversation_starters` memory job when the starter set is stale, cooldown-gated and deduped against in-flight jobs.
28
27
 
@@ -14,7 +14,6 @@
14
14
  import type { ChannelId } from "../channels/types.js";
15
15
  import {
16
16
  findContactByAddress,
17
- findContactByChannelExternalId,
18
17
  findGuardianForChannel,
19
18
  } from "../contacts/contact-store.js";
20
19
  import type { ContactChannel, ContactWithChannels } from "../contacts/types.js";
@@ -181,19 +180,14 @@ export function resolveActorTrust(
181
180
  if (guardianResult) {
182
181
  const { contact: guardianContact, channel: guardianChannel } =
183
182
  guardianResult;
184
- const canonicalGuardianId = guardianChannel.externalUserId
185
- ? canonicalizeInboundIdentity(
186
- input.sourceChannel,
187
- guardianChannel.externalUserId,
188
- )
189
- : null;
190
183
  guardianBindingMatch = {
191
- guardianExternalUserId: guardianChannel.externalUserId ?? "",
184
+ guardianExternalUserId:
185
+ guardianChannel.externalUserId ?? guardianChannel.address ?? "",
192
186
  guardianDeliveryChatId: guardianChannel.externalChatId,
193
187
  };
194
188
  guardianPrincipalId = guardianContact.principalId ?? undefined;
195
189
  isGuardian =
196
- canonicalGuardianId != null && canonicalGuardianId === canonicalSenderId;
190
+ guardianChannel.address.toLowerCase() === canonicalSenderId.toLowerCase();
197
191
  }
198
192
 
199
193
  log.debug(
@@ -206,37 +200,18 @@ export function resolveActorTrust(
206
200
  );
207
201
 
208
202
  // --- Member lookup via contacts ---
209
- // Primary path: match by externalUserId (populated after channel verification
210
- // completes, or for channels registered via the verification upsert path).
211
- // Fallback path: match by address (covers channels registered by the inbound
212
- // name-capture flow, where address is set but externalUserId remains NULL
213
- // until the DTMF challenge succeeds). Mirrors the gateway's OR-based lookup
214
- // in ContactStore.getContactByPhoneNumber so the runtime's unverified-caller
215
- // guard fires for pre-verification channels the gateway passes through.
216
203
  let memberRecord: ActorTrustContext["memberRecord"] = null;
217
- const byExternalId = findContactByChannelExternalId(
204
+ const byAddress = findContactByAddress(
218
205
  input.sourceChannel,
219
206
  canonicalSenderId,
220
207
  );
221
- const byExternalIdChannel = byExternalId?.channels.find(
208
+ const byAddressChannel = byAddress?.channels.find(
222
209
  (ch) =>
223
210
  ch.type === input.sourceChannel &&
224
- ch.externalUserId === canonicalSenderId,
211
+ ch.address.toLowerCase() === canonicalSenderId.toLowerCase(),
225
212
  );
226
-
227
- if (byExternalId && byExternalIdChannel) {
228
- memberRecord = { contact: byExternalId, channel: byExternalIdChannel };
229
- } else {
230
- // Address fallback: catches channels where externalUserId is not yet set.
231
- const byAddress = findContactByAddress(input.sourceChannel, canonicalSenderId);
232
- const byAddressChannel = byAddress?.channels.find(
233
- (ch) =>
234
- ch.type === input.sourceChannel &&
235
- ch.address?.toLowerCase() === canonicalSenderId.toLowerCase(),
236
- );
237
- if (byAddress && byAddressChannel) {
238
- memberRecord = { contact: byAddress, channel: byAddressChannel };
239
- }
213
+ if (byAddress && byAddressChannel) {
214
+ memberRecord = { contact: byAddress, channel: byAddressChannel };
240
215
  }
241
216
 
242
217
  log.debug(
@@ -244,23 +219,16 @@ export function resolveActorTrust(
244
219
  channel: input.sourceChannel,
245
220
  canonicalSenderId,
246
221
  found: !!memberRecord,
247
- via: memberRecord?.channel.externalUserId ? "externalUserId" : memberRecord ? "address" : "none",
222
+ via: memberRecord ? "address" : "none",
248
223
  },
249
224
  "trust-resolver member lookup",
250
225
  );
251
226
 
252
227
  // Only use member metadata when the record's channel identity matches the
253
228
  // current sender to avoid misidentification in group chats.
254
- // Primary check: externalUserId (canonicalized to handle E.164 variance).
255
- // Fallback: address match for channels where externalUserId is NULL (e.g.
256
- // name-capture registrations that haven't completed DTMF verification yet).
257
- const memberMatchesSender = memberRecord?.channel.externalUserId
258
- ? canonicalizeInboundIdentity(
259
- input.sourceChannel,
260
- memberRecord.channel.externalUserId,
261
- ) === canonicalSenderId
262
- : (memberRecord?.channel.address?.toLowerCase() ===
263
- canonicalSenderId.toLowerCase());
229
+ const memberMatchesSender =
230
+ memberRecord?.channel.address.toLowerCase() ===
231
+ canonicalSenderId.toLowerCase();
264
232
 
265
233
  const memberDisplayName =
266
234
  memberMatchesSender &&
@@ -31,10 +31,9 @@ export interface RunBtwSidechainParams {
31
31
  /**
32
32
  * Unified call-site identifier. The provider layer resolves
33
33
  * provider/model/maxTokens/effort/speed/temperature/thinking/contextWindow
34
- * via `resolveCallSiteConfig(callSite, config.llm)`. Defaults to
35
- * `'identityIntro'` since this side-chain runner was originally introduced
36
- * for the identity intro generation path; callers (greeting, title, etc.)
37
- * override it with their own call-site ID.
34
+ * via `resolveCallSiteConfig(callSite, config.llm)`. Generic BTW traffic
35
+ * uses `identityIntro`; callers with a narrower purpose override it with
36
+ * their own call-site ID.
38
37
  */
39
38
  callSite?: LLMCallSite;
40
39
  signal?: AbortSignal;
@@ -95,8 +94,6 @@ export async function runBtwSidechain(
95
94
  config: {
96
95
  max_tokens: params.maxTokens ?? 1024,
97
96
  tool_choice: { type: "none" },
98
- // Default call site is "identityIntro" — the original purpose of
99
- // this side-chain runner. Callers may override per invocation.
100
97
  callSite: params.callSite ?? ("identityIntro" as LLMCallSite),
101
98
  },
102
99
  onEvent: (event) => {
@@ -1,27 +1,31 @@
1
1
  /**
2
2
  * Channel-agnostic approval flow types.
3
3
  *
4
- * These types model the approval prompt/decision lifecycle for tool-use
5
- * confirmations surfaced through external channels (Telegram, Slack, etc.).
6
- * They are intentionally decoupled from any specific channel so that the
7
- * same approval flow can be reused across transports.
4
+ * Wire-format types (`ApprovalUIMetadata`, `PermissionRequestDetails`,
5
+ * `ApprovalActionOption`) are defined as Zod schemas in
6
+ * `@vellumai/gateway-client/outbound-contract` and re-exported here for
7
+ * convenience. Daemon-internal types that do not cross a wire boundary
8
+ * are defined locally.
8
9
  */
9
10
 
11
+ import type { ApprovalActionOption } from "@vellumai/gateway-client";
12
+
10
13
  import type { GuardianDecisionAction } from "./guardian-decision-types.js";
11
14
 
15
+ // Re-export shared wire types so existing daemon imports keep working.
16
+ export type {
17
+ ApprovalActionOption,
18
+ ApprovalUIMetadata,
19
+ PermissionRequestDetails,
20
+ } from "@vellumai/gateway-client";
21
+
12
22
  // ---------------------------------------------------------------------------
13
- // Approval actions
23
+ // Approval actions (daemon-internal)
14
24
  // ---------------------------------------------------------------------------
15
25
 
16
26
  /** The set of actions a user can take on an approval prompt. */
17
27
  export type ApprovalAction = "approve_once" | "reject";
18
28
 
19
- /** An action presented to the user as a tappable button or text option. */
20
- export interface ApprovalActionOption {
21
- id: ApprovalAction;
22
- label: string;
23
- }
24
-
25
29
  /**
26
30
  * Map `GuardianDecisionAction[]` to `ApprovalActionOption[]` so channel
27
31
  * prompt payloads can be derived from the unified decision action set.
@@ -32,13 +36,13 @@ export function toApprovalActionOptions(
32
36
  actions: GuardianDecisionAction[],
33
37
  ): ApprovalActionOption[] {
34
38
  return actions.map((a) => ({
35
- id: a.action as ApprovalAction,
39
+ id: a.action,
36
40
  label: a.label,
37
41
  }));
38
42
  }
39
43
 
40
44
  // ---------------------------------------------------------------------------
41
- // Approval prompt
45
+ // Approval prompt (daemon-internal)
42
46
  // ---------------------------------------------------------------------------
43
47
 
44
48
  /** The approval prompt model sent to users via a channel. */
@@ -52,38 +56,7 @@ export interface ChannelApprovalPrompt {
52
56
  }
53
57
 
54
58
  // ---------------------------------------------------------------------------
55
- // Approval UI metadata (gateway callback payload)
56
- // ---------------------------------------------------------------------------
57
-
58
- /**
59
- * Tool-permission-specific details carried alongside the approval payload.
60
- * Channels that support rich UI (e.g. Slack Block Kit) use these fields
61
- * to render a detailed permission request card with risk indicators,
62
- * tool arguments, and requester identity.
63
- */
64
- export interface PermissionRequestDetails {
65
- toolName: string;
66
- riskLevel: string;
67
- toolInput: Record<string, unknown>;
68
- /** Present for guardian-escalated requests to identify who is asking. */
69
- requesterIdentifier?: string;
70
- }
71
-
72
- /**
73
- * Metadata attached to gateway callback payloads so the channel adapter
74
- * can render approval UI and route the user's decision back to the
75
- * correct pending interaction.
76
- */
77
- export interface ApprovalUIMetadata {
78
- requestId: string;
79
- actions: ApprovalActionOption[];
80
- plainTextFallback: string;
81
- /** When present, the approval is a tool permission request with extra context. */
82
- permissionDetails?: PermissionRequestDetails;
83
- }
84
-
85
- // ---------------------------------------------------------------------------
86
- // Decision result
59
+ // Decision result (daemon-internal)
87
60
  // ---------------------------------------------------------------------------
88
61
 
89
62
  /** How the user communicated their decision. */
@@ -36,10 +36,10 @@ import type {
36
36
 
37
37
  /**
38
38
  * Ensure the Telegram bot username is resolved and cached in config.
39
- * When the bot token was configured via CLI `credential set`,
40
- * `credential_store` tool, or ingress secret redirect, the `getMe` API
41
- * call that populates the config is skipped — this function fills that
42
- * gap so that invite share links can be generated.
39
+ * When the bot token was configured via the `assistant credentials set` CLI
40
+ * or ingress secret redirect, the `getMe` API call that populates the config
41
+ * is skipped — this function fills that gap so that invite share links can be
42
+ * generated.
43
43
  */
44
44
  export async function ensureTelegramBotUsernameResolved(): Promise<void> {
45
45
  if (getTelegramBotUsername() && getTelegramBotId()) {
@@ -333,7 +333,8 @@ export function getGuardianBinding(
333
333
  id: result.channel.id,
334
334
  assistantId,
335
335
  channel,
336
- guardianExternalUserId: result.channel.externalUserId ?? "",
336
+ guardianExternalUserId:
337
+ result.channel.externalUserId ?? result.channel.address ?? "",
337
338
  guardianDeliveryChatId: result.channel.externalChatId ?? "",
338
339
  guardianPrincipalId: result.contact.principalId ?? "",
339
340
  status: "active" as const,
@@ -355,11 +356,11 @@ export function getGuardianBinding(
355
356
  export function isGuardian(
356
357
  assistantId: string,
357
358
  channel: string,
358
- externalUserId: string,
359
+ address: string,
359
360
  ): boolean {
360
361
  const result = findGuardianForChannel(channel);
361
362
  if (result) {
362
- return result.channel.externalUserId === externalUserId;
363
+ return result.channel.address.toLowerCase() === address.toLowerCase();
363
364
  }
364
365
 
365
366
  return false;