@vellumai/assistant 0.8.2 → 0.8.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 (231) hide show
  1. package/ARCHITECTURE.md +11 -12
  2. package/docker-entrypoint.sh +13 -1
  3. package/docker-init-apt-root.sh +79 -6
  4. package/openapi.yaml +336 -21
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  7. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  8. package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
  9. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  10. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  11. package/src/__tests__/context-token-estimator.test.ts +30 -65
  12. package/src/__tests__/conversation-agent-loop.test.ts +57 -1
  13. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
  15. package/src/__tests__/date-context.test.ts +45 -0
  16. package/src/__tests__/external-plugin-loader.test.ts +91 -19
  17. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  18. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  19. package/src/__tests__/heartbeat-service.test.ts +24 -164
  20. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  21. package/src/__tests__/host-app-control-proxy.test.ts +241 -0
  22. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  23. package/src/__tests__/injector-background-turn.test.ts +153 -0
  24. package/src/__tests__/injector-chain.test.ts +5 -0
  25. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
  26. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  27. package/src/__tests__/llm-catalog-parity.test.ts +3 -0
  28. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  29. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  30. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
  31. package/src/__tests__/llm-resolver.test.ts +255 -2
  32. package/src/__tests__/managed-profile-guard.test.ts +10 -0
  33. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  34. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  35. package/src/__tests__/notification-deep-link.test.ts +15 -0
  36. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  37. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  38. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  40. package/src/__tests__/openai-provider.test.ts +218 -3
  41. package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
  42. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  43. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  44. package/src/__tests__/platform-proxy-context.test.ts +6 -1
  45. package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
  46. package/src/__tests__/plugin-types.test.ts +2 -2
  47. package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
  48. package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
  49. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  50. package/src/__tests__/system-prompt.test.ts +6 -73
  51. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  52. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  53. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  54. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  55. package/src/a2a/__tests__/task-store.test.ts +246 -0
  56. package/src/a2a/agent-card.ts +58 -0
  57. package/src/a2a/feature-gate.ts +8 -0
  58. package/src/a2a/protocol-constants.ts +21 -0
  59. package/src/a2a/protocol-errors.ts +50 -0
  60. package/src/a2a/protocol-types.ts +162 -0
  61. package/src/a2a/task-store.ts +168 -0
  62. package/src/agent/loop.ts +167 -18
  63. package/src/channels/config.ts +9 -0
  64. package/src/channels/types.ts +14 -0
  65. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  66. package/src/cli/commands/__tests__/schedules.test.ts +469 -0
  67. package/src/cli/commands/notifications.ts +65 -35
  68. package/src/cli/commands/plugins.ts +67 -0
  69. package/src/cli/commands/schedules.ts +297 -5
  70. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  71. package/src/cli/lib/install-from-github.ts +8 -9
  72. package/src/cli/lib/search-plugins.ts +163 -0
  73. package/src/cli/program.ts +14 -0
  74. package/src/config/assistant-feature-flags.ts +24 -54
  75. package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
  76. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  77. package/src/config/call-site-defaults.ts +105 -0
  78. package/src/config/feature-flag-registry.json +21 -29
  79. package/src/config/llm-resolver.ts +52 -1
  80. package/src/config/schema.ts +2 -0
  81. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  82. package/src/config/schemas/channels.ts +9 -0
  83. package/src/config/schemas/conversations.ts +10 -0
  84. package/src/config/schemas/heartbeat.ts +14 -0
  85. package/src/config/schemas/llm.ts +1 -3
  86. package/src/config/schemas/memory-retrospective.ts +1 -1
  87. package/src/config/schemas/memory-v2.ts +4 -4
  88. package/src/config/schemas/memory.ts +3 -1
  89. package/src/config/seed-inference-profiles.ts +99 -29
  90. package/src/context/compactor.ts +72 -12
  91. package/src/context/token-estimator.ts +32 -34
  92. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
  93. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  94. package/src/daemon/conversation-agent-loop.ts +29 -2
  95. package/src/daemon/conversation-runtime-assembly.ts +9 -0
  96. package/src/daemon/conversation.ts +0 -7
  97. package/src/daemon/date-context.ts +40 -0
  98. package/src/daemon/guardian-action-generators.ts +1 -125
  99. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  100. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  101. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  102. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  103. package/src/daemon/handlers/config-a2a.ts +289 -0
  104. package/src/daemon/handlers/conversations.ts +1 -0
  105. package/src/daemon/host-app-control-proxy.ts +69 -18
  106. package/src/daemon/host-proxy-preactivation.ts +85 -18
  107. package/src/daemon/lifecycle.ts +49 -61
  108. package/src/daemon/memory-v2-startup.ts +49 -13
  109. package/src/daemon/message-types/notifications.ts +21 -0
  110. package/src/daemon/pkb-reminder-builder.test.ts +10 -53
  111. package/src/daemon/pkb-reminder-builder.ts +4 -19
  112. package/src/daemon/process-message.ts +3 -0
  113. package/src/daemon/skill-memory-refresh.ts +5 -1
  114. package/src/daemon/wake-target-adapter.ts +2 -0
  115. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  116. package/src/export/transcript-formatter.ts +54 -20
  117. package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
  118. package/src/heartbeat/heartbeat-service.ts +34 -191
  119. package/src/home/__tests__/feed-types.test.ts +40 -0
  120. package/src/home/feed-types.ts +14 -2
  121. package/src/ipc/cli-client.ts +147 -45
  122. package/src/memory/__tests__/conversation-queries.test.ts +220 -0
  123. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  124. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  125. package/src/memory/conversation-queries.ts +87 -1
  126. package/src/memory/conversation-title-service.ts +26 -4
  127. package/src/memory/db-init.ts +6 -0
  128. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
  129. package/src/memory/graph/conversation-graph-memory.ts +18 -6
  130. package/src/memory/graph/tools.ts +6 -37
  131. package/src/memory/invite-store.ts +53 -0
  132. package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
  133. package/src/memory/llm-request-log-store.ts +92 -1
  134. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  135. package/src/memory/memory-retrospective-job.ts +33 -6
  136. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  137. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  138. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  139. package/src/memory/migrations/index.ts +3 -0
  140. package/src/memory/migrations/registry.ts +8 -0
  141. package/src/memory/schema/a2a.ts +15 -0
  142. package/src/memory/schema/index.ts +1 -0
  143. package/src/memory/schema/inference.ts +2 -0
  144. package/src/memory/schema/infrastructure.ts +1 -0
  145. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  146. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  147. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  148. package/src/memory/v2/__tests__/injection.test.ts +190 -3
  149. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  150. package/src/memory/v2/activation-store.ts +14 -16
  151. package/src/memory/v2/cli-command-content.ts +19 -0
  152. package/src/memory/v2/cli-command-store.ts +304 -0
  153. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  154. package/src/memory/v2/injection.ts +49 -20
  155. package/src/memory/v2/page-index.ts +38 -13
  156. package/src/memory/v2/static-context.ts +4 -4
  157. package/src/memory/v2/types.ts +23 -0
  158. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  159. package/src/messaging/providers/a2a/deliver.ts +156 -0
  160. package/src/messaging/providers/gmail/client.ts +9 -2
  161. package/src/messaging/providers/index.ts +11 -2
  162. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  163. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  164. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  165. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  166. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  167. package/src/notifications/adapters/macos.ts +12 -2
  168. package/src/notifications/broadcaster.ts +29 -4
  169. package/src/notifications/copy-composer.ts +17 -64
  170. package/src/notifications/decision-engine.ts +111 -44
  171. package/src/notifications/deterministic-checks.ts +96 -0
  172. package/src/notifications/emit-signal.ts +1 -0
  173. package/src/notifications/home-feed-side-effect.ts +85 -6
  174. package/src/notifications/signal.ts +0 -4
  175. package/src/notifications/types.ts +8 -0
  176. package/src/oauth/platform-connection.test.ts +43 -3
  177. package/src/oauth/platform-connection.ts +13 -4
  178. package/src/plugins/defaults/injectors.ts +38 -19
  179. package/src/plugins/external-plugin-loader.ts +82 -10
  180. package/src/plugins/types.ts +16 -7
  181. package/src/prompts/__tests__/system-prompt.test.ts +6 -51
  182. package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
  183. package/src/prompts/system-prompt.ts +0 -8
  184. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  185. package/src/prompts/templates/system-sections.ts +0 -9
  186. package/src/providers/__tests__/inference.test.ts +2 -0
  187. package/src/providers/call-site-routing.ts +24 -6
  188. package/src/providers/connection-resolution.ts +63 -13
  189. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  190. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  191. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  192. package/src/providers/inference/adapter-factory.ts +9 -20
  193. package/src/providers/inference/auth.ts +12 -0
  194. package/src/providers/inference/backfill.ts +14 -1
  195. package/src/providers/inference/connections.ts +85 -5
  196. package/src/providers/inference/resolve-auth.ts +2 -0
  197. package/src/providers/model-catalog.ts +199 -244
  198. package/src/providers/model-intents.ts +3 -3
  199. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  200. package/src/providers/openai/chat-completions-provider.ts +159 -6
  201. package/src/providers/openrouter/client.ts +42 -4
  202. package/src/providers/platform-proxy/constants.ts +3 -4
  203. package/src/providers/provider-catalog-visibility.ts +3 -1
  204. package/src/providers/provider-send-message.ts +27 -12
  205. package/src/providers/registry.ts +30 -1
  206. package/src/runtime/agent-wake.ts +61 -1
  207. package/src/runtime/auth/route-policy.ts +13 -0
  208. package/src/runtime/http-server.ts +7 -16
  209. package/src/runtime/http-types.ts +0 -47
  210. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  211. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
  212. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  213. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  214. package/src/runtime/routes/channel-availability-routes.ts +5 -0
  215. package/src/runtime/routes/consolidation-routes.ts +100 -0
  216. package/src/runtime/routes/conversation-query-routes.ts +70 -11
  217. package/src/runtime/routes/conversation-routes.ts +7 -0
  218. package/src/runtime/routes/index.ts +2 -0
  219. package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
  220. package/src/runtime/routes/integrations/a2a.ts +235 -0
  221. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  222. package/src/runtime/routes/subagents-routes.ts +41 -0
  223. package/src/subagent/manager.ts +2 -0
  224. package/src/tools/memory/register.ts +1 -9
  225. package/src/tools/registry.ts +2 -2
  226. package/src/tools/types.ts +37 -2
  227. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  228. package/src/workspace/migrations/registry.ts +2 -0
  229. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  230. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  231. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -0,0 +1,168 @@
