@vellumai/assistant 0.11.2-dev.202608052357.8f326c2 → 0.11.2-dev.202608060040.fd8f444

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 (27) hide show
  1. package/openapi.yaml +77 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +113 -66
  4. package/src/__tests__/anthropic-provider.test.ts +29 -11
  5. package/src/__tests__/conversation-agent-loop.test.ts +146 -0
  6. package/src/__tests__/openai-responses-prompt-cache.test.ts +5 -2
  7. package/src/__tests__/prompt-cache-cross-turn-stability.test.ts +593 -0
  8. package/src/__tests__/queued-message-steer-actor-scoping.test.ts +164 -0
  9. package/src/__tests__/schedule-routes.test.ts +439 -0
  10. package/src/__tests__/schedule-store.test.ts +31 -0
  11. package/src/__tests__/steer-on-enqueue-question.test.ts +3 -2
  12. package/src/agent/loop.ts +13 -10
  13. package/src/cli/commands/__tests__/conversations-wake.test.ts +51 -6
  14. package/src/cli/commands/conversations.ts +7 -3
  15. package/src/config/bundled-skills/schedule/SKILL.md +2 -0
  16. package/src/config/feature-flag-registry.json +0 -8
  17. package/src/context/strip-injections.ts +42 -0
  18. package/src/daemon/conversation-agent-loop.ts +53 -9
  19. package/src/daemon/handlers/conversations.ts +38 -11
  20. package/src/providers/anthropic/client.ts +37 -37
  21. package/src/providers/openai/responses-provider.ts +9 -22
  22. package/src/providers/types.ts +26 -6
  23. package/src/runtime/routes/__tests__/wake-conversation-routes.test.ts +60 -2
  24. package/src/runtime/routes/conversation-query-routes.ts +24 -3
  25. package/src/runtime/routes/schedule-routes.ts +139 -3
  26. package/src/runtime/routes/wake-conversation-routes.ts +23 -0
  27. package/src/schedule/schedule-store.ts +6 -0
package/openapi.yaml CHANGED
@@ -9623,6 +9623,9 @@ paths:
9623
9623
  cronRunId:
9624
9624
  type: string
9625
9625
  minLength: 1
9626
+ scheduleId:
9627
+ type: string
9628
+ minLength: 1
9626
9629
  persist:
9627
9630
  type: boolean
9628
9631
  externalContent:
@@ -20880,6 +20883,10 @@ paths:
20880
20883
  responses:
20881
20884
  "200":
20882
20885
  description: Successful response
20886
+ "403":
20887
+ description: The queued message was enqueued by a different actor principal.
20888
+ "404":
20889
+ description: Conversation or queued message not found.
20883
20890
  /v1/migrations/export:
20884
20891
  post:
20885
20892
  operationId: migrations_export_post
@@ -25470,6 +25477,12 @@ paths:
25470
25477
  schema:
25471
25478
  type: string
25472
25479
  description: When 'true', include deferred schedules that are normally hidden.
25480
+ - name: inference_profile
25481
+ in: query
25482
+ required: false
25483
+ schema:
25484
+ type: string
25485
+ description: Return only schedules pinned to this inference profile (llm.profiles key).
25473
25486
  responses:
25474
25487
  "200":
25475
25488
  description: Successful response
@@ -25587,6 +25600,8 @@ paths:
25587
25600
  - type: "null"
25588
25601
  isOneShot:
25589
25602
  type: boolean
25603
+ isDeferred:
25604
+ type: boolean
25590
25605
  required:
25591
25606
  - id
25592
25607
  - name
@@ -25618,6 +25633,7 @@ paths:
25618
25633
  - wakeConversationId
25619
25634
  - workflowName
25620
25635
  - isOneShot
25636
+ - isDeferred
25621
25637
  additionalProperties: false
25622
25638
  description: Schedule objects
25623
25639
  required:
@@ -25801,6 +25817,8 @@ paths:
25801
25817
  - type: "null"
25802
25818
  isOneShot:
25803
25819
  type: boolean
