@vellumai/assistant 0.11.1-dev.202608041245.b3413fb → 0.11.1-dev.202608041551.40f0fbf
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 +8 -3
- package/package.json +1 -1
- package/src/__tests__/conversation-queue.test.ts +129 -1
- package/src/__tests__/llm-resolver-override-or-default.test.ts +79 -0
- package/src/__tests__/llm-resolver.test.ts +2 -0
- package/src/__tests__/llm-schema.test.ts +36 -0
- package/src/cli/commands/mcp.help.ts +4 -2
- package/src/config/__tests__/sync-gated-profiles.test.ts +25 -0
- package/src/config/llm-resolver.ts +44 -9
- package/src/config/schemas/llm.ts +20 -0
- package/src/config/sync-gated-profiles.ts +11 -0
- package/src/daemon/__tests__/abort-null-controller.test.ts +141 -5
- package/src/daemon/conversation-lifecycle.ts +119 -17
- package/src/daemon/conversation-process.ts +11 -5
- package/src/daemon/conversation.ts +12 -0
- package/src/runtime/AGENTS.md +2 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +5 -0
- package/src/runtime/routes/inference-profiles-routes.ts +11 -2
- package/src/runtime/routes/secret-routes.ts +8 -2
- package/src/util/abort-reasons.ts +16 -0
package/openapi.yaml
CHANGED
|
@@ -14637,7 +14637,9 @@ paths:
|
|
|
14637
14637
|
"404":
|
|
14638
14638
|
description: Profile not found
|
|
14639
14639
|
"409":
|
|
14640
|
-
description:
|
|
14640
|
+
description:
|
|
14641
|
+
Profile is still referenced by activeProfile, advisorProfile, a call site, a default-tier override, or a
|
|
14642
|
+
mix arm
|
|
14641
14643
|
get:
|
|
14642
14644
|
operationId: inference_profiles_by_name_get
|
|
14643
14645
|
summary: Get an effective inference profile
|
|
@@ -27286,10 +27288,13 @@ paths:
|
|
|
27286
27288
|
type: string
|
|
27287
27289
|
name:
|
|
27288
27290
|
type: string
|
|
27291
|
+
error:
|
|
27292
|
+
description:
|
|
27293
|
+
Why the secret was not stored (e.g. provider-side API key validation failed). Present only when success is
|
|
27294
|
+
false.
|
|
27295
|
+
type: string
|
|
27289
27296
|
required:
|
|
27290
27297
|
- success
|
|
27291
|
-
- type
|
|
27292
|
-
- name
|
|
27293
27298
|
additionalProperties: false
|
|
27294
27299
|
/v1/secrets/read:
|
|
27295
27300
|
post:
|
package/package.json
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
import type { AssistantEvent } from "../api/index.js";
|
|
13
13
|
import type { Message, ProviderResponse } from "../providers/types.js";
|
|
14
14
|
import { stampAndBuffer } from "../runtime/assistant-stream-state.js";
|
|
15
|
+
import { createAbortReason } from "../util/abort-reasons.js";
|
|
15
16
|
import { setConfig } from "./helpers/set-config.js";
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
@@ -826,7 +827,7 @@ describe("Conversation message queue", () => {
|
|
|
826
827
|
await new Promise((r) => setTimeout(r, 10));
|
|
827
828
|
});
|
|
828
829
|
|
|
829
|
-
test("abort() clears the queue and
|
|
830
|
+
test("abort() clears the queue and closes out each queued message", async () => {
|
|
830
831
|
const conversation = makeConversation();
|
|
831
832
|
await conversation.loadFromDb();
|
|
832
833
|
|
|
@@ -873,6 +874,17 @@ describe("Conversation message queue", () => {
|
|
|
873
874
|
conversationId: "conv-1",
|
|
874
875
|
});
|
|
875
876
|
|
|
877
|
+
// Each discarded row also gets its own terminal event. `generation_cancelled`
|
|
878
|
+
// closes out the turn they were waiting on; only `message_queued_deleted`
|
|
879
|
+
// closes out the queued rows themselves, and without it clients keep the
|
|
880
|
+
// pending indicator up forever, since no `message_dequeued` is coming.
|
|
881
|
+
expect(
|
|
882
|
+
events2.find((e) => e.type === "message_queued_deleted"),
|
|
883
|
+
).toMatchObject({ conversationId: "conv-1", requestId: "req-2" });
|
|
884
|
+
expect(
|
|
885
|
+
events3.find((e) => e.type === "message_queued_deleted"),
|
|
886
|
+
).toMatchObject({ conversationId: "conv-1", requestId: "req-3" });
|
|
887
|
+
|
|
876
888
|
// abort() must NOT emit conversation_error or generic error for queued discards.
|
|
877
889
|
const err2 = events2.find((e) => e.type === "error");
|
|
878
890
|
expect(err2).toBeUndefined();
|
|
@@ -897,6 +909,122 @@ describe("Conversation message queue", () => {
|
|
|
897
909
|
await new Promise((r) => setTimeout(r, 10));
|
|
898
910
|
});
|
|
899
911
|
|
|
912
|
+
test("a user interrupt keeps the queue and the stopped turn's drain sends it", async () => {
|
|
913
|
+
// GIVEN a turn in flight with a message queued behind it
|
|
914
|
+
const conversation = makeConversation();
|
|
915
|
+
await conversation.loadFromDb();
|
|
916
|
+
|
|
917
|
+
const events2: AssistantEvent[] = [];
|
|
918
|
+
conversation.processMessage({
|
|
919
|
+
content: "msg-1",
|
|
920
|
+
attachments: [],
|
|
921
|
+
requestId: "req-1",
|
|
922
|
+
});
|
|
923
|
+
await waitForPendingRun(1);
|
|
924
|
+
|
|
925
|
+
conversation.enqueueMessage({
|
|
926
|
+
content: "msg-2",
|
|
927
|
+
onEvent: (e) => events2.push(e),
|
|
928
|
+
requestId: "req-2",
|
|
929
|
+
});
|
|
930
|
+
expect(conversation.getQueueDepth()).toBe(1);
|
|
931
|
+
|
|
932
|
+
// WHEN the user hits Stop
|
|
933
|
+
conversation.abort(
|
|
934
|
+
createAbortReason("user_cancel", "conversation-queue.test", "conv-1"),
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
// THEN the queued message survives the abort. Stop ends the turn the user
|
|
938
|
+
// is watching, not the message they queued behind it, and a discard here
|
|
939
|
+
// reaches no client: it emits no per-row terminal event.
|
|
940
|
+
expect(conversation.getQueueDepth()).toBe(1);
|
|
941
|
+
expect(
|
|
942
|
+
events2.find((e) => e.type === "generation_cancelled"),
|
|
943
|
+
).toBeUndefined();
|
|
944
|
+
expect(
|
|
945
|
+
events2.find((e) => e.type === "message_queued_deleted"),
|
|
946
|
+
).toBeUndefined();
|
|
947
|
+
|
|
948
|
+
// AND once the stopped turn unwinds, its `finally` drains the queue, so the
|
|
949
|
+
// message is sent instead of needing to be retyped.
|
|
950
|
+
await resolveRun(0);
|
|
951
|
+
await waitForPendingRun(2);
|
|
952
|
+
expect(events2.find((e) => e.type === "message_dequeued")).toMatchObject({
|
|
953
|
+
conversationId: "conv-1",
|
|
954
|
+
requestId: "req-2",
|
|
955
|
+
});
|
|
956
|
+
expect(conversation.getQueueDepth()).toBe(0);
|
|
957
|
+
expect(
|
|
958
|
+
JSON.stringify(
|
|
959
|
+
pendingRuns[1].messages[pendingRuns[1].messages.length - 1],
|
|
960
|
+
),
|
|
961
|
+
).toContain("msg-2");
|
|
962
|
+
|
|
963
|
+
await resolveRun(1);
|
|
964
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test("a user interrupt mid-tool repairs the abandoned tool_use before the queued message runs", async () => {
|
|
968
|
+
// GIVEN a turn whose history ends with an unanswered tool_use, the shape a
|
|
969
|
+
// Stop mid-tool-call leaves behind and which providers reject outright
|
|
970
|
+
const conversation = makeConversation();
|
|
971
|
+
await conversation.loadFromDb();
|
|
972
|
+
|
|
973
|
+
const events2: AssistantEvent[] = [];
|
|
974
|
+
conversation.processMessage({
|
|
975
|
+
content: "msg-1",
|
|
976
|
+
attachments: [],
|
|
977
|
+
requestId: "req-1",
|
|
978
|
+
});
|
|
979
|
+
await waitForPendingRun(1);
|
|
980
|
+
|
|
981
|
+
conversation.enqueueMessage({
|
|
982
|
+
content: "msg-2",
|
|
983
|
+
onEvent: (e) => events2.push(e),
|
|
984
|
+
requestId: "req-2",
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// WHEN the user interrupts and the stopped turn unwinds with the tool call
|
|
988
|
+
// still unanswered, then drains the queue
|
|
989
|
+
conversation.abort(
|
|
990
|
+
createAbortReason("user_cancel", "conversation-queue.test", "conv-1"),
|
|
991
|
+
);
|
|
992
|
+
const interrupted = pendingRuns[0];
|
|
993
|
+
await interrupted.onEvent({ type: "llm_call_started" });
|
|
994
|
+
interrupted.resolve([
|
|
995
|
+
...interrupted.messages,
|
|
996
|
+
{
|
|
997
|
+
role: "assistant",
|
|
998
|
+
content: [
|
|
999
|
+
{
|
|
1000
|
+
type: "tool_use",
|
|
1001
|
+
id: "toolu-interrupted",
|
|
1002
|
+
name: "bash",
|
|
1003
|
+
input: {},
|
|
1004
|
+
},
|
|
1005
|
+
],
|
|
1006
|
+
},
|
|
1007
|
+
]);
|
|
1008
|
+
await waitForPendingRun(2);
|
|
1009
|
+
|
|
1010
|
+
// THEN the drained turn opens with a synthetic result for the abandoned
|
|
1011
|
+
// call rather than a dangling tool_use.
|
|
1012
|
+
const repaired = pendingRuns[1].messages.find(
|
|
1013
|
+
(m) =>
|
|
1014
|
+
m.role === "user" &&
|
|
1015
|
+
m.content.some(
|
|
1016
|
+
(block) =>
|
|
1017
|
+
block.type === "tool_result" &&
|
|
1018
|
+
block.tool_use_id === "toolu-interrupted",
|
|
1019
|
+
),
|
|
1020
|
+
);
|
|
1021
|
+
expect(repaired).toBeDefined();
|
|
1022
|
+
expect(conversation.pendingInterruptRepair).toBe(false);
|
|
1023
|
+
|
|
1024
|
+
await resolveRun(1);
|
|
1025
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1026
|
+
});
|
|
1027
|
+
|
|
900
1028
|
test("conversation-scoped errors emit both conversation_error and generic error", async () => {
|
|
901
1029
|
const conversation = makeConversation();
|
|
902
1030
|
await conversation.loadFromDb();
|
|
@@ -530,3 +530,82 @@ describe("user shadows of default keys keep their intent", () => {
|
|
|
530
530
|
expect(pinned.provider).toBe("openai");
|
|
531
531
|
});
|
|
532
532
|
});
|
|
533
|
+
|
|
534
|
+
describe("defaultProfileOverrides tier remap", () => {
|
|
535
|
+
test("a tier remap redirects every call site on that tier, and only that tier", () => {
|
|
536
|
+
const llm = LLMSchema.parse({
|
|
537
|
+
profiles: { mine: completeCustom },
|
|
538
|
+
defaultProfileOverrides: { "cost-optimized": "mine" },
|
|
539
|
+
...anthropicDp,
|
|
540
|
+
});
|
|
541
|
+
// conversationSummarization ships on the cost-optimized tier.
|
|
542
|
+
const resolved = resolveCallSiteConfig("conversationSummarization", llm);
|
|
543
|
+
expect(resolved.model).toBe("gpt-5.5");
|
|
544
|
+
expect(resolved.provider).toBe("openai");
|
|
545
|
+
const selection = selectWinningProfile("conversationSummarization", llm);
|
|
546
|
+
expect(selection.profileName).toBe("mine");
|
|
547
|
+
expect(selection.source).toBe("default");
|
|
548
|
+
// A balanced-tier site is untouched by a cost-optimized remap.
|
|
549
|
+
expect(resolveCallSiteConfig("subagentSpawn", llm).provider).toBe(
|
|
550
|
+
"anthropic",
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("a per-call-site pin still beats the tier remap", () => {
|
|
555
|
+
const llm = LLMSchema.parse({
|
|
556
|
+
profiles: {
|
|
557
|
+
mine: completeCustom,
|
|
558
|
+
pinned: { ...completeCustom, model: "gpt-5.4" },
|
|
559
|
+
},
|
|
560
|
+
defaultProfileOverrides: { "cost-optimized": "mine" },
|
|
561
|
+
callSites: { conversationSummarization: { profile: "pinned" } },
|
|
562
|
+
...anthropicDp,
|
|
563
|
+
});
|
|
564
|
+
expect(resolveCallSiteConfig("conversationSummarization", llm).model).toBe(
|
|
565
|
+
"gpt-5.4",
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("an unusable remap target reports and falls through to the stock tier", () => {
|
|
570
|
+
const llm = LLMSchema.parse({
|
|
571
|
+
profiles: { partial: { source: "user", model: "gpt-5.5" } },
|
|
572
|
+
defaultProfileOverrides: { "cost-optimized": "partial" },
|
|
573
|
+
...anthropicDp,
|
|
574
|
+
});
|
|
575
|
+
const { fallbacks, opts } = collect();
|
|
576
|
+
const resolved = resolveCallSiteConfig(
|
|
577
|
+
"conversationSummarization",
|
|
578
|
+
llm,
|
|
579
|
+
opts,
|
|
580
|
+
);
|
|
581
|
+
expect(resolved.provider).toBe("anthropic");
|
|
582
|
+
expect(fallbacks).toContainEqual({
|
|
583
|
+
callSite: "conversationSummarization",
|
|
584
|
+
requested: "partial",
|
|
585
|
+
reason: "incomplete",
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("the anchor consults the balanced remap for profileless call sites", () => {
|
|
590
|
+
const llm = LLMSchema.parse({
|
|
591
|
+
profiles: { mine: completeCustom },
|
|
592
|
+
defaultProfileOverrides: { balanced: "mine" },
|
|
593
|
+
...anthropicDp,
|
|
594
|
+
});
|
|
595
|
+
// `vision` has no shipped profile; it resolves through the anchor.
|
|
596
|
+
expect(resolveCallSiteConfig("vision", llm).model).toBe("gpt-5.5");
|
|
597
|
+
expect(selectWinningProfile("vision", llm).profileName).toBe("mine");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("a self-referential remap is a no-op", () => {
|
|
601
|
+
const llm = LLMSchema.parse({
|
|
602
|
+
defaultProfileOverrides: { balanced: "balanced" },
|
|
603
|
+
...anthropicDp,
|
|
604
|
+
});
|
|
605
|
+
const { fallbacks, opts } = collect();
|
|
606
|
+
expect(resolveCallSiteConfig("subagentSpawn", llm, opts).provider).toBe(
|
|
607
|
+
"anthropic",
|
|
608
|
+
);
|
|
609
|
+
expect(fallbacks).toEqual([]);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
@@ -385,6 +385,7 @@ describe("resolveCallSiteConfig", () => {
|
|
|
385
385
|
callSites: {
|
|
386
386
|
mainAgent: { profile: "nonexistent" },
|
|
387
387
|
},
|
|
388
|
+
defaultProfileOverrides: {},
|
|
388
389
|
profileSession: { defaultTtlSeconds: 1800, maxTtlSeconds: 43200 },
|
|
389
390
|
pricingOverrides: [],
|
|
390
391
|
};
|
|
@@ -561,6 +562,7 @@ describe("resolveCallSiteConfig", () => {
|
|
|
561
562
|
profileOrder: [],
|
|
562
563
|
callSites: {},
|
|
563
564
|
activeProfile: "nonexistent",
|
|
565
|
+
defaultProfileOverrides: {},
|
|
564
566
|
profileSession: { defaultTtlSeconds: 1800, maxTtlSeconds: 43200 },
|
|
565
567
|
pricingOverrides: [],
|
|
566
568
|
};
|
|
@@ -319,4 +319,40 @@ describe("LLMSchema", () => {
|
|
|
319
319
|
});
|
|
320
320
|
expect(result.success).toBe(true);
|
|
321
321
|
});
|
|
322
|
+
|
|
323
|
+
test("defaultProfileOverrides accepts custom profiles and default keys as targets", () => {
|
|
324
|
+
const parsed = LLMSchema.parse({
|
|
325
|
+
profiles: { fast: { speed: "fast", effort: "low" } },
|
|
326
|
+
defaultProfileOverrides: {
|
|
327
|
+
balanced: "fast",
|
|
328
|
+
"cost-optimized": "quality-optimized",
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
expect(parsed.defaultProfileOverrides).toEqual({
|
|
332
|
+
balanced: "fast",
|
|
333
|
+
"cost-optimized": "quality-optimized",
|
|
334
|
+
});
|
|
335
|
+
expect(LLMSchema.parse({}).defaultProfileOverrides).toEqual({});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("a defaultProfileOverrides target naming no profile is rejected", () => {
|
|
339
|
+
const result = LLMSchema.safeParse({
|
|
340
|
+
defaultProfileOverrides: { balanced: "no-such-profile" },
|
|
341
|
+
});
|
|
342
|
+
expect(result.success).toBe(false);
|
|
343
|
+
if (!result.success) {
|
|
344
|
+
expect(result.error.issues[0]?.path).toEqual([
|
|
345
|
+
"defaultProfileOverrides",
|
|
346
|
+
"balanced",
|
|
347
|
+
]);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("a defaultProfileOverrides key outside the default-profile tiers is rejected", () => {
|
|
352
|
+
const result = LLMSchema.safeParse({
|
|
353
|
+
profiles: { fast: { speed: "fast" } },
|
|
354
|
+
defaultProfileOverrides: { turbo: "fast" },
|
|
355
|
+
});
|
|
356
|
+
expect(result.success).toBe(false);
|
|
357
|
+
});
|
|
322
358
|
});
|
|
@@ -128,8 +128,10 @@ Arguments:
|
|
|
128
128
|
name Name of a configured MCP server to authenticate with
|
|
129
129
|
|
|
130
130
|
Only works with sse or streamable-http transports (stdio servers do not use
|
|
131
|
-
OAuth).
|
|
132
|
-
|
|
131
|
+
OAuth). On desktop, opens a browser for OAuth authorization with the remote
|
|
132
|
+
server. On web, prints the OAuth URL to the terminal — copy and open it in a
|
|
133
|
+
new browser tab manually. The running assistant handles the OAuth callback
|
|
134
|
+
and token exchange in both cases.
|
|
133
135
|
|
|
134
136
|
The command waits up to 2.5 minutes for the user to complete the browser-based
|
|
135
137
|
OAuth flow. If the server already has valid cached tokens, the command succeeds
|
|
@@ -340,6 +340,31 @@ describe("reconcileFlagGatedProfiles", () => {
|
|
|
340
340
|
expect(LLMSchema.safeParse(after.llm).success).toBe(true);
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
+
test("flag off clears a tier override targeting os-beta", () => {
|
|
344
|
+
process.env.IS_PLATFORM = "true";
|
|
345
|
+
seedBalancedConfig();
|
|
346
|
+
setOverridesForTesting({ "os-beta": true });
|
|
347
|
+
reconcileFlagGatedProfiles();
|
|
348
|
+
|
|
349
|
+
const raw = readConfig();
|
|
350
|
+
(raw.llm as Record<string, unknown>).defaultProfileOverrides = {
|
|
351
|
+
balanced: "os-beta",
|
|
352
|
+
"cost-optimized": "quality-optimized",
|
|
353
|
+
};
|
|
354
|
+
writeConfig(raw);
|
|
355
|
+
invalidateConfigCache();
|
|
356
|
+
|
|
357
|
+
setOverridesForTesting({ "os-beta": false });
|
|
358
|
+
expect(reconcileFlagGatedProfiles()).toBe(true);
|
|
359
|
+
|
|
360
|
+
const after = readConfig();
|
|
361
|
+
expect(
|
|
362
|
+
(after.llm as Record<string, unknown>).defaultProfileOverrides,
|
|
363
|
+
).toEqual({ "cost-optimized": "quality-optimized" });
|
|
364
|
+
|
|
365
|
+
expect(LLMSchema.safeParse(after.llm).success).toBe(true);
|
|
366
|
+
});
|
|
367
|
+
|
|
343
368
|
test("a user-owned os-beta profile is never touched", () => {
|
|
344
369
|
process.env.IS_PLATFORM = "true";
|
|
345
370
|
writeConfig({
|
|
@@ -38,10 +38,12 @@ import {
|
|
|
38
38
|
* 2. `llm.activeProfile` — `mainAgent` only, since it IS that call site's
|
|
39
39
|
* user-facing chat-model selection
|
|
40
40
|
* 3. `llm.callSites[callSite].profile` (the call site's named profile)
|
|
41
|
-
* 4. `CALL_SITE_DEFAULTS[callSite].profile` intent
|
|
42
|
-
* `llm.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* 4. `CALL_SITE_DEFAULTS[callSite].profile` intent, remapped through
|
|
42
|
+
* `llm.defaultProfileOverrides[intent]` when set, otherwise resolved
|
|
43
|
+
* through `llm.defaultProvider`
|
|
44
|
+
* 5. balanced intent (same remap consult) through `llm.defaultProvider`:
|
|
45
|
+
* the code-owned anchor for profileless call sites, or when nothing
|
|
46
|
+
* above is usable
|
|
45
47
|
*
|
|
46
48
|
* A winner must carry its own `provider` AND `model`: the base layer's schema
|
|
47
49
|
* default identity never stands in for a selected profile. The anchor is
|
|
@@ -147,9 +149,11 @@ export function resolveCallSiteConfig(
|
|
|
147
149
|
// 1. `opts.overrideProfile` (conversation/schedule/per-turn pin)
|
|
148
150
|
// 2. `llm.activeProfile` (mainAgent only — it IS that call site's selection)
|
|
149
151
|
// 3. `llm.callSites[callSite].profile`
|
|
150
|
-
// 4. `CALL_SITE_DEFAULTS[callSite].profile` intent
|
|
151
|
-
//
|
|
152
|
-
//
|
|
152
|
+
// 4. `CALL_SITE_DEFAULTS[callSite].profile` intent, remapped via
|
|
153
|
+
// `llm.defaultProfileOverrides[intent]` when set, else intent × default
|
|
154
|
+
// provider
|
|
155
|
+
// 5. balanced intent (same remap consult) × default provider (profileless
|
|
156
|
+
// sites, or nothing above usable)
|
|
153
157
|
// A winner must carry its own `provider` AND `model`: the base layer's
|
|
154
158
|
// schema-default identity must never stand in for a selected profile (that
|
|
155
159
|
// would be merge inheritance by the back door). A fallback anchor must be
|
|
@@ -212,8 +216,28 @@ export function selectWinningProfile(
|
|
|
212
216
|
entry: sitePin.entry,
|
|
213
217
|
};
|
|
214
218
|
}
|
|
219
|
+
// A tier remap (`llm.defaultProfileOverrides`) redirects the intent rung
|
|
220
|
+
// to a named profile. Resolved through the same `usableEntry` gate as
|
|
221
|
+
// every other rung, so an unusable target reports and falls through to
|
|
222
|
+
// the stock tier resolution. A self-reference is skipped rather than
|
|
223
|
+
// resolved: the stock rung's handling of unusable user shadows differs
|
|
224
|
+
// from the named-rung path, and a no-op remap must not change behavior.
|
|
225
|
+
const remappedIntent = (intent: string) => {
|
|
226
|
+
if (!isDefaultProfileKey(intent)) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const target = llm.defaultProfileOverrides?.[intent];
|
|
230
|
+
return target === intent
|
|
231
|
+
? undefined
|
|
232
|
+
: usableEntry(target, llm, opts, report);
|
|
233
|
+
};
|
|
234
|
+
|
|
215
235
|
const intent = CALL_SITE_DEFAULTS[callSite]?.profile;
|
|
216
236
|
if (intent != null) {
|
|
237
|
+
const remap = remappedIntent(intent);
|
|
238
|
+
if (remap) {
|
|
239
|
+
return { profileName: remap.name, source: "default", entry: remap.entry };
|
|
240
|
+
}
|
|
217
241
|
const entry = usableDefaultIntent(intent, llm, opts, report);
|
|
218
242
|
if (entry) {
|
|
219
243
|
return { profileName: intent, source: "default", entry };
|
|
@@ -221,8 +245,19 @@ export function selectWinningProfile(
|
|
|
221
245
|
}
|
|
222
246
|
// Anchor: profileless call sites (`vision`, `workflowLeaf`) and any
|
|
223
247
|
// resolution whose every named rung was unusable land on balanced intent
|
|
224
|
-
// through the default provider.
|
|
225
|
-
//
|
|
248
|
+
// through the default provider. The anchor consults the balanced remap
|
|
249
|
+
// first (a profileless call site follows the user's balanced choice like
|
|
250
|
+
// any balanced-tier site) but bottoms out on the code-owned catalog, so
|
|
251
|
+
// it always resolves. `profileName` stays null on the catalog path: the
|
|
252
|
+
// anchor itself is not a selection.
|
|
253
|
+
const anchorRemap = remappedIntent("balanced");
|
|
254
|
+
if (anchorRemap) {
|
|
255
|
+
return {
|
|
256
|
+
profileName: anchorRemap.name,
|
|
257
|
+
source: "default",
|
|
258
|
+
entry: anchorRemap.entry,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
226
261
|
return {
|
|
227
262
|
profileName: null,
|
|
228
263
|
source: "default",
|
|
@@ -657,6 +657,15 @@ export const LLMSchema = z
|
|
|
657
657
|
// are seeded into the user's on-disk config by migration 040, not at
|
|
658
658
|
// schema level, so `LLMSchema.parse({})` yields an empty map.
|
|
659
659
|
callSites: z.partialRecord(LLMCallSiteEnum, LLMCallSiteConfig).default({}),
|
|
660
|
+
// Per-tier remap of the shipped call-site defaults: every call site whose
|
|
661
|
+
// `CALL_SITE_DEFAULTS` profile is <tier> resolves through the named
|
|
662
|
+
// profile instead of the tier's intent × provider matrix body. A
|
|
663
|
+
// per-call-site pin (`llm.callSites.*.profile`) still wins over the
|
|
664
|
+
// remap, and an unusable remap target falls through to the stock tier
|
|
665
|
+
// resolution (see `selectWinningProfile`).
|
|
666
|
+
defaultProfileOverrides: z
|
|
667
|
+
.partialRecord(z.enum(DEFAULT_PROFILE_KEYS), z.string().min(1))
|
|
668
|
+
.default({}),
|
|
660
669
|
activeProfile: z.string().min(1).optional(),
|
|
661
670
|
// The profile the advisor role consults when spawned as a subagent (chosen
|
|
662
671
|
// under Models & Services). It is excluded from the chat-profile pickers so
|
|
@@ -725,6 +734,17 @@ export const LLMSchema = z
|
|
|
725
734
|
});
|
|
726
735
|
}
|
|
727
736
|
}
|
|
737
|
+
for (const [tier, profileName] of Object.entries(
|
|
738
|
+
config.defaultProfileOverrides ?? {},
|
|
739
|
+
)) {
|
|
740
|
+
if (profileName != null && !profileNames.has(profileName)) {
|
|
741
|
+
ctx.addIssue({
|
|
742
|
+
code: "custom",
|
|
743
|
+
path: ["defaultProfileOverrides", tier],
|
|
744
|
+
message: `Profile "${profileName}" referenced by llm.defaultProfileOverrides.${tier} is not defined in llm.profiles`,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
728
748
|
if (
|
|
729
749
|
config.activeProfile != null &&
|
|
730
750
|
!profileNames.has(config.activeProfile)
|
|
@@ -221,5 +221,16 @@ function disableProfile(
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// Clear any tier override targeting a removed profile; the cleared tier
|
|
225
|
+
// falls back to its stock intent resolution.
|
|
226
|
+
const tierOverrides = readObject(llm.defaultProfileOverrides);
|
|
227
|
+
if (tierOverrides) {
|
|
228
|
+
for (const [tier, target] of Object.entries(tierOverrides)) {
|
|
229
|
+
if (typeof target === "string" && removed.has(target)) {
|
|
230
|
+
delete tierOverrides[tier];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
224
235
|
return true;
|
|
225
236
|
}
|
|
@@ -37,25 +37,41 @@ mock.module("../../messaging/providers/telegram-bot/render.js", () => ({
|
|
|
37
37
|
}));
|
|
38
38
|
|
|
39
39
|
const { abortConversation } = await import("../conversation-lifecycle.js");
|
|
40
|
+
import { createAbortReason } from "../../util/abort-reasons.js";
|
|
40
41
|
import type { AbortContext } from "../conversation-lifecycle.js";
|
|
41
42
|
|
|
43
|
+
interface QueuedEvent {
|
|
44
|
+
type: string;
|
|
45
|
+
requestId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
interface Harness {
|
|
43
49
|
ctx: AbortContext;
|
|
44
50
|
setProcessingCalls: boolean[];
|
|
45
51
|
prompterDisposed: () => boolean;
|
|
46
52
|
secretPrompterDisposed: () => boolean;
|
|
47
53
|
queueClearedCount: () => number;
|
|
54
|
+
queuedEvents: QueuedEvent[];
|
|
55
|
+
drainKicks: string[];
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
function makeContext(opts: {
|
|
51
59
|
processing: boolean;
|
|
52
60
|
controller: AbortController | null;
|
|
61
|
+
/** requestIds of messages sitting in the queue behind the aborted turn. */
|
|
62
|
+
queued?: string[];
|
|
53
63
|
}): Harness {
|
|
54
64
|
let processing = opts.processing;
|
|
55
65
|
const setProcessingCalls: boolean[] = [];
|
|
56
66
|
let prompterDisposed = false;
|
|
57
67
|
let secretPrompterDisposed = false;
|
|
58
68
|
let queueCleared = 0;
|
|
69
|
+
const queuedEvents: QueuedEvent[] = [];
|
|
70
|
+
const drainKicks: string[] = [];
|
|
71
|
+
let queuedItems = (opts.queued ?? []).map((requestId) => ({
|
|
72
|
+
requestId,
|
|
73
|
+
onEvent: (event: QueuedEvent) => queuedEvents.push(event),
|
|
74
|
+
}));
|
|
59
75
|
|
|
60
76
|
const ctx = {
|
|
61
77
|
conversationId: "abort-null-controller-test",
|
|
@@ -82,10 +98,19 @@ function makeContext(opts: {
|
|
|
82
98
|
queue: {
|
|
83
99
|
clear: () => {
|
|
84
100
|
queueCleared += 1;
|
|
101
|
+
queuedItems = [];
|
|
102
|
+
},
|
|
103
|
+
get isEmpty() {
|
|
104
|
+
return queuedItems.length === 0;
|
|
85
105
|
},
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
get length() {
|
|
107
|
+
return queuedItems.length;
|
|
88
108
|
},
|
|
109
|
+
[Symbol.iterator]: () => queuedItems[Symbol.iterator](),
|
|
110
|
+
},
|
|
111
|
+
pendingInterruptRepair: false,
|
|
112
|
+
kickDrainQueue: async (_reason?: string, origin?: string) => {
|
|
113
|
+
drainKicks.push(origin ?? "");
|
|
89
114
|
},
|
|
90
115
|
} as unknown as AbortContext;
|
|
91
116
|
|
|
@@ -95,6 +120,8 @@ function makeContext(opts: {
|
|
|
95
120
|
prompterDisposed: () => prompterDisposed,
|
|
96
121
|
secretPrompterDisposed: () => secretPrompterDisposed,
|
|
97
122
|
queueClearedCount: () => queueCleared,
|
|
123
|
+
queuedEvents,
|
|
124
|
+
drainKicks,
|
|
98
125
|
};
|
|
99
126
|
}
|
|
100
127
|
|
|
@@ -152,6 +179,114 @@ describe("abortConversation", () => {
|
|
|
152
179
|
);
|
|
153
180
|
});
|
|
154
181
|
|
|
182
|
+
test("a user interrupt leaves queued messages for the stopped turn's drain", () => {
|
|
183
|
+
// GIVEN a live turn with two messages queued behind it
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
const h = makeContext({
|
|
186
|
+
processing: true,
|
|
187
|
+
controller,
|
|
188
|
+
queued: ["req-a", "req-b"],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// WHEN the user stops the turn
|
|
192
|
+
abortConversation(
|
|
193
|
+
h.ctx,
|
|
194
|
+
createAbortReason("user_cancel", "test", "abort-null-controller-test"),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// THEN the queue is untouched, so the agent loop's `finally` drains it and
|
|
198
|
+
// the queued messages are sent rather than silently dropped
|
|
199
|
+
expect(h.queueClearedCount()).toBe(0);
|
|
200
|
+
expect(h.ctx.queue.length).toBe(2);
|
|
201
|
+
expect(h.queuedEvents).toEqual([]);
|
|
202
|
+
// AND the drain is left to that `finally`, not kicked from here
|
|
203
|
+
expect(h.drainKicks).toEqual([]);
|
|
204
|
+
// AND the drain is told to repair tool_use blocks the killed turn abandoned
|
|
205
|
+
expect(h.ctx.pendingInterruptRepair).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a user interrupt with no live turn kicks the drain itself", () => {
|
|
209
|
+
// GIVEN a conversation flagged processing with no controller left to
|
|
210
|
+
// signal, so no agent-loop `finally` is coming to drain the preserved queue
|
|
211
|
+
const h = makeContext({
|
|
212
|
+
processing: true,
|
|
213
|
+
controller: null,
|
|
214
|
+
queued: ["req-a"],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// WHEN the user stops it
|
|
218
|
+
abortConversation(
|
|
219
|
+
h.ctx,
|
|
220
|
+
createAbortReason("user_cancel", "test", "abort-null-controller-test"),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// THEN the queued message is still sent, because the abort kicks the drain
|
|
224
|
+
expect(h.ctx.queue.length).toBe(1);
|
|
225
|
+
expect(h.drainKicks).toEqual(["abortConversation:no_live_turn"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a non-interrupt abort discards the queue and closes out each row", () => {
|
|
229
|
+
// GIVEN a live turn with a queued message, torn down by a dispose
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
const h = makeContext({
|
|
232
|
+
processing: true,
|
|
233
|
+
controller,
|
|
234
|
+
queued: ["req-a"],
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// WHEN the conversation is disposed rather than interrupted
|
|
238
|
+
abortConversation(
|
|
239
|
+
h.ctx,
|
|
240
|
+
createAbortReason(
|
|
241
|
+
"conversation_disposed",
|
|
242
|
+
"test",
|
|
243
|
+
"abort-null-controller-test",
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// THEN the queue is dropped, since no turn is left for it to run on, and
|
|
248
|
+
// the sender gets the terminal event that closes the queued row, so no
|
|
249
|
+
// client is left holding a pending indicator nothing will ever settle
|
|
250
|
+
expect(h.queueClearedCount()).toBe(1);
|
|
251
|
+
expect(h.queuedEvents.map((e) => e.type)).toEqual([
|
|
252
|
+
"generation_cancelled",
|
|
253
|
+
"message_queued_deleted",
|
|
254
|
+
]);
|
|
255
|
+
expect(h.queuedEvents[1].requestId).toBe("req-a");
|
|
256
|
+
expect(h.ctx.pendingInterruptRepair).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("a teardown abort discards the queue even when the flag reads idle", () => {
|
|
260
|
+
// GIVEN a conversation whose turn already cleared the processing flag but
|
|
261
|
+
// is still unwinding: the agent-loop `finally` clears the flag, then awaits
|
|
262
|
+
// the turn-boundary commit before its `kickDrainQueue`. A dispose landing
|
|
263
|
+
// in that window sees an idle conversation with a populated queue.
|
|
264
|
+
const h = makeContext({
|
|
265
|
+
processing: false,
|
|
266
|
+
controller: null,
|
|
267
|
+
queued: ["req-a"],
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// WHEN the conversation is disposed
|
|
271
|
+
abortConversation(
|
|
272
|
+
h.ctx,
|
|
273
|
+
createAbortReason(
|
|
274
|
+
"conversation_disposed",
|
|
275
|
+
"test",
|
|
276
|
+
"abort-null-controller-test",
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// THEN the queue goes with it, so the pending `kickDrainQueue` finds
|
|
281
|
+
// nothing to run against the disposed conversation, and the queued row
|
|
282
|
+
// still gets its terminal event
|
|
283
|
+
expect(h.ctx.queue.length).toBe(0);
|
|
284
|
+
expect(h.queuedEvents.map((e) => e.type)).toEqual([
|
|
285
|
+
"generation_cancelled",
|
|
286
|
+
"message_queued_deleted",
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
|
|
155
290
|
test("clears the persisted flag when in-memory reads idle (cancel with no live abort)", () => {
|
|
156
291
|
// GIVEN a conversation whose in-memory flag reads idle — e.g. reloaded
|
|
157
292
|
// after its owning turn was interrupted out-of-process, so a persisted
|
|
@@ -166,9 +301,10 @@ describe("abortConversation", () => {
|
|
|
166
301
|
// persisted column, so the row is unwedged for cold readers and the next
|
|
167
302
|
// reload — cancel is not a silent no-op.
|
|
168
303
|
expect(h.setProcessingCalls).toEqual([false]);
|
|
169
|
-
// AND the live-turn teardown (prompters,
|
|
170
|
-
// live turn holding them.
|
|
304
|
+
// AND the live-turn teardown (prompters, surfaces) is left alone: there was
|
|
305
|
+
// no live turn holding them. The queue is the exception, discarded on any
|
|
306
|
+
// non-interrupt abort regardless of the flag.
|
|
171
307
|
expect(h.prompterDisposed()).toBe(false);
|
|
172
|
-
expect(h.
|
|
308
|
+
expect(h.ctx.queue.length).toBe(0);
|
|
173
309
|
});
|
|
174
310
|
});
|
|
@@ -12,6 +12,7 @@ import { getConfig } from "../config/loader.js";
|
|
|
12
12
|
import { usesConceptPageMemory } from "../config/memory-v3-gate.js";
|
|
13
13
|
import type { PermissionPrompter } from "../permissions/prompter.js";
|
|
14
14
|
import type { SecretPrompter } from "../permissions/secret-prompter.js";
|
|
15
|
+
import { isHiddenMessageMetadata } from "../persistence/conversation-types.js";
|
|
15
16
|
import {
|
|
16
17
|
enqueueMemoryJob,
|
|
17
18
|
isMemoryEnabled,
|
|
@@ -23,10 +24,17 @@ import { resolveCapabilities } from "../runtime/capabilities.js";
|
|
|
23
24
|
import { isAutoAnalysisConversation } from "../runtime/services/auto-analysis-guard.js";
|
|
24
25
|
import { unregisterConversationSender } from "../tools/browser/browser-screencast.js";
|
|
25
26
|
import { disposeToolProfiler } from "../tools/tool-profiler.js";
|
|
26
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
type AbortReason,
|
|
29
|
+
createAbortReason,
|
|
30
|
+
isUserInterruptAbort,
|
|
31
|
+
} from "../util/abort-reasons.js";
|
|
27
32
|
import { getLogger } from "../util/logger.js";
|
|
28
33
|
import { unregisterCallNotifiers } from "./conversation-notifiers.js";
|
|
29
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
MessageQueue,
|
|
36
|
+
QueueDrainReason,
|
|
37
|
+
} from "./conversation-queue-manager.js";
|
|
30
38
|
import { resetSkillToolProjection } from "./conversation-skill-tools.js";
|
|
31
39
|
import type { SurfaceData, SurfaceType } from "./message-protocol.js";
|
|
32
40
|
|
|
@@ -112,6 +120,12 @@ export interface AbortContext {
|
|
|
112
120
|
>;
|
|
113
121
|
accumulatedSurfaceState: Map<string, Record<string, unknown>>;
|
|
114
122
|
readonly queue: MessageQueue;
|
|
123
|
+
/** Armed when an interrupt leaves queued messages behind, so the drain
|
|
124
|
+
* repairs tool_use blocks the killed turn abandoned. */
|
|
125
|
+
pendingInterruptRepair: boolean;
|
|
126
|
+
/** Never rejects. Called only when an interrupt preserves the queue but no
|
|
127
|
+
* live turn is left to reach its `finally` and drain it. */
|
|
128
|
+
kickDrainQueue(reason?: QueueDrainReason, origin?: string): Promise<void>;
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
export interface DisposeContext extends AbortContext {
|
|
@@ -129,18 +143,99 @@ export interface DisposeContext extends AbortContext {
|
|
|
129
143
|
|
|
130
144
|
// ── abort ─────────────────────────────────────────────────────────────
|
|
131
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Keep the queue intact across a user interrupt so the messages queued behind
|
|
148
|
+
* the stopped turn still run.
|
|
149
|
+
*
|
|
150
|
+
* A user interrupt ends the turn in flight and nothing else: the aborted turn
|
|
151
|
+
* unwinds to the agent loop's `finally`, which calls `kickDrainQueue`, so the
|
|
152
|
+
* queued messages become the next turn. This is the same drain-after-abort
|
|
153
|
+
* sequence `steerToMessage` relies on.
|
|
154
|
+
*
|
|
155
|
+
* An interrupt can land mid-tool, leaving `tool_use` blocks with no results, so
|
|
156
|
+
* arm the same repair a steer arms: the drained turn then opens with synthetic
|
|
157
|
+
* results instead of a dangling call.
|
|
158
|
+
*
|
|
159
|
+
* `hasLiveTurn` is false when the processing flag was force-cleared because no
|
|
160
|
+
* controller was left to signal. No agent-loop `finally` runs in that case, so
|
|
161
|
+
* the drain is kicked here instead; otherwise the preserved messages wait for
|
|
162
|
+
* an unrelated later turn to finish.
|
|
163
|
+
*/
|
|
164
|
+
function preserveQueueAcrossInterrupt(
|
|
165
|
+
ctx: AbortContext,
|
|
166
|
+
{ hasLiveTurn }: { hasLiveTurn: boolean },
|
|
167
|
+
): void {
|
|
168
|
+
if (ctx.queue.isEmpty) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
ctx.pendingInterruptRepair = true;
|
|
172
|
+
log.info(
|
|
173
|
+
{
|
|
174
|
+
conversationId: ctx.conversationId,
|
|
175
|
+
queueDepth: ctx.queue.length,
|
|
176
|
+
hasLiveTurn,
|
|
177
|
+
},
|
|
178
|
+
"User interrupt: preserving queued messages for the interrupted turn's drain",
|
|
179
|
+
);
|
|
180
|
+
if (!hasLiveTurn) {
|
|
181
|
+
void ctx.kickDrainQueue("loop_complete", "abortConversation:no_live_turn");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Discard the queue when the conversation itself is going away (dispose,
|
|
187
|
+
* eviction, voice supersession, subagent teardown): there is no turn left for
|
|
188
|
+
* a queued message to run on.
|
|
189
|
+
*
|
|
190
|
+
* This runs whether or not the in-memory flag reads processing. A turn that
|
|
191
|
+
* cleared the flag can still be unwinding through its awaited turn-boundary
|
|
192
|
+
* commit with a `kickDrainQueue` ahead of it, and a queue left populated there
|
|
193
|
+
* drains onto a conversation whose state (and possibly whose durable row) is
|
|
194
|
+
* already gone.
|
|
195
|
+
*
|
|
196
|
+
* Each sender gets two events, both load-bearing. `generation_cancelled`
|
|
197
|
+
* closes out the turn the message was waiting on; `message_queued_deleted` is
|
|
198
|
+
* the terminal event for the queued row itself, the same one a user-issued
|
|
199
|
+
* DELETE emits (`deleteQueuedMessage`). Without the second, a client holds the
|
|
200
|
+
* pending indicator forever, since no `message_dequeued` is coming. Hidden
|
|
201
|
+
* sends are suppressed for the same reason they get no queued ack: they have
|
|
202
|
+
* no client row to close.
|
|
203
|
+
*/
|
|
204
|
+
function discardQueueOnAbort(ctx: AbortContext): void {
|
|
205
|
+
for (const queued of ctx.queue) {
|
|
206
|
+
queued.onEvent({
|
|
207
|
+
type: "generation_cancelled",
|
|
208
|
+
conversationId: ctx.conversationId,
|
|
209
|
+
});
|
|
210
|
+
if (isHiddenMessageMetadata(queued.metadata)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
queued.onEvent({
|
|
214
|
+
type: "message_queued_deleted",
|
|
215
|
+
conversationId: ctx.conversationId,
|
|
216
|
+
requestId: queued.requestId,
|
|
217
|
+
...(queued.clientMessageId
|
|
218
|
+
? { clientMessageId: queued.clientMessageId }
|
|
219
|
+
: {}),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
ctx.queue.clear();
|
|
223
|
+
}
|
|
224
|
+
|
|
132
225
|
export function abortConversation(
|
|
133
226
|
ctx: AbortContext,
|
|
134
227
|
reason?: AbortReason,
|
|
135
228
|
): void {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
229
|
+
const effectiveReason =
|
|
230
|
+
reason ??
|
|
231
|
+
createAbortReason(
|
|
232
|
+
"preempted_by_new_message",
|
|
233
|
+
"abortConversation:default",
|
|
234
|
+
ctx.conversationId,
|
|
235
|
+
);
|
|
236
|
+
const hasLiveTurn = ctx.abortController !== null;
|
|
237
|
+
const wasProcessing = ctx.isProcessing();
|
|
238
|
+
if (wasProcessing) {
|
|
144
239
|
log.info(
|
|
145
240
|
{ conversationId: ctx.conversationId, abortReason: effectiveReason },
|
|
146
241
|
"Aborting in-flight processing",
|
|
@@ -175,13 +270,6 @@ export function abortConversation(
|
|
|
175
270
|
ctx.surfaceActionRequestIds.clear();
|
|
176
271
|
ctx.surfaceState.clear();
|
|
177
272
|
ctx.accumulatedSurfaceState.clear();
|
|
178
|
-
for (const queued of ctx.queue) {
|
|
179
|
-
queued.onEvent({
|
|
180
|
-
type: "generation_cancelled",
|
|
181
|
-
conversationId: ctx.conversationId,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
ctx.queue.clear();
|
|
185
273
|
} else {
|
|
186
274
|
// The in-memory flag reads idle, but a cancel must still clear a persisted
|
|
187
275
|
// `processing_started_at` that outlived the turn that set it. This is the
|
|
@@ -201,6 +289,20 @@ export function abortConversation(
|
|
|
201
289
|
// reports idle in-memory to clients, so no refetch needs to be pushed.
|
|
202
290
|
ctx.setProcessing(false);
|
|
203
291
|
}
|
|
292
|
+
|
|
293
|
+
// The queue decision is independent of the processing flag: an interrupt
|
|
294
|
+
// hands its queue to the drain, and every other abort kind is a teardown
|
|
295
|
+
// that must take the queue with it even when the flag already reads idle.
|
|
296
|
+
if (isUserInterruptAbort(effectiveReason)) {
|
|
297
|
+
// An idle conversation counts as having a live turn for drain purposes:
|
|
298
|
+
// either a turn is unwinding toward its own `kickDrainQueue`, or the queue
|
|
299
|
+
// is empty. Kicking a second drain here would race the first.
|
|
300
|
+
preserveQueueAcrossInterrupt(ctx, {
|
|
301
|
+
hasLiveTurn: hasLiveTurn || !wasProcessing,
|
|
302
|
+
});
|
|
303
|
+
} else {
|
|
304
|
+
discardQueueOnAbort(ctx);
|
|
305
|
+
}
|
|
204
306
|
}
|
|
205
307
|
|
|
206
308
|
// ── dispose ──────────────────────────────────────────────────────────
|
|
@@ -324,10 +324,11 @@ async function buildPassthroughBatch(
|
|
|
324
324
|
return conversation.queue.shiftN(matched);
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
// ── Steer repair
|
|
327
|
+
// ── Steer / interrupt repair ────────────────────────────────────────
|
|
328
328
|
|
|
329
329
|
/**
|
|
330
|
-
* When a steer-to-message abort
|
|
330
|
+
* When a steer-to-message abort (or a user interrupt with messages still
|
|
331
|
+
* queued behind the stopped turn) cuts off an in-flight tool call, the
|
|
331
332
|
* conversation history may end with an assistant message containing one
|
|
332
333
|
* or more `tool_use` blocks that have no corresponding `tool_result`.
|
|
333
334
|
* LLM providers reject this sequence. This helper scans the tail of the
|
|
@@ -335,10 +336,12 @@ async function buildPassthroughBatch(
|
|
|
335
336
|
* unmatched `tool_use` blocks.
|
|
336
337
|
*/
|
|
337
338
|
function repairPendingToolUseBlocks(conversation: Conversation): void {
|
|
338
|
-
|
|
339
|
+
const steered = conversation.pendingSteerRepair;
|
|
340
|
+
if (!steered && !conversation.pendingInterruptRepair) {
|
|
339
341
|
return;
|
|
340
342
|
}
|
|
341
343
|
conversation.pendingSteerRepair = false;
|
|
344
|
+
conversation.pendingInterruptRepair = false;
|
|
342
345
|
|
|
343
346
|
const messages = conversation.messages;
|
|
344
347
|
if (messages.length === 0) {
|
|
@@ -381,15 +384,18 @@ function repairPendingToolUseBlocks(conversation: Conversation): void {
|
|
|
381
384
|
{
|
|
382
385
|
conversationId: conversation.conversationId,
|
|
383
386
|
pendingToolUseCount: pendingToolUseIds.length,
|
|
387
|
+
trigger: steered ? "steer" : "interrupt",
|
|
384
388
|
},
|
|
385
|
-
"Injecting synthetic tool_result for pending tool_use blocks
|
|
389
|
+
"Injecting synthetic tool_result for pending tool_use blocks",
|
|
386
390
|
);
|
|
387
391
|
|
|
388
392
|
// Build a single user message with tool_result blocks for all pending IDs.
|
|
389
393
|
const syntheticContent = pendingToolUseIds.map((toolUseId) => ({
|
|
390
394
|
type: "tool_result" as const,
|
|
391
395
|
tool_use_id: toolUseId,
|
|
392
|
-
content:
|
|
396
|
+
content: steered
|
|
397
|
+
? "Tool execution was interrupted by user steering."
|
|
398
|
+
: "Tool execution was interrupted by the user.",
|
|
393
399
|
is_error: true,
|
|
394
400
|
}));
|
|
395
401
|
conversation.messages.push({
|
|
@@ -538,6 +538,18 @@ export class Conversation {
|
|
|
538
538
|
* @internal
|
|
539
539
|
*/
|
|
540
540
|
pendingSteerRepair = false;
|
|
541
|
+
/**
|
|
542
|
+
* Set by `abortConversation` when a user interrupt (Stop / Esc / the CLI
|
|
543
|
+
* cancel signal) ends a turn that still has messages queued behind it. Those
|
|
544
|
+
* messages survive the abort and drain into the next turn, so the drain path
|
|
545
|
+
* owes them the same synthetic tool_result repair a steer gets: the killed
|
|
546
|
+
* turn may have left `tool_use` blocks with no results. Unlike
|
|
547
|
+
* `pendingSteerRepair` this does not promote a single head message. An
|
|
548
|
+
* interrupt has nothing to promote, so the drain batches the queue the way it
|
|
549
|
+
* would after any other turn. Cleared after repair.
|
|
550
|
+
* @internal
|
|
551
|
+
*/
|
|
552
|
+
pendingInterruptRepair = false;
|
|
541
553
|
/**
|
|
542
554
|
* When true, side-effect tools must prompt even if a trust/allow rule
|
|
543
555
|
* would auto-allow. Set by non-interactive callers (e.g. non-guardian
|
package/src/runtime/AGENTS.md
CHANGED
|
@@ -12,6 +12,8 @@ The single HTTP send endpoint is `POST /v1/messages`. Key behaviors:
|
|
|
12
12
|
|
|
13
13
|
Do NOT add new send endpoints. All message ingress should go through `POST /v1/messages` (HTTP).
|
|
14
14
|
|
|
15
|
+
**A user interrupt ends the turn, not the queue.** `POST /v1/conversations/:id/cancel` (Stop / Esc, and the CLI's cancel signal file) aborts the turn in flight; anything the same user queued behind it survives and runs on that turn's drain, because the aborted loop reaches its `finally`, which calls `kickDrainQueue` like any other turn end. `abortConversation` discards the queue only for abort kinds where the conversation itself is going away (dispose, eviction, voice supersession, subagent teardown), and every discarded row gets `message_queued_deleted` alongside `generation_cancelled`. Two invariants to keep: an unconditional `queue.clear()` emits no per-row terminal event, so clients keep rendering a queued row whose cancel/steer buttons are dead (DELETE 404s, steer reports `not_processing`) and whose message is silently lost on the next reload; and a teardown must discard even when the processing flag already reads idle, since a turn that cleared the flag can still be unwinding through its awaited turn-boundary commit with a `kickDrainQueue` ahead of it.
|
|
16
|
+
|
|
15
17
|
### Channel inbound honors "queue if busy" via defer-until-idle
|
|
16
18
|
|
|
17
19
|
Channel inbound turns (Slack/Telegram/etc. — `processChannelMessageInBackground`) obey the same "process when the current turn completes" contract, but they do **not** route through the conversation's queue. A channel turn delivers its reply back through the provider callback URL (streaming session + `finalizeEventDelivery` + processed/delivery bookkeeping); the queue drain fans replies onto the SSE hub only and performs none of that. So a channel turn that arrives while the conversation is mid-turn is **deferred until the processing lock frees** (`withChannelTurnAdmission` in `routes/inbound-stages/channel-turn-admission.ts`: per-conversation single-flight for FIFO ordering + event-driven `waitForIdle`), then run with its delivery orchestration intact.
|
|
@@ -277,6 +277,10 @@ describe("collectProfileReferences", () => {
|
|
|
277
277
|
memoryExtraction: { profile: "my-fast" },
|
|
278
278
|
recall: { profile: "other" },
|
|
279
279
|
},
|
|
280
|
+
defaultProfileOverrides: {
|
|
281
|
+
balanced: "my-fast",
|
|
282
|
+
"cost-optimized": "other",
|
|
283
|
+
},
|
|
280
284
|
profiles: {
|
|
281
285
|
"my-fast": { source: "user", provider: "anthropic" },
|
|
282
286
|
"my-mix": {
|
|
@@ -290,6 +294,7 @@ describe("collectProfileReferences", () => {
|
|
|
290
294
|
"llm.activeProfile",
|
|
291
295
|
"llm.advisorProfile",
|
|
292
296
|
"llm.callSites.memoryExtraction",
|
|
297
|
+
"llm.defaultProfileOverrides.balanced",
|
|
293
298
|
"llm.profiles.my-mix.mix",
|
|
294
299
|
].sort(),
|
|
295
300
|
);
|
|
@@ -296,7 +296,8 @@ function fragmentFromBody(
|
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
298
|
* Enumerate every live reference to profile `name` in the raw `llm` config
|
|
299
|
-
* block: `activeProfile`, `advisorProfile`, each `callSites.<id>.profile`,
|
|
299
|
+
* block: `activeProfile`, `advisorProfile`, each `callSites.<id>.profile`,
|
|
300
|
+
* each `defaultProfileOverrides.<tier>`, and
|
|
300
301
|
* every mix arm (`profiles.<mix>.mix[].profile`). Deleting a profile while any
|
|
301
302
|
* of these point at it would leave a dangling reference that `LLMSchema`'s
|
|
302
303
|
* superRefine rejects on the next load — silently resetting the user's chat
|
|
@@ -324,6 +325,14 @@ export function collectProfileReferences(
|
|
|
324
325
|
}
|
|
325
326
|
}
|
|
326
327
|
}
|
|
328
|
+
const tierOverrides = asPlainObject(llm.defaultProfileOverrides);
|
|
329
|
+
if (tierOverrides) {
|
|
330
|
+
for (const [tier, target] of Object.entries(tierOverrides)) {
|
|
331
|
+
if (target === name) {
|
|
332
|
+
refs.push(`llm.defaultProfileOverrides.${tier}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
327
336
|
const profiles = asPlainObject(llm.profiles);
|
|
328
337
|
if (profiles) {
|
|
329
338
|
for (const [profileName, profileEntry] of Object.entries(profiles)) {
|
|
@@ -741,7 +750,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
741
750
|
"404": { description: "Profile not found" },
|
|
742
751
|
"409": {
|
|
743
752
|
description:
|
|
744
|
-
"Profile is still referenced by activeProfile, advisorProfile, a call site, or a mix arm",
|
|
753
|
+
"Profile is still referenced by activeProfile, advisorProfile, a call site, a default-tier override, or a mix arm",
|
|
745
754
|
},
|
|
746
755
|
},
|
|
747
756
|
handler: handleDeleteProfile,
|
|
@@ -756,8 +756,14 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
756
756
|
}),
|
|
757
757
|
responseBody: z.object({
|
|
758
758
|
success: z.boolean(),
|
|
759
|
-
type: z.string(),
|
|
760
|
-
name: z.string(),
|
|
759
|
+
type: z.string().optional(),
|
|
760
|
+
name: z.string().optional(),
|
|
761
|
+
error: z
|
|
762
|
+
.string()
|
|
763
|
+
.optional()
|
|
764
|
+
.describe(
|
|
765
|
+
"Why the secret was not stored (e.g. provider-side API key validation failed). Present only when success is false.",
|
|
766
|
+
),
|
|
761
767
|
}),
|
|
762
768
|
handler: handleAddSecret,
|
|
763
769
|
},
|
|
@@ -46,6 +46,22 @@ export function createAbortReason(
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* True when the abort came from a person stopping the turn they are watching
|
|
51
|
+
* (the Stop button, Esc, or the CLI's cancel signal file) rather than from the
|
|
52
|
+
* conversation going away or another subsystem seizing it.
|
|
53
|
+
*
|
|
54
|
+
* The distinction matters for the message queue. A user interrupt ends the
|
|
55
|
+
* turn in flight; it says nothing about the messages the same user queued
|
|
56
|
+
* behind it, so those survive the abort and run on the interrupted turn's
|
|
57
|
+
* drain. Every other kind (dispose, eviction, voice supersession, subagent
|
|
58
|
+
* teardown) is the conversation or its owner disappearing, where a queued
|
|
59
|
+
* message has nothing left to run on and is discarded.
|
|
60
|
+
*/
|
|
61
|
+
export function isUserInterruptAbort(reason: AbortReason | undefined): boolean {
|
|
62
|
+
return reason?.kind === "user_cancel" || reason?.kind === "signal_cancel";
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
export function isAbortReason(value: unknown): value is AbortReason {
|
|
50
66
|
if (typeof value !== "object" || value === null) {
|
|
51
67
|
return false;
|