1
+ import { eq } from "drizzle-orm";
2
+
3
+ import type { DrizzleDb } from "../memory/db-connection.js";
4
+ import { getDb } from "../memory/db-connection.js";
5
+ import { rawChanges } from "../memory/raw-query.js";
6
+ import { a2aTasks } from "../memory/schema.js";
7
+ import { TERMINAL_TASK_STATES } from "./protocol-constants.js";
8
+ import type {
9
+ A2AMessage,
10
+ A2ATask,
11
+ Artifact,
12
+ TaskState,
13
+ } from "./protocol-types.js";
14
+
15
+ // ── Internal types ──────────────────────────────────────────────────
16
+
17
+ /** Raw database row shape for a2a_tasks. */
18
+ type A2ATaskRow = typeof a2aTasks.$inferSelect;
19
+
20
+ // ── Helpers ─────────────────────────────────────────────────────────
21
+
22
+ /** Throw if the task doesn't exist or is in a terminal state. */
23
+ function assertNonTerminal(
24
+ db: DrizzleDb,
25
+ taskId: string,
26
+ targetState: TaskState,
27
+ ): void {
28
+ const current = db
29
+ .select({ state: a2aTasks.state })
30
+ .from(a2aTasks)
31
+ .where(eq(a2aTasks.id, taskId))
32
+ .get();
33
+
34
+ if (!current) {
35
+ throw new Error(`A2A task not found: ${taskId}`);
36
+ }
37
+
38
+ if (TERMINAL_TASK_STATES.has(current.state as TaskState)) {
39
+ throw new Error(
40
+ `Cannot transition task ${taskId} from terminal state "${current.state}" to "${targetState}"`,
41
+ );
42
+ }
43
+ }
44
+
45
+ function rowToTask(row: A2ATaskRow): A2ATask {
46
+ return {
47
+ id: row.id,
48
+ context_id: row.contextId ?? undefined,
49
+ status: {
50
+ state: row.state as TaskState,
51
+ message: row.statusMessage
52
+ ? {
53
+ message_id: crypto.randomUUID(),
54
+ role: "agent",
55
+ parts: [{ kind: "text", text: row.statusMessage }],
56
+ }
57
+ : undefined,
58
+ timestamp: new Date(row.updatedAt).toISOString(),
59
+ },
60
+ artifacts: row.artifactsJson
61
+ ? (JSON.parse(row.artifactsJson) as Artifact[])
62
+ : undefined,
63
+ };
64
+ }
65
+
66
+ // ── Store functions ─────────────────────────────────────────────────
67
+
68
+ export function createTask(params: {
69
+ contextId?: string;
70
+ senderAssistantId: string;
71
+ requestMessage: A2AMessage;
72
+ pushUrl?: string;
73
+ }): A2ATask {
74
+ const db = getDb();
75
+ const now = Date.now();
76
+
77
+ const row: A2ATaskRow = {
78
+ id: crypto.randomUUID(),
79
+ contextId: params.contextId ?? null,
80
+ conversationId: null,
81
+ state: "submitted",
82
+ statusMessage: null,
83
+ requestMessageJson: JSON.stringify(params.requestMessage),
84
+ artifactsJson: null,
85
+ pushUrl: params.pushUrl ?? null,
86
+ senderAssistantId: params.senderAssistantId,
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ };
90
+
91
+ db.insert(a2aTasks).values(row).run();
92
+
93
+ return rowToTask(row);
94
+ }
95
+
96
+ export function getTask(taskId: string): A2ATask | null {
97
+ const db = getDb();
98
+ const row = db.select().from(a2aTasks).where(eq(a2aTasks.id, taskId)).get();
99
+ return row ? rowToTask(row) : null;
100
+ }
101
+
102
+ export function updateState(
103
+ taskId: string,
104
+ state: TaskState,
105
+ statusMessage?: string,
106
+ ): A2ATask {
107
+ const db = getDb();
108
+ assertNonTerminal(db, taskId, state);
109
+
110
+ db.update(a2aTasks)
111
+ .set({
112
+ state,
113
+ statusMessage: statusMessage ?? null,
114
+ updatedAt: Date.now(),
115
+ })
116
+ .where(eq(a2aTasks.id, taskId))
117
+ .run();
118
+
119
+ return rowToTask(
120
+ db.select().from(a2aTasks).where(eq(a2aTasks.id, taskId)).get()!,
121
+ );
122
+ }
123
+
124
+ export function completeWithArtifacts(
125
+ taskId: string,
126
+ artifacts: Artifact[],
127
+ ): A2ATask {
128
+ const db = getDb();
129
+ assertNonTerminal(db, taskId, "completed");
130
+
131
+ db.update(a2aTasks)
132
+ .set({
133
+ state: "completed",
134
+ statusMessage: null,
135
+ artifactsJson: JSON.stringify(artifacts),
136
+ updatedAt: Date.now(),
137
+ })
138
+ .where(eq(a2aTasks.id, taskId))
139
+ .run();
140
+
141
+ return rowToTask(
142
+ db.select().from(a2aTasks).where(eq(a2aTasks.id, taskId)).get()!,
143
+ );
144
+ }
145
+
146
+ export function linkConversation(taskId: string, conversationId: string): void {
147
+ const db = getDb();
148
+ const now = Date.now();
149
+
150
+ db.update(a2aTasks)
151
+ .set({ conversationId, updatedAt: now })
152
+ .where(eq(a2aTasks.id, taskId))
153
+ .run();
154
+
155
+ if (rawChanges() === 0) {
156
+ throw new Error(`A2A task not found: ${taskId}`);
157
+ }
158
+ }
159
+
160
+ export function getPushUrl(taskId: string): string | null {
161
+ const db = getDb();
162
+ const row = db
163
+ .select({ pushUrl: a2aTasks.pushUrl })
164
+ .from(a2aTasks)
165
+ .where(eq(a2aTasks.id, taskId))
166
+ .get();
167
+ return row?.pushUrl ?? null;
168
+ }
package/src/agent/loop.ts CHANGED
@@ -67,6 +67,43 @@ export interface CheckpointInfo {
67
67
 
68
68
  export type CheckpointDecision = "continue" | "yield";
69
69
 
70
+ /**
71
+ * Why an {@link AgentLoop.run} invocation exited its `while (true)` body.
72
+ *
73
+ * Emitted exactly once per run as part of an {@link AgentEvent} of type
74
+ * `agent_loop_exit`, then persisted onto the **final** `llm_request_logs`
75
+ * row of the run. Rows from intermediate turns keep a NULL
76
+ * `agent_loop_exit_reason`, which is how downstream tooling (and the LLM
77
+ * Context Inspector) distinguishes "loop kept going" from "loop is done".
78
+ *
79
+ * Values are stable wire/DB strings — they are written to SQLite and
80
+ * surfaced over the inspector wire format, so renaming any of them is a
81
+ * breaking change.
82
+ *
83
+ * Cardinality matches the nine `break;`/`throw` sites currently inside the
84
+ * loop body. Keep in sync with `emitExit` call sites in
85
+ * {@link AgentLoop.run}.
86
+ */
87
+ export type AgentLoopExitReason =
88
+ /** `if (signal?.aborted) break;` at the top of the loop. */
89
+ | "aborted_pre_call"
90
+ /** Empty assistant response after the configured retry budget. */
91
+ | "empty_response_exhausted"
92
+ /** Assistant message has no tool-use blocks (or no tool executor). */
93
+ | "no_tool_calls"
94
+ /** Signal aborted while building the user-side tool-results message. */
95
+ | "aborted_post_response"
96
+ /** Signal aborted mid-tool-execution; completed results were pushed. */
97
+ | "aborted_during_tools"
98
+ /** A tool result requested handing back to the user. */
99
+ | "yield_to_user"
100
+ /** The orchestrator's `onCheckpoint` callback returned `"yield"`. */
101
+ | "checkpoint_yield"
102
+ /** Signal aborted while the catch handler was synthesizing an error turn. */
103
+ | "aborted_via_error"
104
+ /** Catch-block fallback: an unhandled error broke the loop. */
105
+ | "error";
106
+
70
107
  export type AgentEvent =
71
108
  | { type: "text_delta"; text: string }
72
109
  | { type: "thinking_delta"; thinking: string }
@@ -126,6 +163,32 @@ export type AgentEvent =
126
163
  content?: unknown[];
127
164
  }