25820
+ isDeferred:
25821
+ type: boolean
25804
25822
  required:
25805
25823
  - id
25806
25824
  - name
@@ -25832,6 +25850,7 @@ paths:
25832
25850
  - wakeConversationId
25833
25851
  - workflowName
25834
25852
  - isOneShot
25853
+ - isDeferred
25835
25854
  additionalProperties: false
25836
25855
  description: The created schedule
25837
25856
  required:
@@ -25967,6 +25986,8 @@ paths:
25967
25986
  - type: "null"
25968
25987
  isOneShot:
25969
25988
  type: boolean
25989
+ isDeferred:
25990
+ type: boolean
25970
25991
  required:
25971
25992
  - id
25972
25993
  - name
@@ -25998,6 +26019,7 @@ paths:
25998
26019
  - wakeConversationId
25999
26020
  - workflowName
26000
26021
  - isOneShot
26022
+ - isDeferred
26001
26023
  additionalProperties: false
26002
26024
  description: Updated schedule list
26003
26025
  required:
@@ -26130,6 +26152,8 @@ paths:
26130
26152
  - type: "null"
26131
26153
  isOneShot:
26132
26154
  type: boolean
26155
+ isDeferred:
26156
+ type: boolean
26133
26157
  required:
26134
26158
  - id
26135
26159
  - name
@@ -26161,6 +26185,7 @@ paths:
26161
26185
  - wakeConversationId
26162
26186
  - workflowName
26163
26187
  - isOneShot
26188
+ - isDeferred
26164
26189
  additionalProperties: false
26165
26190
  description: Schedule object
26166
26191
  required:
@@ -26353,6 +26378,8 @@ paths:
26353
26378
  - type: "null"
26354
26379
  isOneShot:
26355
26380
  type: boolean
26381
+ isDeferred:
26382
+ type: boolean
26356
26383
  required:
26357
26384
  - id
26358
26385
  - name
@@ -26384,6 +26411,7 @@ paths:
26384
26411
  - wakeConversationId
26385
26412
  - workflowName
26386
26413
  - isOneShot
26414
+ - isDeferred
26387
26415
  additionalProperties: false
26388
26416
  description: Updated schedule list
26389
26417
  required:
@@ -26519,6 +26547,8 @@ paths:
26519
26547
  - type: "null"
26520
26548
  isOneShot:
26521
26549
  type: boolean
26550
+ isDeferred:
26551
+ type: boolean
26522
26552
  required:
26523
26553
  - id
26524
26554
  - name
@@ -26550,6 +26580,7 @@ paths:
26550
26580
  - wakeConversationId
26551
26581
  - workflowName
26552
26582
  - isOneShot
26583
+ - isDeferred
26553
26584
  additionalProperties: false
26554
26585
  description: Updated schedule list
26555
26586
  required:
@@ -26685,6 +26716,8 @@ paths:
26685
26716
  - type: "null"
26686
26717
  isOneShot:
26687
26718
  type: boolean
26719
+ isDeferred:
26720
+ type: boolean
26688
26721
  required:
26689
26722
  - id
26690
26723
  - name
@@ -26716,6 +26749,7 @@ paths:
26716
26749
  - wakeConversationId
26717
26750
  - workflowName
26718
26751
  - isOneShot
26752
+ - isDeferred
26719
26753
  additionalProperties: false
26720
26754
  description: Updated schedule list
26721
26755
  required:
@@ -26988,6 +27022,8 @@ paths:
26988
27022
  - type: "null"
26989
27023
  isOneShot:
26990
27024
  type: boolean
27025
+ isDeferred:
27026
+ type: boolean
26991
27027
  required:
26992
27028
  - id
26993
27029
  - name
@@ -27019,11 +27055,52 @@ paths:
27019
27055
  - wakeConversationId
27020
27056
  - workflowName
27021
27057
  - isOneShot
27058
+ - isDeferred
27022
27059
  additionalProperties: false
27023
27060
  description: Updated schedule list
27024
27061
  required:
27025
27062
  - schedules
27026
27063
  additionalProperties: false
