@vellumai/assistant 0.10.3 → 0.10.4-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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -24,6 +24,7 @@ import { wrapWithCallSiteRouting } from "../providers/call-site-routing.js";
24
24
  import { resolveDefaultProvider } from "../providers/connection-resolution.js";
25
25
  import { RateLimitProvider } from "../providers/ratelimit.js";
26
26
  import { listProviders } from "../providers/registry.js";
27
+ import type { Message, TextContent } from "../providers/types.js";
27
28
  import { createAbortReason } from "../util/abort-reasons.js";
28
29
  import { ProviderNotConfiguredError } from "../util/errors.js";
29
30
  import { getLogger } from "../util/logger.js";
@@ -58,6 +59,38 @@ export function mergeSkillIds(
58
59
  return [...new Set([...roleSkillIds, ...(configSkillIds ?? [])])];
59
60
  }
60
61
 
62
+ // ── Final-text extraction helper ────────────────────────────────────────
63
+
64
+ /**
65
+ * Concatenate the `text` blocks of the conversation's trailing assistant
66
+ * message. Used by `spawnAndAwait` to return the child's final synthesis to
67
+ * the awaiting caller. Returns an empty string when the conversation has no
68
+ * assistant message or the final assistant message carries no text blocks
69
+ * (e.g. it ended on a tool_use).
70
+ */
71
+ function extractFinalAssistantText(messages: Message[]): string {
72
+ for (let i = messages.length - 1; i >= 0; i--) {
73
+ const message = messages[i];
74
+ if (message.role !== "assistant") continue;
75
+ return message.content
76
+ .filter((block): block is TextContent => block.type === "text")
77
+ .map((block) => block.text)
78
+ .join("");
79
+ }
80
+ return "";
81
+ }
82
+
83
+ /**
84
+ * Pull the user-visible text out of a streaming delta event, or null for any
85
+ * other event type. Used by the synchronous `onText` tap to forward
86
+ * `assistant_text_delta` / `assistant_thinking_delta` chunks to the caller.
87
+ */
88
+ function extractDeltaText(msg: ServerMessage): string | null {
89
+ if (msg.type === "assistant_text_delta") return msg.text;
90
+ if (msg.type === "assistant_thinking_delta") return msg.thinking;
91
+ return null;
92
+ }
93
+
61
94
  // ── Default subagent system prompt ──────────────────────────────────────
62
95
 