128
165
  | { type: "error"; error: Error }
166
+ | {
167
+ /**
168
+ * Emitted when the `llmCall` pipeline throws — i.e. the provider
169
+ * rejected the request before returning a usable response. Carries
170
+ * the loop-level raw request we attempted to send (messages, tools,
171
+ * system prompt, provider-agnostic config) plus the thrown error.
172
+ * Consumers (`handleProviderError` in the daemon handlers, the
173
+ * `onEvent` in `agent-wake`) persist these as `llm_request_logs`
174
+ * rows so failed calls are queryable in the LLM inspector instead
175
+ * of only surfacing in pino logs.
176
+ *
177
+ * `rawRequest` is the loop-level abstract shape rather than the
178
+ * provider-specific payload (which the provider builds internally
179
+ * and never returns when it throws). `actualProvider` echoes the
180
+ * `ProviderError.provider` tag when available so the persisted row
181
+ * has the same `provider` column value as a successful `usage` row.
182
+ *
183
+ * Re-thrown by the inner LLM-call try/catch after emission so the
184
+ * outer agent-loop catch still handles abort, Sentry capture, the
185
+ * existing `error` event, and the loop break.
186
+ */
187
+ type: "provider_error";
188
+ rawRequest: unknown;
189
+ error: Error;
190
+ actualProvider?: string;
191
+ }
129
192
  | {
130
193
  type: "usage";
131
194
  inputTokens: number;
@@ -144,6 +207,18 @@ export type AgentEvent =
144
207
  * for this call (e.g. legacy/stubbed code paths).
145
208
  */
146
209
  estimatedInputTokens?: number;
210
+ }
211
+ | {
212
+ /**
213
+ * Emitted exactly once at the end of {@link AgentLoop.run}, after the
214
+ * loop body has exited (whether via `break;`, an unhandled error in
215
+ * the catch block, or the empty-response throw path). Consumers
216
+ * persist `reason` onto the final `llm_request_logs` row for the run;
217
+ * intermediate rows keep `agent_loop_exit_reason = NULL`, which is the
218
+ * canonical "loop kept going" signal.
219
+ */
220
+ type: "agent_loop_exit";
221
+ reason: AgentLoopExitReason;
147
222
  };
