@vellumai/assistant 0.9.0-dev.202606182147.d5eebd3 → 0.9.0-dev.202606182256.609733d

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 (37) hide show
  1. package/openapi.yaml +46 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/channel-inbound-disk-pressure.test.ts +5 -13
  4. package/src/__tests__/config-schema.test.ts +34 -0
  5. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  6. package/src/__tests__/reaction-persistence.test.ts +110 -1
  7. package/src/api/events/tool-result.ts +6 -0
  8. package/src/config/call-site-defaults.ts +3 -0
  9. package/src/config/llm-resolver.ts +3 -0
  10. package/src/config/schemas/call-site-catalog.ts +7 -0
  11. package/src/config/schemas/llm.ts +23 -0
  12. package/src/config/schemas/services.ts +18 -0
  13. package/src/config/seed-inference-profiles.ts +20 -0
  14. package/src/daemon/message-types/web-activity.ts +7 -1
  15. package/src/messaging/providers/slack/send.test.ts +114 -2
  16. package/src/messaging/providers/slack/send.ts +30 -7
  17. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +56 -0
  18. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +5 -3
  19. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +6 -3
  20. package/src/plugins/defaults/advisor/advisor-gate.ts +29 -0
  21. package/src/plugins/defaults/advisor/config.ts +10 -11
  22. package/src/plugins/defaults/advisor/consult.ts +25 -6
  23. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +7 -1
  24. package/src/plugins/defaults/advisor/tools/advisor.ts +11 -0
  25. package/src/providers/__tests__/provider-env-vars.test.ts +6 -0
  26. package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
  27. package/src/providers/fetch-provider-catalog.ts +85 -0
  28. package/src/providers/search-provider-catalog.ts +10 -0
  29. package/src/runtime/routes/conversation-query-routes.ts +18 -0
  30. package/src/runtime/routes/inbound-message-handler.ts +33 -234
  31. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +358 -0
  32. package/src/security/__tests__/provider-key-env-fallback.test.ts +6 -0
  33. package/src/security/secret-patterns.ts +3 -0
  34. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +360 -0
  35. package/src/tools/network/__tests__/web-search.test.ts +143 -0
  36. package/src/tools/network/web-fetch.ts +372 -1
  37. package/src/tools/network/web-search.ts +213 -10
package/openapi.yaml CHANGED
@@ -16239,6 +16239,7 @@ paths:
16239
16239
  - brave
16240
16240
  - perplexity
16241
16241
  - tavily
16242
+ - firecrawl
16242
16243
  resultCount:
16243
16244
  type: number
16244
16245
  durationMs:
@@ -16286,6 +16287,11 @@ paths:
16286
16287
  type: string
16287
16288
  finalUrl:
16288
16289
  type: string
16290
+ provider:
16291
+ type: string
16292
+ enum:
16293
+ - default
16294
+ - firecrawl
16289
16295
  status:
16290
16296
  type: number
16291
16297
  contentType:
@@ -16621,6 +16627,7 @@ paths:
16621
16627
  - brave
16622
16628
  - perplexity
16623
16629
  - tavily
16630
+ - firecrawl
16624
16631
  resultCount:
16625
16632
  type: number
16626
16633
  durationMs:
@@ -16668,6 +16675,11 @@ paths:
16668
16675
  type: string
16669
16676
  finalUrl:
16670
16677
  type: string
16678
+ provider:
16679
+ type: string
16680
+ enum:
16681
+ - default
16682
+ - firecrawl
16671
16683
  status:
16672
16684
  type: number
16673
16685
  contentType:
@@ -28733,6 +28745,10 @@ components:
28733
28745
  anyOf:
28734
28746
  - type: string
28735
28747
  - type: "null"
28748
+ advisorProfile:
28749
+ anyOf:
28750
+ - type: string
28751
+ - type: "null"
28736
28752
  callSites:
28737
28753
  type: object
28738
28754
  propertyNames:
@@ -28768,6 +28784,16 @@ components:
28768
28784
  type: string
28769
28785
  additionalProperties: {}
28770
28786
  - type: "null"