63
96
  function buildSubagentSystemPrompt(
@@ -110,6 +143,19 @@ interface ManagedSubagent {
110
143
  * the release to the TTL sweep rather than tearing down mid-drain.
111
144
  */
112
145
  hadEnqueuedMessages?: boolean;
146
+ /**
147
+ * Set on the synchronous `spawnAndAwait` path. When true, `runSubagent`
148
+ * skips the terminal parent-injection (`notifyParentTerminal`) — the awaiting
149
+ * caller receives the child's final text directly, so re-injecting a
150
+ * "read the result" notification into the parent would be redundant noise.
151
+ */
152
+ synchronous?: boolean;
153
+ /**
154
+ * Optional text tap for the synchronous path. When set, `wrappedSendToClient`
155
+ * forwards each `assistant_text_delta` / `assistant_thinking_delta` chunk to
156
+ * this callback IN ADDITION to the normal `subagent_event` envelope.
157
+ */
158
+ onText?: (chunk: string) => void;
113
159
  }
114
160
 
115
161
  export interface SubagentNotificationInfo {
@@ -121,6 +167,21 @@ export interface SubagentNotificationInfo {
121
167
  objective?: string;
122
168
  }
123
169
 
170
+ /**
171
+ * Thrown by `spawnAndAwait` when the run is aborted (e.g. an external timeout)
172
+ * before reaching a terminal `completed` state. Carries `partialText` — the
173
+ * child's trailing assistant text captured at the moment of abort — so a caller
174
+ * that times out a long generation can still surface the partial result instead
175
+ * of discarding it. Extends `Error` with the same legacy message, so callers
176
+ * that only inspect `.message` keep working.
177
+ */
178
+ export class SubagentAbortedError extends Error {
179
+ constructor(readonly partialText: string) {
180
+ super("Subagent run aborted before completion.");
181
+ this.name = "SubagentAbortedError";
182
+ }
183
+ }
184
+
124
185
  export class SubagentManager {
125
186
  /** subagentId → ManagedSubagent */
126
187
  private subagents = new Map<string, ManagedSubagent>();
@@ -145,6 +206,30 @@ export class SubagentManager {
145
206
  config: Omit<SubagentConfig, "id">,
146
207
  parentSendToClient: (msg: ServerMessage) => void,
147
208
  ): Promise<string> {
209
+ const { subagentId } = await this.setUpSubagent(config, parentSendToClient);
210
+
211
+ // ── Kick off the agent loop (fire-and-forget) ───────────────────
212
+ this.runSubagent(subagentId, config.objective).catch((err) => {
213
+ log.error({ subagentId, err }, "Subagent run failed unexpectedly");
214
+ });
215
+
216
+ return subagentId;
217
+ }
218
+
219
+ // ── Internal: shared spawn setup ──────────────────────────────────────
220
+
221
+ /**
222
+ * Perform all spawn-time setup shared by `spawn` and `spawnAndAwait`:
223
+ * enforce the depth limit, resolve role/provider/system prompt, construct
224
+ * the child Conversation, register it, and emit the `subagent_spawned`
225
+ * event. Does NOT start the agent loop — the caller decides whether to run
226
+ * fire-and-forget (`spawn`) or awaited (`spawnAndAwait`).
227
+ */
228
+ private async setUpSubagent(
229
+ config: Omit<SubagentConfig, "id">,
230
+ parentSendToClient: (msg: ServerMessage) => void,
231
+ opts?: { synchronous?: boolean; onText?: (chunk: string) => void },
232
+ ): Promise<{ subagentId: string; managed: ManagedSubagent }> {
148
233
  // ── Limit checks ────────────────────────────────────────────────
149
234
 
150
235
  // Depth check: prevent subagents from spawning nested subagents.
@@ -159,17 +244,20 @@ export class SubagentManager {
159
244
 
160
245
  // ── Resolve role ─────────────────────────────────────────────────
161
246
  const isFork = config.fork === true;
162
- let role: SubagentRole = (config.role as SubagentRole) ?? "general";
247
+ const role: SubagentRole = (config.role as SubagentRole) ?? "general";
163
248
  if (isFork && role !== "general") {
249
+ // A context-inheriting subagent normally keeps the parent's `general`
250
+ // role so its KV cache stays aligned with the parent conversation. An
251
+ // explicit non-general role opts out of that alignment on purpose
252
+ // (e.g. the advisor role running on a stronger profile), so honor it.
164
253
  log.warn(
165
254
  {
166
255
  requestedRole: role,
167
256
  parentConversationId: config.parentConversationId,
168
257
  label: config.label,
169
258
  },
170
- "Fork requested with non-general role — forcing general to preserve KV cache alignment",
259
+ "Fork requested with non-general role — caller opted out of parent KV-cache alignment",
171
260
  );
172
- role = "general";
173
261
  }
174
262
  if (!SUBAGENT_ROLE_REGISTRY[role]) {
175
263
  throw new Error(
@@ -219,19 +307,21 @@ export class SubagentManager {
219
307
 
220
308
  let systemPrompt: string;
221
309
  if (isFork) {
222
- // Forks use the parent's system prompt directly — no subagent preamble.
223
- if (config.parentSystemPrompt) {
224
- systemPrompt = config.parentSystemPrompt;
225
- } else {
226
- const resolved = parentConversation?.getCurrentSystemPrompt();
227
- if (!resolved) {
228
- throw new Error(
229
- "Fork spawn requires a parent system prompt but neither config.parentSystemPrompt " +
230
- "nor findConversation yielded one.",
231
- );
232
- }
233
- systemPrompt = resolved;
310
+ // Forks default to the parent's system prompt verbatim — no subagent
311
+ // preamble — so the KV cache stays aligned with the parent. An explicit
312
+ // `systemPromptOverride` opts out of that alignment and takes precedence
313
+ // (e.g. the advisor role framing the inherited context as advice).
314
+ const resolved =
315
+ config.systemPromptOverride ??
316
+ config.parentSystemPrompt ??
317
+ parentConversation?.getCurrentSystemPrompt();
318
+ if (!resolved) {
319
+ throw new Error(
320
+ "Fork spawn requires a parent system prompt but neither config.parentSystemPrompt " +
321
+ "nor findConversation yielded one.",
322
+ );
234
323
  }
324
+ systemPrompt = resolved;
235
325
  } else {
236
326
  systemPrompt =
237
327
  config.systemPromptOverride ??
@@ -279,11 +369,20 @@ export class SubagentManager {
279
369
  conversation: null! as Conversation,
280
370
  state,
281
371
  parentSendToClient,
372
+ ...(opts?.synchronous ? { synchronous: true } : {}),
373
+ ...(opts?.onText ? { onText: opts.onText } : {}),
282
374
  };
283
375
 
284
376
  // Wrap sendToClient to envelope all events with the subagent ID.
285
377
  // Reads from managed.parentSendToClient so reconnects are picked up.
286
378
  const wrappedSendToClient = (msg: ServerMessage): void => {
379
+ // Tap streaming text/thinking deltas for the synchronous caller (if any),
380
+ // in addition to the normal envelope below. Reads from managed.onText so
381
+ // the synchronous path can forward chunks without altering event routing.
382
+ if (managed.onText) {
383
+ const text = extractDeltaText(msg);
384
+ if (text) managed.onText(text);
385
+ }
287
386
  managed.parentSendToClient({
288
387
  type: "subagent_event",
289
388
  subagentId,
@@ -298,7 +397,16 @@ export class SubagentManager {
298
397
  systemPrompt,
299
398
  wrappedSendToClient,
300
399
  workingDir,
301
- { maxTokens, cacheTtl: "5m" },
400
+ {
401
+ maxTokens,
402
+ cacheTtl: "5m",
403
+ // The advisor consult runs tool-less for CLIENT tools but should ground
404
+ // its guidance with provider-native web search when the resolved
405
+ // provider supports it. This is a server tool the provider runs itself,
406
+ // so it stays one-shot — no client tool surfaced, allowlist unchanged.
407
+ // Other roles keep the default (no native search appended).
408
+ ...(role === "advisor" ? { enableNativeWebSearch: true } : {}),
409
+ },
302
410
  );
303
411
 
304
412
  // Mark conversation as having no direct client — it routes through parent.
@@ -325,16 +433,19 @@ export class SubagentManager {
325
433
  conversation.setAssistantId(parentConversation.assistantId);
326
434
  }
327
435
 
328
- if (isFork) {
329
- // Force the fork to use the parent's system prompt as-is without dynamic rebuild.
330
- // This ensures KV cache alignment with the parent conversation.
436
+ if (isFork && !config.systemPromptOverride) {
437
+ // A verbatim-prompt fork pins the parent's system prompt as-is, skipping
438
+ // the dynamic rebuild so the KV cache stays aligned with the parent. A
439
+ // fork that supplies its own override prompt opts out of that alignment,
440
+ // so leave `hasSystemPromptOverride` at its default.
331
441
  conversation.hasSystemPromptOverride = true;
332
442
  }
333
443
 
334
- // Apply role-based tool filter if the role defines one.
335
- // Skip for forks general role has allowedTools: undefined, and forks
336
- // should have the same tool access as the parent.
337
- if (!isFork && roleConfig.allowedTools) {
444
+ // Apply the role's tool allowlist when one is defined. The `general` role
445
+ // has `allowedTools: undefined`, so default forks (which keep the general
446
+ // role) are unaffected; a fork carrying an explicit role gets its
447
+ // allowlist applied like any other subagent.
448
+ if (roleConfig.allowedTools) {
338
449
  conversation.setSubagentAllowedTools(new Set(roleConfig.allowedTools));
339
450
  }
340
451
 
@@ -393,12 +504,65 @@ export class SubagentManager {
393
504
  "Subagent spawned",
394
505
  );
395
506
 
396
- // ── Kick off the agent loop (fire-and-forget) ───────────────────
397
- this.runSubagent(subagentId, config.objective).catch((err) => {
398
- log.error({ subagentId, err }, "Subagent run failed unexpectedly");
399
- });
507
+ return { subagentId, managed };
508
+ }
400
509
 
401
- return subagentId;
510
+ // ── Spawn and await (synchronous) ─────────────────────────────────────
511
+
512
+ /**
513
+ * Spawn a subagent and AWAIT its run, resolving to the child's final
514
+ * assistant text. Unlike `spawn` (fire-and-forget), the caller blocks until
515
+ * the child reaches a terminal state and receives the text directly — so the
516
+ * terminal parent-injection (`notifyParentTerminal`) is skipped on this path.
517
+ *
518
+ * `opts.signal` aborts the underlying run when triggered (e.g. an external
519
+ * timeout). `opts.onText` receives each streaming text/thinking chunk in
520
+ * addition to the normal `subagent_event` envelope.
521
+ */
522
+ async spawnAndAwait(
523
+ config: Omit<SubagentConfig, "id">,
524
+ parentSendToClient: (msg: ServerMessage) => void,
525
+ opts?: { signal?: AbortSignal; onText?: (chunk: string) => void },
526
+ ): Promise<string> {
527
+ const { subagentId, managed } = await this.setUpSubagent(
528
+ config,
529
+ parentSendToClient,
530
+ { synchronous: true, ...(opts?.onText ? { onText: opts.onText } : {}) },
531
+ );
532
+
533
+ // Wire the external signal to abort the child conversation. If the signal
534
+ // is already aborted, abort immediately so the run rejects promptly.
535
+ const signal = opts?.signal;
536
+ const onAbort = (): void => {
537
+ // Route through the manager abort path so the subagent is marked terminal
538
+ // ("aborted") and broadcast as such. A bare conversation.abort() leaves
539
+ // status non-terminal, so runSubagent's success branch would record the
540
+ // run as "completed" once runAgentLoop resolves the consumed cancellation.
541
+ // Suppress the parent notification: the awaiting caller observes the abort
542
+ // as a thrown rejection, so a "do NOT re-spawn" injection would be
543
+ // redundant noise.
544
+ this.abort(subagentId, managed.parentSendToClient, undefined, {
545
+ suppressNotification: true,
546
+ });
547
+ };
548
+ if (signal) {
549
+ if (signal.aborted) onAbort();
550
+ else signal.addEventListener("abort", onAbort, { once: true });
551
+ }
552
+
553
+ try {
554
+ const finalText = await this.runSubagent(subagentId, config.objective);
555
+ // Surface aborts as a rejection so the caller's timeout path is
556
+ // observable — but carry the partial text on the error so a caller that
557
+ // timed out a long generation (e.g. the advisor consult) can still
558
+ // surface what was produced instead of throwing it away.
559
+ if (signal?.aborted) {
560
+ throw new SubagentAbortedError(finalText);
561
+ }
562
+ return finalText;
563
+ } finally {
564
+ signal?.removeEventListener("abort", onAbort);
565
+ }
402
566
  }
403
567
 
404
568
  // ── Internal: run the subagent ────────────────────────────────────────
@@ -406,14 +570,31 @@ export class SubagentManager {
406
570
  private async runSubagent(
407
571
  subagentId: string,
408
572
  objective: string,
409
- ): Promise<void> {
573
+ ): Promise<string> {
410
574
  const managed = this.subagents.get(subagentId);
411
- if (!managed) return;
575
+ if (!managed) return "";
412
576
 
413
577
  // Capture the live conversation — it is non-null at this point because
414
578
  // spawn() sets it before firing runSubagent.
415
579
  const conversation = managed.conversation!;
416
580
 
581
+ // The child's trailing assistant text, captured after runAgentLoop resolves
582
+ // (before the `finally` releases the conversation). Returned to the
583
+ // synchronous `spawnAndAwait` caller; the fire-and-forget `spawn` caller
584
+ // ignores it.
585
+ let finalText = "";
586
+
587
+ // Aborted before the run started (e.g. an already-aborted signal on the
588
+ // synchronous spawnAndAwait path): the subagent is already terminal. Do not
589
+ // start the agent loop or reset status back to "running" — but still release
590
+ // the conversation, exactly as the post-run `finally` does for a terminal
591
+ // run. The loop never started, so no messages were enqueued; this matches
592
+ // the finally's non-deferred release branch.
593
+ if (TERMINAL_STATUSES.has(managed.state.status)) {
594
+ this.releaseConversation(managed);
595
+ return finalText;
596
+ }
597
+
417
598
  // Read the current parent sender so reconnects are picked up.
418
599
  const getSender = () => managed.parentSendToClient;
419
600
 
@@ -441,7 +622,16 @@ export class SubagentManager {
441
622
  // the fork tends to continue the parent conversation instead of
442
623
  // pivoting to the task — the inherited context is louder than a bare
443
624
  // objective buried after 100k+ tokens of chat history.
444
- const message = managed.state.isFork
625
+ //
626
+ // The advisor consult is the exception: it is a fork, but its
627
+ // `systemPromptOverride` already frames the inherited context as advice
628
+ // ("you are a senior advisor … do not write its final deliverable"), so
629
+ // the generic "complete this task and return your findings" wrapper would
630
+ // fight that framing. The advisor's objective is already the bare advice
631
+ // request (`advisorRequestText()`), so it is sent uncontested.
632
+ const useForkFraming =
633
+ managed.state.isFork && managed.state.config.role !== "advisor";
634
+ const message = useForkFraming
445
635
  ? [
446
636
  "⎯⎯⎯ FORK TASK ⎯⎯⎯",
447
637
  "You have been forked from the parent conversation to execute a specific task.",
@@ -466,6 +656,9 @@ export class SubagentManager {
466
656
  });
467
657
 
468
658
  // Agent loop completed successfully.
659
+ // Capture the trailing assistant text before any release nulls the
660
+ // conversation reference. The fire-and-forget caller ignores the return.
661
+ finalText = extractFinalAssistantText(conversation.messages);
469
662
  // Copy usage stats from the conversation before sending status (which includes usage).
470
663
  managed.state.usage = { ...conversation.usageStats };
471
664
  // Only update state + notify if still non-terminal (guards against abort race).
@@ -476,7 +669,12 @@ export class SubagentManager {
476
669
  log.info({ subagentId }, "Subagent completed");
477
670
 
478
671
  // Notify the parent conversation so the LLM can call subagent_read.
479
- this.notifyParentTerminal(managed, "completed");
672
+ // Skipped on the synchronous path — the awaiting caller receives the
673
+ // final text directly, so re-injecting a "read the result" prompt
674
+ // would be redundant noise in the parent.
675
+ if (!managed.synchronous) {
676
+ this.notifyParentTerminal(managed, "completed");
677
+ }
480
678
  }
481
679
  } catch (err) {
482
680
  const errorMsg = err instanceof Error ? err.message : String(err);
@@ -489,10 +687,22 @@ export class SubagentManager {
489
687
  // Only update status if not already terminal (e.g. aborted).
490
688
  if (!TERMINAL_STATUSES.has(managed.state.status)) {
491
689
  this.setStatus(subagentId, "failed", getSender(), errorMsg);
492
- this.notifyParentTerminal(managed, "failed");
690
+ // Skip terminal parent-injection on the synchronous path — the failure
691
+ // surfaces to the awaiting caller as a rejected promise instead.
692
+ if (!managed.synchronous) {
693
+ this.notifyParentTerminal(managed, "failed");
694
+ }
493
695
  }
494
696
 
495
697
  log.error({ subagentId, err }, "Subagent failed");
698
+
699
+ // Surface the failure to the synchronous caller. The fire-and-forget
700
+ // path has no awaiter, so re-throwing there only feeds the `.catch()`
701
+ // logger in `spawn` — harmless but noisy — so it is confined to the
702
+ // synchronous path.
703
+ if (managed.synchronous) {
704
+ throw err;
705
+ }
496
706
  } finally {
497
707
  // Release the heavyweight Conversation — output is already persisted in DB.
498
708
  // drainQueue is async: it awaits buildPassthroughBatch (which awaits
@@ -516,6 +726,8 @@ export class SubagentManager {
516
726
  this.releaseConversation(managed);
517
727
  }
518
728
  }
729
+
730
+ return finalText;
519
731
  }
520
732
 
521
733
  // ── Abort ─────────────────────────────────────────────────────────────
@@ -116,7 +116,8 @@ export type SubagentRole =
116
116
  | "researcher"
117
117
  | "coder"
118
118
  | "planner"
119
- | "investigator";
119
+ | "investigator"
120
+ | "advisor";
120
121
 
121
122
  export interface SubagentRoleConfig {
122
123
  /**
@@ -198,4 +199,10 @@ export const SUBAGENT_ROLE_REGISTRY: Record<SubagentRole, SubagentRoleConfig> =
198
199
  "If you approach context limits, stop investigating and produce the report from what you have — a partial report delivered is worth more than a complete investigation lost.",
199
200
  ].join(" "),
200
201
  },
202
+ advisor: {
203
+ allowedTools: [],
204
+ skillIds: [],
205
+ systemPromptPreamble:
206
+ "You are a read-only senior advisor consulted for a one-shot strategic review. Read the inherited conversation, then return focused, high-leverage guidance in a single response. You have no tools — you cannot search, read files, or run commands — so reason from the context you were given.",
207
+ },
201
208
  };
@@ -1,3 +1,4 @@
1
+ import { isPluginDisabled } from "../plugins/disabled-state.js";
1
2
  import { getLogger } from "../util/logger.js";
2
3
  import { coreAppProxyTools } from "./apps/definitions.js";
3
4
  import { registerAppTools } from "./apps/registry.js";
@@ -556,9 +557,15 @@ export function getMcpToolDefinitions(): Tool[] {
556
557
  * {@link getMcpToolDefinitions} so a plugin install behaves like `mcp reload`.
557
558
  */
558
559
  export function getPluginToolDefinitions(): Tool[] {
559
- return Array.from(tools.values()).filter(
560
- (t) => ownersByName.get(t.name)?.kind === "plugin",
561
- );
560
+ return Array.from(tools.values()).filter((t) => {
561
+ const owner = ownersByName.get(t.name);
562
+ if (owner?.kind !== "plugin") return false;
563
+ // Filter out tools contributed by disabled plugins at read time so
564
+ // `assistant plugins disable <name>` takes effect on the next turn
565
+ // without a daemon restart. Mirrors the `.disabled` sentinel filtering
566
+ // in `getHooksFor` (plugins/registry.ts).
567
+ return !isPluginDisabled(owner.id);
568
+ });
562
569
  }
563
570
 
564
571
  /**
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A progress-aware deadline for the synchronous advisor consult.
3
+ *
4
+ * A reasoning advisor profile spends most of its window *thinking*, streaming
5
+ * reasoning tokens the whole time. A fixed wall-clock ceiling would cut it off
6
+ * mid-thought, so this aborts only after the consult goes `idleMs` without any
7
+ * streamed token (thinking or text) — i.e. genuine silence — with an absolute
8
+ * `maxMs` backstop so a runaway or looping stream can't block the parent
9
+ * forever.
10
+ *
11
+ * Usage: combine `signal` with the caller's own signal, call `recordProgress()`
12
+ * on every streamed chunk, and `dispose()` once the consult settles.
13
+ */
14
+ export interface ConsultDeadline {
15
+ /** Aborts when the idle window lapses or the absolute max elapses. */
16
+ readonly signal: AbortSignal;
17
+ /** Reset the idle window — call on every streamed chunk (thinking or text). */
18
+ recordProgress(): void;
19
+ /** Clear both timers; call once the consult settles (success or failure). */
20
+ dispose(): void;
21
+ }
22
+
23
+ export function createConsultDeadline(opts: {
24
+ idleMs: number;
25
+ maxMs: number;
26
+ }): ConsultDeadline {
27
+ const controller = new AbortController();
28
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
29
+
30
+ const recordProgress = (): void => {
31
+ // Once aborted, don't re-arm — the consult is already being torn down.
32
+ if (controller.signal.aborted) return;
33
+ if (idleTimer) clearTimeout(idleTimer);
34
+ idleTimer = setTimeout(() => controller.abort(), opts.idleMs);
35
+ };
36
+
37
+ // Absolute backstop, independent of streaming progress.
38
+ const maxTimer = setTimeout(() => controller.abort(), opts.maxMs);
39
+
40
+ // Arm the idle window immediately so time-to-first-token is bounded too.
41
+ recordProgress();
42
+
43
+ const dispose = (): void => {
44
+ if (idleTimer) clearTimeout(idleTimer);
45
+ clearTimeout(maxTimer);
46
+ };
47
+
48
+ return { signal: controller.signal, recordProgress, dispose };
49
+ }