148
223
 
149
224
  const DEFAULT_CONFIG: AgentLoopConfig = {
@@ -398,8 +473,24 @@ export class AgentLoop {
398
473
  const substitutionMap = new Map<string, string>();
399
474
  let streamingPending = "";
400
475
 
476
+ // Idempotency guard for `emitExit`. Used so the throw path in the
477
+ // empty-response branch can stamp its reason ("empty_response_exhausted")
478
+ // before throwing — the catch handler that observes the rethrow will
479
+ // then attempt to stamp "error" and harmlessly no-op, preserving the
480
+ // more specific reason. Also defends against accidental future
481
+ // double-emits if a new break site is added without checking this.
482
+ let exitReasonEmitted = false;
483
+ const emitExit = async (reason: AgentLoopExitReason): Promise<void> => {
484
+ if (exitReasonEmitted) return;
485
+ exitReasonEmitted = true;
486
+ await onEvent({ type: "agent_loop_exit", reason });
487
+ };
488
+
401
489
  while (true) {
402
- if (signal?.aborted) break;
490
+ if (signal?.aborted) {
491
+ await emitExit("aborted_pre_call");
492
+ break;
493
+ }
403
494
 
404
495
  rlog.info(
405
496
  { turn: toolUseTurns, messageCount: history.length },
@@ -618,23 +709,64 @@ export class AgentLoop {
618
709
  toolUseTurns,
619
710
  );
620
711
 
621
- const response: LLMCallResult = await runPipeline<
622
- LLMCallArgs,
623
- LLMCallResult
624
- >(
625
- "llmCall",
626
- getMiddlewaresFor("llmCall"),
627
- (args) =>
628
- args.provider.sendMessage(
629
- args.messages,
630
- args.tools,
631
- args.systemPrompt,
632
- args.options,
633
- ),
634
- llmCallArgs,
635
- turnCtx,
636
- DEFAULT_TIMEOUTS.llmCall,
637
- );
712
+ // Inner try/catch narrows error-recording scope to the provider
713
+ // call itself. The outer agent-loop catch (below) wraps the entire
714
+ // turn body (tool execution, plugin pipelines, checkpoints), so
715
+ // recording there would risk mis-attributing tool/plugin throws as
716
+ // provider rejections. On provider failure we emit `provider_error`
717
+ // with the loop-level raw request so consumers can persist it as an
718
+ // `llm_request_logs` row, then re-throw so the existing outer catch
719
+ // continues to handle abort sync, Sentry capture, the `error` event,
720
+ // and the loop break unchanged.
721
+ let response: LLMCallResult;
722
+ try {
723
+ response = await runPipeline<LLMCallArgs, LLMCallResult>(
724
+ "llmCall",
725
+ getMiddlewaresFor("llmCall"),
726
+ (args) =>
727
+ args.provider.sendMessage(
728
+ args.messages,
729
+ args.tools,
730
+ args.systemPrompt,
731
+ args.options,
732
+ ),
733
+ llmCallArgs,
734
+ turnCtx,
735
+ DEFAULT_TIMEOUTS.llmCall,
736
+ );
737
+ } catch (llmCallError) {
738
+ // Skip recording on abort — the user cancelled the request and
739
+ // there's no provider rejection worth a log row. The outer catch
740
+ // still synthesizes cancellation tool_results.
741
+ if (!signal?.aborted) {
742
+ const errInstance =
743
+ llmCallError instanceof Error
744
+ ? llmCallError
745
+ : new Error(String(llmCallError));
746
+ // Strip non-serializable / runtime-only fields from `options`
747
+ // before snapshotting. `onEvent` is a closure with side effects
748
+ // and `signal` is an AbortSignal — neither is meaningful in a
749
+ // persisted log row, and `JSON.stringify` would silently drop or
750
+ // misrepresent both.
751
+ const rawRequest = {
752
+ provider: this.provider.name,
753
+ messages: llmCallArgs.messages,
754
+ tools: llmCallArgs.tools,
755
+ systemPrompt: llmCallArgs.systemPrompt,
756
+ config: llmCallArgs.options?.config,
757
+ };
758
+ onEvent({
759
+ type: "provider_error",
760
+ rawRequest,
761
+ error: errInstance,
762
+ actualProvider:
763
+ errInstance instanceof ProviderError
764
+ ? errInstance.provider
765
+ : this.provider.name,
766
+ });
767
+ }
768
+ throw llmCallError;
769
+ }
638
770
 
639
771
  const providerDurationMs = Date.now() - providerStart;
640
772
 
@@ -785,6 +917,11 @@ export class AgentLoop {
785
917
  { turn: toolUseTurns, retries: emptyResponseRetries },
786
918
  "emptyResponse pipeline requested error surface",
787
919
  );
920
+ // Stamp the specific exit reason *before* throwing. The catch
921
+ // handler below will see the rethrown error and attempt to stamp
922
+ // "error" — guarded by `exitReasonEmitted`, that becomes a no-op
923
+ // and the more specific reason wins.
924
+ await emitExit("empty_response_exhausted");
788
925
  throw new AssistantError(
789
926
  "Model returned empty response after tool results",
790
927
  ErrorCode.INTERNAL_ERROR,
@@ -811,6 +948,7 @@ export class AgentLoop {
811
948
  await onEvent({ type: "message_complete", message: assistantMessage });
812
949
 
813
950
  if (toolUseBlocks.length === 0 || !this.toolExecutor) {
951
+ await emitExit("no_tool_calls");
814
952
  break;
815
953
  }
816
954
 
@@ -835,6 +973,7 @@ export class AgentLoop {
835
973
  }),
836
974
  );
837
975
  history.push({ role: "user", content: cancelledBlocks });
976
+ await emitExit("aborted_post_response");
838
977
  break;
839
978
  }
840
979
 
@@ -1022,6 +1161,7 @@ export class AgentLoop {
1022
1161
  // If cancelled during execution, push completed results and stop
1023
1162
  if (signal?.aborted) {
1024
1163
  history.push({ role: "user", content: resultBlocks });
1164
+ await emitExit("aborted_during_tools");
1025
1165
  break;
1026
1166
  }
1027
1167
 
@@ -1029,6 +1169,7 @@ export class AgentLoop {
1029
1169
  // surface awaiting a button click), push results and stop the loop.
1030
1170
  if (toolResults.some(({ result }) => result.yieldToUser)) {
1031
1171
  history.push({ role: "user", content: resultBlocks });
1172
+ await emitExit("yield_to_user");
1032
1173
  break;
1033
1174
  }
1034
1175
 
@@ -1095,6 +1236,7 @@ export class AgentLoop {
1095
1236
  history,
1096
1237
  });
1097
1238
  if (decision === "yield") {
1239
+ await emitExit("checkpoint_yield");
1098
1240
  break;
1099
1241
  }
1100
1242
  }
@@ -1114,6 +1256,7 @@ export class AgentLoop {
1114
1256
  );
1115
1257
  history.push({ role: "user", content: cancelledBlocks });
1116
1258
  }
1259
+ await emitExit("aborted_via_error");
1117
1260
  break;
1118
1261
  }
1119
1262
  const err = error instanceof Error ? error : new Error(String(error));
@@ -1125,6 +1268,12 @@ export class AgentLoop {
1125
1268
  Sentry.captureException(err);
1126
1269
  }
1127
1270
  onEvent({ type: "error", error: err });
1271
+ // Catch-block fallback. If the rethrow came from the
1272
+ // empty-response throw path above, `emitExit("error")` no-ops
1273
+ // because `emitExit("empty_response_exhausted")` already ran
1274
+ // before the throw. Otherwise, this is the genuine
1275
+ // unhandled-error exit.
1276
+ await emitExit("error");
1128
1277
  break;
1129
1278
  }