27064
+ /v1/schedules/reassign-profile:
27065
+ post:
27066
+ operationId: schedules_reassignprofile_post
27067
+ summary: Reassign schedules to another inference profile
27068
+ description:
27069
+ Move schedules onto one inference profile. Pass 'from' to move only the schedules pinned to that profile,
27070
+ so deleting a profile does not leave its schedules pointing at a name that no longer exists. Omit 'from' to move
27071
+ every schedule, which re-pins the whole set onto one profile. Schedules already pinned to the target are
27072
+ skipped.
27073
+ tags:
27074
+ - schedules
27075
+ requestBody:
27076
+ required: true
27077
+ content:
27078
+ application/json:
27079
+ schema:
27080
+ type: object
27081
+ properties:
27082
+ from:
27083
+ description: Inference profile key the schedules are pinned to now; omit to select every schedule
27084
+ type: string
27085
+ to:
27086
+ type: string
27087
+ description: Inference profile key to move them to; must be configured
27088
+ required:
27089
+ - to
27090
+ responses:
27091
+ "200":
27092
+ description: Successful response
27093
+ content:
27094
+ application/json:
27095
+ schema:
27096
+ type: object
27097
+ properties:
27098
+ reassigned:
27099
+ type: number
27100
+ description: Number of schedules moved
27101
+ required:
27102
+ - reassigned
27103
+ additionalProperties: false
27027
27104
  /v1/schedules/usage-summary:
27028
27105
  get:
27029
27106
  operationId: schedules_usagesummary_get
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608052357.8f326c2",
3
+ "version": "0.11.2-dev.202608060040.fd8f444",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,36 +1,22 @@
1
1
  /**
2
- * Verifies that the memory-v3-live cache-anchor signal surfaces on every
3
- * `SendMessageOptions.config` the loop emits. When v3-live is on, the latest
4
- * user message carries a volatile `<memory>` block, so the loop sets
5
- * `providerConfig.mutableLatestUserMessage` to tell the provider to anchor its
6
- * long-TTL cache breakpoint on the most recent STABLE message instead.
2
+ * Verifies how the loop derives the `mutableLatestUserMessage` cache-anchor
3
+ * signal on `SendMessageOptions.config`.
7
4
  *
8
- * The loop reads `config.memory.v3.live` directly where it assembles
9
- * `providerConfig`, so the signal is sourced from config rather than a run
10
- * option. When v3-live is off the field is omitted (not `false`/`undefined`) so
11
- * the wire stays byte-identical to today.
5
+ * The signal means "the latest user message's bytes do not recur next turn", so
6
+ * it is set from the history actually being sent: only when the latest user
7
+ * message carries a memory-v3 `<memory_spotlight>` block, the one injected
8
+ * block that is strip-and-replaced on the tail every turn. Every other injected
9
+ * block is frozen into history and re-renders byte-identically, so turns
10
+ * without a spotlight keep a normal cache anchor even when memory-v3 is live.
11
+ *
12
+ * When no spotlight is present the field is omitted entirely (not `false`) so
13
+ * the wire stays byte-identical for conversations that never see one.
12
14
  */
13
15
 
14
- import { afterEach, describe, expect, mock, test } from "bun:test";
15
-
16
- // AgentLoop reads the v3-live gate (`config.memory.v3.live`) via
17
- // `isMemoryV3Live` to decide the cache-anchor signal; drive it per-test.
18
- let memoryV3LiveSlot = false;
19
- mock.module("../config/memory-v3-gate.js", () => ({
20
- isMemoryV3Live: () => memoryV3LiveSlot,
21
- isV3TierActive: () => memoryV3LiveSlot,
22
- isMemoryEnabled: (config?: { memory?: { enabled?: boolean } }) =>
23
- config?.memory?.enabled !== false,
24
- usesConceptPageMemory: (memory?: {
25
- enabled?: boolean;
26
- v2?: { enabled?: boolean };
27
- v3?: { live?: boolean };
28
- }) =>
29
- memory?.enabled !== false &&
30
- (memory?.v3?.live === true || memory?.v2?.enabled === true),
31
- }));
16
+ import { describe, expect, test } from "bun:test";
32
17
 
