@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.
- package/openapi.yaml +46 -0
- package/package.json +1 -1
- package/src/__tests__/channel-inbound-disk-pressure.test.ts +5 -13
- package/src/__tests__/config-schema.test.ts +34 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/reaction-persistence.test.ts +110 -1
- package/src/api/events/tool-result.ts +6 -0
- package/src/config/call-site-defaults.ts +3 -0
- package/src/config/llm-resolver.ts +3 -0
- package/src/config/schemas/call-site-catalog.ts +7 -0
- package/src/config/schemas/llm.ts +23 -0
- package/src/config/schemas/services.ts +18 -0
- package/src/config/seed-inference-profiles.ts +20 -0
- package/src/daemon/message-types/web-activity.ts +7 -1
- package/src/messaging/providers/slack/send.test.ts +114 -2
- package/src/messaging/providers/slack/send.ts +30 -7
- package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +56 -0
- package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +5 -3
- package/src/plugins/defaults/advisor/__tests__/consult.test.ts +6 -3
- package/src/plugins/defaults/advisor/advisor-gate.ts +29 -0
- package/src/plugins/defaults/advisor/config.ts +10 -11
- package/src/plugins/defaults/advisor/consult.ts +25 -6
- package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +7 -1
- package/src/plugins/defaults/advisor/tools/advisor.ts +11 -0
- package/src/providers/__tests__/provider-env-vars.test.ts +6 -0
- package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
- package/src/providers/fetch-provider-catalog.ts +85 -0
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/runtime/routes/conversation-query-routes.ts +18 -0
- package/src/runtime/routes/inbound-message-handler.ts +33 -234
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +358 -0
- package/src/security/__tests__/provider-key-env-fallback.test.ts +6 -0
- package/src/security/secret-patterns.ts +3 -0
- package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +360 -0
- package/src/tools/network/__tests__/web-search.test.ts +143 -0
- package/src/tools/network/web-fetch.ts +372 -1
- 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
|
@@ -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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|