28787
+ web-fetch:
28788
+ anyOf:
28789
+ - type: object
28790
+ properties:
28791
+ mode:
28792
+ $ref: "#/components/schemas/ServiceMode"
28793
+ provider:
28794
+ type: string
28795
+ additionalProperties: {}
28796
+ - type: "null"
28771
28797
  image-generation:
28772
28798
  anyOf:
28773
28799
  - type: object
@@ -28963,6 +28989,12 @@ components:
28963
28989
  - $ref: "#/components/schemas/ProfileStatus"
28964
28990
  - type: "null"
28965
28991
  - type: "null"
28992
+ advisorEnabled:
28993
+ anyOf:
28994
+ - anyOf:
28995
+ - type: boolean
28996
+ - type: "null"
28997
+ - type: "null"
28966
28998
  mix:
28967
28999
  anyOf:
28968
29000
  - minItems: 2
@@ -29354,6 +29386,8 @@ components:
29354
29386
  type: string
29355
29387
  activeProfile:
29356
29388
  type: string
29389
+ advisorProfile:
29390
+ type: string
29357
29391
  callSites:
29358
29392
  type: object
29359
29393
  propertyNames:
@@ -29504,6 +29538,14 @@ components:
29504
29538
  provider:
29505
29539
  type: string
29506
29540
  additionalProperties: {}
29541
+ web-fetch:
29542
+ type: object
29543
+ properties:
29544
+ mode:
29545
+ $ref: "#/components/schemas/ServiceMode"
29546
+ provider:
29547
+ type: string
29548
+ additionalProperties: {}
29507
29549
  image-generation:
29508
29550
  type: object
29509
29551
  properties:
@@ -29653,6 +29695,10 @@ components:
29653
29695
  anyOf:
29654
29696
  - $ref: "#/components/schemas/ProfileStatus"
29655
29697
  - type: "null"
29698
+ advisorEnabled:
29699
+ anyOf:
29700
+ - type: boolean
29701
+ - type: "null"
29656
29702
  mix:
29657
29703
  minItems: 2
29658
29704
  type: array
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606182147.d5eebd3",
3
+ "version": "0.9.0-dev.202606182256.609733d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -236,7 +236,7 @@ describe("channel inbound disk pressure gate", () => {
236
236
  expect(db.select().from(messages).all()).toHaveLength(0);
237
237
  });
238
238
 