33
18
  import { AgentLoop } from "../agent/loop.js";
19
+ import { wrapMemorySpotlightBlock } from "../plugins/defaults/memory/memory-marker.js";
34
20
  import type {
35
21
  Message,
36
22
  Provider,
@@ -44,6 +30,15 @@ const userMessage: Message = {
44
30
  content: [{ type: "text", text: "hi" }],
45
31
  };
46
32
 
33
+ /** A user turn whose tail block is the ephemeral memory-v3 spotlight. */
34
+ const spotlightUserMessage: Message = {
35
+ role: "user",
36
+ content: [
37
+ { type: "text", text: "hi" },
38
+ { type: "text", text: wrapMemorySpotlightBlock("recalled: Alice's plan") },
39
+ ],
40
+ };
41
+
47
42
  function textResponse(text: string): ProviderResponse {
48
43
  return {
49
44
  content: [{ type: "text", text }],
@@ -87,40 +82,52 @@ function makeRecordingProvider(responses: ProviderResponse[]): {
87
82
  return { provider, configs: () => configs };
88
83
  }
89
84
 
90
- describe("AgentLoop.run — mutableLatestUserMessage from memory-v3-live", () => {
91
- afterEach(() => {
92
- memoryV3LiveSlot = false;
93
- });
85
+ const echoTool: ToolDefinition = {
86
+ name: "echo",
87
+ description: "Echo back the input",
88
+ input_schema: {
89
+ type: "object",
90
+ properties: { value: { type: "string" } },
91
+ },
92
+ };
94
93
 
95
- test("sets mutableLatestUserMessage on every LLM call when memory-v3-live is on (multi-turn)", async () => {
96
- // GIVEN memory-v3-live is enabled
97
- memoryV3LiveSlot = true;
94
+ describe("AgentLoop.run: mutableLatestUserMessage from spotlight presence", () => {
95
+ test("sets mutableLatestUserMessage when the latest user message carries a spotlight block", async () => {
96
+ // GIVEN a provider that records the config of each LLM call
97
+ const { provider, configs } = makeRecordingProvider([textResponse("done")]);
98
+ const loop = new AgentLoop({
99
+ provider,
100
+ systemPrompt: "system",
101
+ conversationId: "test-conversation",
102
+ config: { maxTokens: 1024 },
103
+ });
98
104
 
99
- // AND a provider that records the config of each LLM call across a tool round-trip
100
- const { provider, configs } = makeRecordingProvider([
101
- toolUseResponse("t1", "echo", { value: "first" }),
102
- textResponse("done"),
103
- ]);
104
- const dummyTools: ToolDefinition[] = [
105
- {
106
- name: "echo",
107
- description: "Echo back the input",
108
- input_schema: {
109
- type: "object",
110
- properties: { value: { type: "string" } },
111
- },
112
- },
113
- ];
105
+ // WHEN the loop runs over a history whose tail carries the spotlight
106
+ await loop.run({
107
+ requestId: "test-request",
108
+ messages: [spotlightUserMessage],
109
+ onEvent: () => {},
110
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
111
+ callSite: "mainAgent",
112
+ });
113
+
114
+ // THEN the send carries the cache-anchor signal
115
+ expect(configs()).toHaveLength(1);
116
+ expect(configs()[0]?.mutableLatestUserMessage).toBe(true);
117
+ });
118
+
119
+ test("omits mutableLatestUserMessage when the latest user message carries no spotlight", async () => {
120
+ // GIVEN a provider that records the config of each LLM call
121
+ const { provider, configs } = makeRecordingProvider([textResponse("hi")]);
114
122
  const loop = new AgentLoop({
115
- provider: provider,
123
+ provider,
116
124
  systemPrompt: "system",
117
125
  conversationId: "test-conversation",
118
126
  config: { maxTokens: 1024 },
119
- tools: dummyTools,
120
- toolExecutor: async () => ({ content: "ok", isError: false }),
121
127
  });
122
128
 
123
- // WHEN the loop runs
129
+ // WHEN the loop runs over a spotlight-free history: the shape memory-v3
130
+ // produces whenever it selects nothing to spotlight
124
131
  await loop.run({
125
132
  requestId: "test-request",
126
133
  messages: [userMessage],
@@ -129,21 +136,16 @@ describe("AgentLoop.run — mutableLatestUserMessage from memory-v3-live", () =>
129
136
  callSite: "mainAgent",
130
137
  });
131
138
 
132
- // THEN every send (initial + tool round-trip) carries the cache-anchor signal
133
- expect(configs()).toHaveLength(2);
134
- for (const cfg of configs()) {
135
- expect(cfg?.mutableLatestUserMessage).toBe(true);
136
- }
139
+ // THEN the field is omitted entirely, not carried as false/undefined
140
+ expect(configs()).toHaveLength(1);
141
+ expect("mutableLatestUserMessage" in (configs()[0] ?? {})).toBe(false);
137
142
  });
138
143
 
139
- test("omits mutableLatestUserMessage when memory-v3-live is off (flag-off byte-identity)", async () => {
140
- // GIVEN memory-v3-live is off (no override; registry default is false)
141
- memoryV3LiveSlot = false;
142
-
143
- // AND a provider that records the config of each LLM call
144
+ test("a text block merely opening with the spotlight tag is not treated as volatile", async () => {
145
+ // GIVEN a user message discussing the marker rather than carrying one
144
146
  const { provider, configs } = makeRecordingProvider([textResponse("hi")]);
145
147
  const loop = new AgentLoop({
146
- provider: provider,
148
+ provider,
147
149
  systemPrompt: "system",
148
150
  conversationId: "test-conversation",
149
151
  config: { maxTokens: 1024 },
@@ -152,14 +154,59 @@ describe("AgentLoop.run — mutableLatestUserMessage from memory-v3-live", () =>
152
154
  // WHEN the loop runs
153
155
  await loop.run({
154
156
  requestId: "test-request",
155
- messages: [userMessage],
157
+ messages: [
158
+ {
159
+ role: "user",
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: "<memory_spotlight>\nwhat does this tag do?",
164
+ },
165
+ ],
166
+ },
167
+ ],
156
168
  onEvent: () => {},
157
169
  trust: { sourceChannel: "vellum", trustClass: "unknown" },
158
170
  callSite: "mainAgent",
159
171
  });
160
172
 
161
- // THEN the field is omitted entirely, not carried as false/undefined
173
+ // THEN the unterminated wrapper does not count: matching the full-wrapper
174
+ // requirement the per-turn strip applies
162
175
  expect(configs()).toHaveLength(1);
163
176
  expect("mutableLatestUserMessage" in (configs()[0] ?? {})).toBe(false);
164
177
  });
178
+
179
+ test("the signal holds for every request in a tool loop", async () => {
180
+ // GIVEN a spotlight-bearing opening message and a tool round-trip
181
+ const { provider, configs } = makeRecordingProvider([
182
+ toolUseResponse("t1", "echo", { value: "first" }),
183
+ textResponse("done"),
184
+ ]);
185
+ const loop = new AgentLoop({
186
+ provider,
187
+ systemPrompt: "system",
188
+ conversationId: "test-conversation",
189
+ config: { maxTokens: 1024 },
190
+ tools: [echoTool],
191
+ toolExecutor: async () => ({ content: "ok", isError: false }),
192
+ });
193
+
194
+ // WHEN the loop runs
195
+ await loop.run({
196
+ requestId: "test-request",
197
+ messages: [spotlightUserMessage],
198
+ onEvent: () => {},
199
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
200
+ callSite: "mainAgent",
201
+ });
202
+
203
+ // THEN both calls report the turn as volatile. The signal describes the
204
+ // turn-starting message, not whichever user message happens to be last, so
205
+ // the trailing tool-result turn does not clear it. Holding it steady is
206
+ // what keeps the provider marking that one block at a single TTL for the
207
+ // whole turn instead of writing it twice.
208
+ expect(configs()).toHaveLength(2);
209
+ expect(configs()[0]?.mutableLatestUserMessage).toBe(true);
210
+ expect(configs()[1]?.mutableLatestUserMessage).toBe(true);
211
+ });
165
212
  });
@@ -781,10 +781,11 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
781
781
  expect(prevLast.cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
782
782
  });
783
783
 
784
- test("mutableLatestUserMessage: during a tool-use loop placement is unchanged (block is fixed within the turn)", async () => {
785
- // Turn-start is not the last message (tool_result follows), so the
786
- // turn-start block is fixed for the rest of the turn — the flag must not
787
- // move the anchor.
784
+ test("mutableLatestUserMessage: a volatile turn start keeps the 5m TTL through its tool-use loop", async () => {
785
+ // The turn start is fixed for the rest of the turn, so it stays markable;
786
+ // but it is volatile ACROSS turns, so the long TTL would buy a write that
787
+ // can never be read back. Upgrading it here would also mark the same block
788
+ // at a second TTL, billing two writes for one reusable prefix.
788
789
  const messages: Message[] = [
789
790
  userMsg("Turn 1"),
790
791
  assistantMsg("Response 1"),
@@ -804,14 +805,12 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
804
805
  }>;
805
806
  }>;
806
807
 
807
- // Turn-start (Turn 2, index 2) still gets the 1h anchor — within a tool-use
808
- // loop the block is fixed, so the volatile-latest flag must not move it.
809
808
  const turn2 = sent[2];
810
809
  const turn2Last = turn2.content[turn2.content.length - 1];
811
- expect(turn2Last.cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
810
+ expect(turn2Last.cache_control).toEqual({ type: "ephemeral", ttl: "5m" });
812
811
  });
813
812
 
814
- test("mutableLatestUserMessage: first turn with no previous user message does not throw and applies no long-TTL anchor", async () => {
813
+ test("mutableLatestUserMessage: volatile first turn carries the 5m tail but no long-TTL anchor", async () => {
815
814
  await provider.sendMessage([userMsg("First ever turn (volatile)")], {
816
815
  config: { mutableLatestUserMessage: true },
817
816
  });
@@ -823,9 +822,28 @@ describe("AnthropicProvider — Cache-Control Characterization", () => {
823
822
  cache_control?: { type: string; ttl?: string };
824
823
  }>;
825
824
  }>;
826
- // Only user message is volatile and there is no prior stable one, so no
827
- // 1h anchor and no 5m tail bridge land on any user message — there is no
828
- // previous-turn anchor to bridge to. Graceful, no throw.
825
+ // The only user message is volatile, so it gets no 1h anchor: those bytes
826
+ // do not recur next turn. It does get the 5m tail; the message is fixed
827
+ // within its own turn, so the tool-loop calls that follow read the system
828
+ // prompt, tools, and opening message back instead of re-writing them.
829
+ const lastBlock = sent[0].content[sent[0].content.length - 1];
830
+ expect(lastBlock.cache_control).toEqual({ type: "ephemeral", ttl: "5m" });
831
+ });
832
+
833
+ test("mutableLatestUserMessage: volatile first turn with disableTurnStartCache places no message breakpoint", async () => {
834
+ // One-shot call sites opt out of paying for a turn-start write; the 5m
835
+ // bridge lands on that same block, so it honors the same opt-out.
836
+ await provider.sendMessage([userMsg("One-shot (volatile)")], {
837
+ config: { mutableLatestUserMessage: true, disableTurnStartCache: true },
838
+ });
839
+
840
+ const sent = lastStreamParams!.messages as Array<{
841
+ role: string;
842
+ content: Array<{
843
+ type: string;
844
+ cache_control?: { type: string; ttl?: string };
845
+ }>;
846
+ }>;
829
847
  const lastBlock = sent[0].content[sent[0].content.length - 1];
830
848
  expect(lastBlock.cache_control).toBeUndefined();
831
849
  });