1130
1279
  }
@@ -94,6 +94,15 @@ const CHANNEL_POLICIES = {
94
94
  codeRedemptionEnabled: false,
95
95
  },
96
96
  },
97
+ a2a: {
98
+ notification: {
99
+ deliveryEnabled: false,
100
+ conversationStrategy: "continue_existing_conversation",
101
+ },
102
+ invite: {
103
+ codeRedemptionEnabled: false,
104
+ },
105
+ },
97
106
  } as const satisfies Record<ChannelId, ChannelNotificationPolicy>;
98
107
 
99
108
  export type ChannelPolicies = typeof CHANNEL_POLICIES;
@@ -6,6 +6,7 @@ export const CHANNEL_IDS = [
6
6
  "slack",
7
7
  "email",
8
8
  "platform",
9
+ "a2a",
9
10
  ] as const;
10
11
 
11
12
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -133,6 +134,18 @@ export const CHANNEL_METADATA: Partial<Record<ChannelId, ChannelInfo>> = {
133
134
  "I'd like to verify a contact's WhatsApp identity. Can you walk me through it?",
134
135
  },
135
136
  },
137
+ a2a: {
138
+ id: "a2a",
139
+ label: "A2A",
140
+ subtitle: "Agent-to-Agent protocol",
141
+ icon: "bot",
142
+ supportsVerification: false,
143
+ setupMessages: {
144
+ guardian: "Connect with other Vellum assistants via the A2A protocol.",
145
+ contact:
146
+ "I'd like to connect with another assistant via A2A. Can you help me set that up?",
147
+ },
148
+ },
136
149
  };
137
150
 
138
151
  export const INTERFACE_IDS = [
@@ -146,6 +159,7 @@ export const INTERFACE_IDS = [
146
159
  "slack",
147
160
  "email",
148
161
  "chrome-extension",
162
+ "a2a",
149
163
  ] as const;
150
164
 
151
165
  export type InterfaceId = (typeof INTERFACE_IDS)[number];