239
- test("blocks non-guardian Slack reactions before persistence while locked", async () => {
239
+ test("blocks non-guardian Slack reactions silently (no reply) before persistence while locked", async () => {
240
240
  upsertContact({
241
241
  displayName: "Example Slack User",
242
242
  channels: [
@@ -274,18 +274,10 @@ describe("channel inbound disk pressure gate", () => {
274
274
  reason: "trusted-contact",
275
275
  });
276
276
  expect(processMessage).not.toHaveBeenCalled();
277
- expect(deliverChannelReplyMock.mock.calls).toEqual([
278
- [
279
- "https://gateway.test/deliver/slack",
280
- {
281
- chatId: "slack-channel-1",
282
- text: expectedRemoteBlockReply,
283
- assistantId: "self",
284
- ephemeral: true,
285
- user: "slack-user-1",
286
- },
287
- ],
288
- ]);
277
+ // Reactions are blocked silently during disk pressure: a passive signal
278
+ // has nothing to "try again", so no block reply is delivered (unlike the
279
+ // message path, which does reply).
280
+ expect(deliverChannelReplyMock).not.toHaveBeenCalled();
289
281
 
290
282
  const db = getDb();
291
283
  const event = db
@@ -145,6 +145,40 @@ describe("AssistantConfigSchema", () => {
145
145
  expect(result.services["web-search"].mode).toBe("your-own");
146
146
  });
147
147
 
148
+ test("accepts Firecrawl as a web search provider", () => {
149
+ const result = AssistantConfigSchema.parse({
150
+ services: {
151
+ "web-search": { mode: "your-own", provider: "firecrawl" },
152
+ },
153
+ });
154
+
155
+ expect(result.services["web-search"].provider).toBe("firecrawl");
156
+ expect(result.services["web-search"].mode).toBe("your-own");
157
+ });
158
+
159
+ test("defaults the web-fetch provider to the built-in fetcher", () => {
160
+ const result = AssistantConfigSchema.parse({});
161
+ expect(result.services["web-fetch"].provider).toBe("default");
162
+ });
163
+
164
+ test("accepts Firecrawl as a web fetch provider", () => {
165
+ const result = AssistantConfigSchema.parse({
166
+ services: {
167
+ "web-fetch": { mode: "your-own", provider: "firecrawl" },
168
+ },
169
+ });
170
+
171
+ expect(result.services["web-fetch"].provider).toBe("firecrawl");
172
+ });
173
+
174
+ test("rejects an unknown web-fetch provider", () => {
175
+ expect(() =>
176
+ AssistantConfigSchema.parse({
177
+ services: { "web-fetch": { provider: "nope" } },
178
+ }),
179
+ ).toThrow();
180
+ });
181
+
148
182
  test("accepts valid complete config", () => {
149
183
  const input = {
150
184
  llm: {
@@ -158,6 +158,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
158
158
  "credential-execution/prompted-credential.ts", // shared prompt-action persistence (stores secret via setSecureKeyAsync)
159
159
  "tools/credentials/broker.ts", // brokered credential access
160
160
  "tools/network/web-search.ts", // web search API key lookup
161
+ "tools/network/web-fetch.ts", // web fetch provider (Firecrawl) API key lookup
161
162
  "daemon/handlers/config-telegram.ts", // Telegram bot token management
162
163
  "daemon/handlers/config-vercel.ts", // Vercel API token management
163
164
  "runtime/routes/integrations/twilio.ts", // Twilio credential management (HTTP control-plane)
@@ -59,7 +59,7 @@ import * as pendingInteractions from "../runtime/pending-interactions.js";
59
59
  import {
60
60
  isSlackReactionEvent,
61
61
  parseSlackReactionCallbackData,
62
- } from "../runtime/routes/inbound-message-handler.js";
62
+ } from "../runtime/routes/inbound-stages/reaction-intercept.js";
63
63
  import { handleChannelInbound } from "./helpers/channel-test-adapter.js";
64
64
  import { createGuardianBinding } from "./helpers/create-guardian-binding.js";
65
65
 
@@ -575,3 +575,112 @@ describe("guardian approval-by-reaction integration via handleChannelInbound", (
575
575
  expect(reactionRows.length).toBe(0);
576
576
  });
577
577
  });
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Reaction access-control regression (LUM-2489)
581
+ // ---------------------------------------------------------------------------
582
+ //
583
+ // A reaction is a passive signal, not an access attempt. Because reactions are
584
+ // dispatched before the ingress pipeline, an unknown user's 👍 must never run
585
+ // ACL (no trusted-contact verification handshake), create a conversation, or
586
+ // write a binding — it is dropped as channel noise. A known contact's reaction
587
+ // is recorded; neither triggers a verification challenge.
588
+
589
+ describe("reaction access control (no verification handshake)", () => {
590
+ const STRANGER_USER_ID = "U_REACTION_STRANGER";
591
+ const CONTACT_USER_ID = "U_REACTION_CONTACT";
592
+ // Guardian's approval channel is a DM, distinct from the public channel the
593
+ // reaction lands in (mirroring production). Reusing the public channel id
594
+ // would let a reactor match the guardian's channel via findContactChannel's
595
+ // externalChatId fallback and mask the bug.
596
+ const GUARDIAN_DM_CHAT = "D_GUARDIAN_DM";
597
+
598
+ function tableCount(table: string): number {
599
+ return (
600
+ getDb().$client.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as {
601
+ n: number;
602
+ }
603
+ ).n;
604
+ }
605
+
606
+ beforeEach(() => {
607
+ resetState();
608
+ getDb().run("DELETE FROM channel_verification_sessions");
609
+ // The assistant has a guardian (as in production); the reactors below are
610
+ // different users.
611
+ createGuardianBinding({
612
+ channel: "slack",
613
+ guardianExternalUserId: GUARDIAN_USER_ID,
614
+ guardianDeliveryChatId: GUARDIAN_DM_CHAT,
615
+ guardianPrincipalId: GUARDIAN_USER_ID,
616
+ });
617
+ msgCounter = 0;
618
+ });
619
+
620
+ test("stranger's reaction is dropped — no challenge, session, conversation, or row", async () => {
621
+ let agentDispatched = false;
622
+ const processMessage = async (): Promise<{ messageId: string }> => {
623
+ agentDispatched = true;
624
+ return { messageId: "should-not-be-called" };
625
+ };
626
+
627
+ const req = buildReactionRequest("reaction:thumbsup", {
628
+ actorExternalId: STRANGER_USER_ID,
629
+ actorDisplayName: "Outside Reactor",
630
+ actorUsername: "outsider",
631
+ });
632
+ const resp = await handleChannelInbound(
633
+ req,
634
+ processMessage,
635
+ TEST_BEARER_TOKEN,
636
+ );
637
+ const json = (await resp.json()) as Record<string, unknown>;
638
+
639
+ // Accepted as a passive signal — never denied or turned into a challenge.
640
+ expect(json.accepted).toBe(true);
641
+ expect(json.denied).not.toBe(true);
642
+ expect(json.reason).not.toBe("verification_challenge_sent");
643
+ expect(json.verificationSessionId).toBeUndefined();
644
+ expect(agentDispatched).toBe(false);
645
+
646
+ // No verification handshake, and no side effects: a dropped reaction leaves
647
+ // no transcript row, no conversation, and no binding.
648
+ expect(tableCount("channel_verification_sessions")).toBe(0);
649
+ expect(readPersistedMessages().length).toBe(0);
650
+ expect(tableCount("conversations")).toBe(0);
651
+ expect(tableCount("external_conversation_bindings")).toBe(0);
652
+ });
653
+
654
+ test("known contact's reaction is recorded — no challenge", async () => {
655
+ // A pending contact classifies as `unverified_contact` — a known tier, so
656
+ // its reactions are recorded. On a real message it would be re-challenged,
657
+ // but a reaction must not trigger that.
658
+ upsertContactChannel({
659
+ sourceChannel: "slack",
660
+ externalUserId: CONTACT_USER_ID,
661
+ externalChatId: SLACK_CHANNEL_ID,
662
+ status: "pending",
663
+ policy: "allow",
664
+ displayName: "Pending Contact",
665
+ });
666
+
667
+ const req = buildReactionRequest("reaction:tada", {
668
+ actorExternalId: CONTACT_USER_ID,
669
+ actorDisplayName: "Pending Contact",
670
+ actorUsername: "pending_contact",
671
+ });
672
+ const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
673
+ const json = (await resp.json()) as Record<string, unknown>;
674
+
675
+ expect(json.denied).not.toBe(true);
676
+ expect(json.reason).not.toBe("verification_challenge_sent");
677
+ expect(tableCount("channel_verification_sessions")).toBe(0);
678
+
679
+ const rows = readPersistedMessages();
680
+ expect(rows.length).toBe(1);
681
+ const envelope = JSON.parse(rows[0].metadata!) as Record<string, unknown>;
682
+ const slackMeta = readSlackMetadata(envelope.slackMeta as string);
683
+ expect(slackMeta?.eventKind).toBe("reaction");
684
+ expect(slackMeta?.reaction?.emoji).toBe("tada");
685
+ });
686
+ });
@@ -47,10 +47,15 @@ export const WebSearchProviderIdSchema = z.enum([
47
47
  "brave",
48
48
  "perplexity",
49
49
  "tavily",
50
+ "firecrawl",
50
51
  ]);
51
52
 
52
53
  export type WebSearchProviderId = z.infer<typeof WebSearchProviderIdSchema>;
53
54
 
55
+ export const WebFetchProviderIdSchema = z.enum(["default", "firecrawl"]);
56
+
57
+ export type WebFetchProviderId = z.infer<typeof WebFetchProviderIdSchema>;
58
+
54
59
  export const WebSearchResultItemSchema = z.object({
55
60
  rank: z.number(),
56
61
  title: z.string(),
@@ -78,6 +83,7 @@ export type WebSearchMetadata = z.infer<typeof WebSearchMetadataSchema>;
78
83
  export const WebFetchMetadataSchema = z.object({
79
84
  url: z.string(),
80
85
  finalUrl: z.string(),
86
+ provider: WebFetchProviderIdSchema.optional(),
81
87
  status: z.number(),
82
88
  contentType: z.string().optional(),
83
89
  byteCount: z.number(),
@@ -69,6 +69,9 @@ export const CALL_SITE_DEFAULTS: Record<LLMCallSite, CallSiteDefaultConfig> = {
69
69
  meetConsentMonitor: { profile: "cost-optimized" },
70
70
  meetChatOpportunity: { profile: "cost-optimized" },
71
71
  inference: { profile: "cost-optimized" },
72
+ // The advisor consults the strongest managed profile by default; a workspace
73
+ // overrides this via `llm.advisorProfile` (which floats above this).
74
+ advisor: { profile: "quality-optimized" },
72
75
 
73
76
  heartbeatAgent: {
74
77
  profile: "cost-optimized",
@@ -366,6 +366,9 @@ function profileConfigFragment(profile: ProfileEntry): Mergeable {
366
366
  // lower-precedence (e.g. active) profile into one that merely inherited it.
367
367
  // `RetryProvider` resolves it from the applied profile, not the merge.
368
368
  logitBias: _logitBias,
369
+ // Per-profile advisor toggle is profile identity, not inheritable model
370
+ // config — strip it so it can't leak into the merged `LLMConfigBase`.
371
+ advisorEnabled: _advisorEnabled,
369
372
  ...config
370
373
  } = profile;
371
374
  return config as Mergeable;
@@ -300,6 +300,13 @@ const CATALOG_RECORD: CatalogRecord = {
300
300
  description: "General-purpose LLM inference call site for skill use.",
301
301
  domain: "skills",
302
302
  },
303
+ advisor: {
304
+ id: "advisor",
305
+ displayName: "Advisor",
306
+ description:
307
+ "Stronger reviewer model consulted mid-task to shape or pressure-test the plan.",
308
+ domain: "skills",
309
+ },
303
310
  homeGreeting: {
304
311
  id: "homeGreeting",
305
312
  displayName: "Home Greeting",
@@ -78,6 +78,7 @@ export const LLMCallSiteEnum = z.enum([
78
78
  "meetConsentMonitor",
79
79
  "meetChatOpportunity",
80
80
  "inference",
81
+ "advisor",
81
82
  "trustRuleSuggestion",
82
83
  "homeGreeting",
83
84
  "homeSuggestedPrompts",
@@ -432,6 +433,13 @@ export const ProfileEntry = LLMConfigFragment.extend({
432
433
  * #30362 even though the schema didn't accept it until now.
433
434
  */
434
435
  status: ProfileStatusSchema.nullable().optional(),
436
+ /**
437
+ * Whether the advisor is active while this profile is the chat profile.
438
+ * Absent/null means enabled (default on); only an explicit `false` disables
439
+ * it. `.nullable()` matches `status`/`label` so the PUT route's "send null
440
+ * to clear" sentinel resets it back to the default-on state.
441
+ */
442
+ advisorEnabled: z.boolean().nullable().optional(),
435
443
  /**
436
444
  * When present, this profile is a "mix": it carries no model config and
437
445
  * instead references a weighted list of standard profiles. The resolver
@@ -475,6 +483,11 @@ export const LLMSchema = z
475
483
  // schema level, so `LLMSchema.parse({})` yields an empty map.
476
484
  callSites: z.partialRecord(LLMCallSiteEnum, LLMCallSiteConfig).default({}),
477
485
  activeProfile: z.string().min(1).optional(),
486
+ // The profile the advisor consults (chosen under Models & Services). It is
487
+ // excluded from the chat-profile pickers so it can't be selected as the
488
+ // assistant's chat model. Absent falls back to the `advisor` call-site
489
+ // default (`quality-optimized`).
490
+ advisorProfile: z.string().min(1).optional(),
478
491
  // TTL bounds for inference profile sessions. `defaultTtlSeconds` is read by
479
492
  // the CLI to apply when `--ttl` is omitted; the daemon handler itself only
480
493
  // reads `maxTtlSeconds` (to clamp caller-supplied values).
@@ -508,6 +521,16 @@ export const LLMSchema = z
508
521
  message: `Profile "${config.activeProfile}" referenced by llm.activeProfile is not defined in llm.profiles`,
509
522
  });
510
523
  }
524
+ if (
525
+ config.advisorProfile != null &&
526
+ !profileNames.has(config.advisorProfile)
527
+ ) {
528
+ ctx.addIssue({
529
+ code: "custom",
530
+ path: ["advisorProfile"],
531
+ message: `Profile "${config.advisorProfile}" referenced by llm.advisorProfile is not defined in llm.profiles`,
532
+ });
533
+ }
511
534
 
512
535
  // --- Mix profile validation --------------------------------------------
513
536
  // Config keys a mix profile must NOT also set (a mix only references other
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import { DEFAULT_IMAGE_MODEL } from "../../media/image-models.js";
4
+ import { FETCH_PROVIDER_IDS } from "../../providers/fetch-provider-catalog.js";
4
5
  import { SEARCH_PROVIDER_IDS } from "../../providers/search-provider-catalog.js";
5
6
  import { SttServiceSchema } from "./stt.js";
6
7
  import { TtsServiceSchema } from "./tts.js";
@@ -28,6 +29,13 @@ const VALID_IMAGE_GEN_PROVIDERS = ["gemini", "openai"] as const;
28
29
  */
29
30
  const VALID_WEB_SEARCH_PROVIDERS = SEARCH_PROVIDER_IDS;
30
31
 
32
+ /**
33
+ * Derived from `FETCH_PROVIDER_CATALOG`. Adding a new web-fetch provider
34
+ * to the catalog automatically extends the config-schema enum — no edit
35
+ * here required.
36
+ */
37
+ const VALID_WEB_FETCH_PROVIDERS = FETCH_PROVIDER_IDS;
38
+
31
39
  const BaseServiceSchema = z.object({
32
40
  mode: ServiceModeSchema.default("your-own"),
33
41
  });
@@ -58,6 +66,15 @@ const WebSearchServiceSchema = BaseServiceSchema.extend({
58
66
  .default("inference-provider-native"),
59
67
  });
60
68
 
69
+ const WebFetchServiceSchema = BaseServiceSchema.extend({
70
+ // Provider that backs the `web_fetch` tool. `default` is the daemon's
71
+ // built-in HTTP fetch + extract path (no key). BYOK providers (e.g.
72
+ // `firecrawl`) scrape via their hosted API and reuse the same stored key as
73
+ // their web-search counterpart. The `mode` field is inherited from
74
+ // `BaseServiceSchema` for symmetry; web-fetch has no managed proxy today.
75
+ provider: z.enum(VALID_WEB_FETCH_PROVIDERS).default("default"),
76
+ });
77
+
61
78
  const GoogleOAuthServiceSchema = BaseServiceSchema.extend({
62
79
  mode: ServiceModeSchema.default("managed"),
63
80
  });
@@ -142,6 +159,7 @@ export const ServicesSchema = z.object({
142
159
  "web-search": WebSearchServiceSchema.default(
143
160
  WebSearchServiceSchema.parse({}),
144
161
  ),
162
+ "web-fetch": WebFetchServiceSchema.default(WebFetchServiceSchema.parse({})),
145
163
  stt: SttServiceSchema.default({
146
164
  mode: "your-own" as const,
147
165
  provider: "deepgram" as const,
@@ -61,6 +61,9 @@ const MANAGED_PROFILE_TEMPLATES: Record<string, ManagedProfileTemplate> = {
61
61
  effort: "high",
62
62
  thinking: { enabled: true, streamThinking: true },
63
63
  contextWindow: { maxInputTokens: DEFAULT_CONTEXT_WINDOW_MAX_INPUT_TOKENS },
64
+ // This is the advisor's own (strongest) profile: when it's also the chat
65
+ // profile there's nothing stronger to consult, so the advisor defaults off.
66
+ advisorEnabled: false,
64
67
  },
65
68
  "cost-optimized": {
66
69
  intent: "latency-optimized",
@@ -286,6 +289,12 @@ export function seedInferenceProfiles(
286
289
  : previous.label;
287
290
  }
288
291
  if ("status" in previous) next.status = previous.status;
292
+ // The per-profile advisor toggle is a user override — preserve it across
293
+ // reseeds so a user's choice survives reboots (the template value only
294
+ // seeds the initial default, e.g. off for quality-optimized).
295
+ if ("advisorEnabled" in previous) {
296
+ next.advisorEnabled = previous.advisorEnabled;
297
+ }
289
298
  }
290
299
  profiles[name] = next as ProfileEntry;
291
300
  }
@@ -369,6 +378,17 @@ export function seedInferenceProfiles(
369
378
  }
370
379
  }
371
380
 
381
+ // Advisor profile: default to the strongest managed profile when unset, so
382
+ // the advisor consults `quality-optimized` out of the box. Guarded on
383
+ // existence so it never names a missing profile (superRefine rejects that);
384
+ // off-platform/BYOK installs can repoint it at one of their own profiles.
385
+ if (
386
+ readString(llm.advisorProfile) === undefined &&
387
+ readObject(profiles["quality-optimized"]) !== null
388
+ ) {
389
+ llm.advisorProfile = "quality-optimized";
390
+ }
391
+
372
392
  // Profile ordering — ensure all seeded profiles appear in the order array.
373
393
  // "auto" is prepended so it appears first in the picker.
374
394
  const profileOrder = Array.isArray(llm.profileOrder)
@@ -9,7 +9,11 @@ export type WebSearchProviderId =
9
9
  | "anthropic-native"
10
10
  | "brave"
11
11
  | "perplexity"
12
- | "tavily";
12
+ | "tavily"
13
+ | "firecrawl";
14
+
15
+ /** Provider that backed a `web_fetch` call. `default` is the built-in fetcher. */
16
+ export type WebFetchProviderId = "default" | "firecrawl";
13
17
 
14
18
  export interface WebSearchResultItem {
15
19
  rank: number; // 1-indexed
@@ -35,6 +39,8 @@ export interface WebSearchMetadata {
35
39
  export interface WebFetchMetadata {
36
40
  url: string;
37
41
  finalUrl: string;
42
+ /** Provider that served the fetch. Defaults to the built-in fetcher. */
43
+ provider?: WebFetchProviderId;
38
44
  status: number;
39
45
  contentType?: string;
40
46
  byteCount: number;
@@ -20,12 +20,19 @@ mock.module("./api.js", () => ({
20
20
  callSlackApiForm: async () => ({}),
21
21
  completeSlackUpload: async () => {},
22
22
  SlackApiError: class SlackApiError extends Error {
23
- slackError?: string;
23
+ readonly slackError: string | undefined;
24
+
25
+ constructor(slackError: string | undefined) {
26
+ super(slackError ?? "unknown");
27
+ this.slackError = slackError;
28
+ }
24
29
  },
25
30
  uploadToSlackUrl: async () => {},
26
31
  }));
27
32
 
28
- import { sendSlackAssistantThreadStatus } from "./send.js";
33
+ const { SlackApiError } = await import("./api.js");
34
+ const { sendSlackAssistantThreadStatus, sendSlackReply } =
35
+ await import("./send.js");
29
36
 
30
37
  describe("sendSlackAssistantThreadStatus", () => {
31
38
  beforeEach(() => {
@@ -75,3 +82,108 @@ describe("sendSlackAssistantThreadStatus", () => {
75
82
  });
76
83
  });
77
84
  });
85
+
86
+ describe("sendSlackReply update path", () => {
87
+ const messageTs = "1700000000.000100";
88
+ const blocks = [
89
+ { type: "section", text: { type: "mrkdwn", text: "Final reply" } },
90
+ ];
91
+
92
+ beforeEach(() => {
93
+ callSlackApiMock.mockReset();
94
+ callSlackApiMock.mockImplementation(async () => ({ ok: true }));
95
+ });
96
+
97
+ test("retries chat.update without blocks on invalid_blocks instead of posting a duplicate", async () => {
98
+ callSlackApiMock
99
+ .mockImplementationOnce(async () => {
100
+ throw new SlackApiError("invalid_blocks");
101
+ })
102
+ .mockImplementationOnce(async () => ({ ok: true, ts: messageTs }));
103
+
104
+ const result = await sendSlackReply("C123", "Final reply", {
105
+ messageTs,
106
+ threadTs: "1700000000.000001",
107
+ blocks,
108
+ });
109
+
110
+ expect(result).toEqual({ ok: true, ts: messageTs });
111
+ // Two chat.update calls (with then without blocks); never chat.postMessage,
112
+ // so the placeholder is edited in place rather than duplicated.
113
+ expect(callSlackApiMock).toHaveBeenCalledTimes(2);
114
+ expect(callSlackApiMock).toHaveBeenNthCalledWith(1, "chat.update", {
115
+ channel: "C123",
116
+ text: "Final reply",
117
+ ts: messageTs,
118
+ blocks,
119
+ });
120
+ expect(callSlackApiMock).toHaveBeenNthCalledWith(2, "chat.update", {
121
+ channel: "C123",
122
+ text: "Final reply",
123
+ ts: messageTs,
124
+ });
125
+ const postMessageCalls = callSlackApiMock.mock.calls.filter(
126
+ (call) => call[0] === "chat.postMessage",
127
+ );
128
+ expect(postMessageCalls).toHaveLength(0);
129
+ });
130
+
131
+ test("falls back to chat.postMessage only after the no-block update retry also fails", async () => {
132
+ callSlackApiMock
133
+ .mockImplementationOnce(async () => {
134
+ throw new SlackApiError("invalid_blocks");
135
+ })
136
+ .mockImplementationOnce(async () => {
137
+ throw new SlackApiError("message_not_found");
138
+ })
139
+ .mockImplementationOnce(async () => ({
140
+ ok: true,
141
+ ts: "1700000000.000200",
142
+ }));
143
+
144
+ const result = await sendSlackReply("C123", "Final reply", {
145
+ messageTs,
146
+ threadTs: "1700000000.000001",
147
+ blocks,
148
+ });
149
+
150
+ expect(result).toEqual({ ok: true, ts: "1700000000.000200" });
151
+ expect(callSlackApiMock).toHaveBeenCalledTimes(3);
152
+ expect(callSlackApiMock.mock.calls[0]?.[0]).toBe("chat.update");
153
+ expect(callSlackApiMock.mock.calls[1]?.[0]).toBe("chat.update");
154
+ // The post fallback drops the rejected blocks.
155
+ expect(callSlackApiMock).toHaveBeenNthCalledWith(3, "chat.postMessage", {
156
+ channel: "C123",
157
+ text: "Final reply",
158
+ thread_ts: "1700000000.000001",
159
+ });
160
+ });
161
+
162
+ test("non-invalid_blocks update failure still falls back to chat.postMessage", async () => {
163
+ callSlackApiMock
164
+ .mockImplementationOnce(async () => {
165
+ throw new SlackApiError("internal_error");
166
+ })
167
+ .mockImplementationOnce(async () => ({
168
+ ok: true,
169
+ ts: "1700000000.000200",
170
+ }));
171
+
172
+ const result = await sendSlackReply("C123", "Final reply", {
173
+ messageTs,
174
+ threadTs: "1700000000.000001",
175
+ blocks,
176
+ });
177
+
178
+ expect(result).toEqual({ ok: true, ts: "1700000000.000200" });
179
+ // One failed chat.update, then a single chat.postMessage (no extra retry).
180
+ expect(callSlackApiMock).toHaveBeenCalledTimes(2);
181
+ expect(callSlackApiMock.mock.calls[0]?.[0]).toBe("chat.update");
182
+ expect(callSlackApiMock).toHaveBeenNthCalledWith(2, "chat.postMessage", {
183
+ channel: "C123",
184
+ text: "Final reply",
185
+ thread_ts: "1700000000.000001",
186
+ blocks,
187
+ });
188
+ });
189
+ });