@vellumai/assistant 0.11.2-dev.202608052324.a11da34 → 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.
- package/openapi.yaml +84 -4
- package/package.json +1 -1
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +113 -66
- package/src/__tests__/anthropic-provider.test.ts +29 -11
- package/src/__tests__/conversation-agent-loop.test.ts +146 -0
- package/src/__tests__/file-read-container-boundary.test.ts +137 -0
- package/src/__tests__/file-read-tool.test.ts +21 -9
- package/src/__tests__/openai-responses-prompt-cache.test.ts +5 -2
- package/src/__tests__/path-policy.test.ts +164 -1
- package/src/__tests__/prompt-cache-cross-turn-stability.test.ts +593 -0
- package/src/__tests__/queued-message-steer-actor-scoping.test.ts +164 -0
- package/src/__tests__/schedule-routes.test.ts +488 -8
- package/src/__tests__/schedule-store.test.ts +169 -0
- package/src/__tests__/scheduler-wake.test.ts +5 -0
- package/src/__tests__/steer-on-enqueue-question.test.ts +3 -2
- package/src/agent/loop.ts +13 -10
- package/src/cli/commands/__tests__/conversations-wake.test.ts +51 -6
- package/src/cli/commands/conversations.ts +7 -3
- package/src/cli/commands/schedules.help.ts +14 -12
- package/src/cli/commands/schedules.ts +7 -2
- package/src/config/bundled-skills/schedule/SKILL.md +5 -1
- package/src/config/bundled-skills/schedule/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +0 -8
- package/src/context/strip-injections.ts +42 -0
- package/src/daemon/conversation-agent-loop.ts +53 -9
- package/src/daemon/handlers/conversations.ts +38 -11
- package/src/persistence/conversation-crud.ts +44 -0
- package/src/persistence/migrations/363-backfill-schedule-inference-profile.test.ts +229 -0
- package/src/persistence/migrations/363-backfill-schedule-inference-profile.ts +127 -0
- package/src/persistence/steps.ts +9 -0
- package/src/providers/anthropic/client.ts +37 -37
- package/src/providers/openai/responses-provider.ts +9 -22
- package/src/providers/types.ts +26 -6
- package/src/runtime/routes/__tests__/wake-conversation-routes.test.ts +60 -2
- package/src/runtime/routes/conversation-query-routes.ts +24 -3
- package/src/runtime/routes/schedule-routes.ts +145 -7
- package/src/runtime/routes/wake-conversation-routes.ts +23 -0
- package/src/schedule/inference-profile.ts +64 -3
- package/src/schedule/schedule-store.ts +51 -6
- package/src/tools/filesystem/read.ts +4 -10
- package/src/tools/schedule/list.ts +2 -1
- package/src/tools/schedule/update.ts +10 -6
- package/src/tools/shared/filesystem/path-policy.ts +70 -5
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:
|
|
@@ -25679,8 +25695,10 @@ paths:
|
|
|
25679
25695
|
- type: string
|
|
25680
25696
|
- type: "null"
|
|
25681
25697
|
description:
|
|
25682
|
-
Inference profile (llm.profiles key) the schedule's runs use. Omitted or null
|
|
25683
|
-
|
|
25698
|
+
Inference profile (llm.profiles key) the schedule's runs use. Omitted or null pins the schedule to the
|
|
25699
|
+
currently resolved default profile, so its model does not move when that default changes.
|
|
25700
|
+
Workflow-mode schedules resolve a model per workflow step, so the pin is recorded but does not
|
|
25701
|
+
govern their runs.
|
|
25684
25702
|
required:
|
|
25685
25703
|
- name
|
|
25686
25704
|
- expression
|
|
@@ -25799,6 +25817,8 @@ paths:
|
|
|
25799
25817
|
- type: "null"
|
|
25800
25818
|
isOneShot:
|
|
25801
25819
|
type: boolean
|
|
25820
|
+
isDeferred:
|
|
25821
|
+
type: boolean
|
|
25802
25822
|
required:
|
|
25803
25823
|
- id
|
|
25804
25824
|
- name
|
|
@@ -25830,6 +25850,7 @@ paths:
|
|
|
25830
25850
|
- wakeConversationId
|
|
25831
25851
|
- workflowName
|
|
25832
25852
|
- isOneShot
|
|
25853
|
+
- isDeferred
|
|
25833
25854
|
additionalProperties: false
|
|
25834
25855
|
description: The created schedule
|
|
25835
25856
|
required:
|
|
@@ -25965,6 +25986,8 @@ paths:
|
|
|
25965
25986
|
- type: "null"
|
|
25966
25987
|
isOneShot:
|
|
25967
25988
|
type: boolean
|
|
25989
|
+
isDeferred:
|
|
25990
|
+
type: boolean
|
|
25968
25991
|
required:
|
|
25969
25992
|
- id
|
|
25970
25993
|
- name
|
|
@@ -25996,6 +26019,7 @@ paths:
|
|
|
25996
26019
|
- wakeConversationId
|
|
25997
26020
|
- workflowName
|
|
25998
26021
|
- isOneShot
|
|
26022
|
+
- isDeferred
|
|
25999
26023
|
additionalProperties: false
|
|
26000
26024
|
description: Updated schedule list
|
|
26001
26025
|
required:
|
|
@@ -26128,6 +26152,8 @@ paths:
|
|
|
26128
26152
|
- type: "null"
|
|
26129
26153
|
isOneShot:
|
|
26130
26154
|
type: boolean
|
|
26155
|
+
isDeferred:
|
|
26156
|
+
type: boolean
|
|
26131
26157
|
required:
|
|
26132
26158
|
- id
|
|
26133
26159
|
- name
|
|
@@ -26159,6 +26185,7 @@ paths:
|
|
|
26159
26185
|
- wakeConversationId
|
|
26160
26186
|
- workflowName
|
|
26161
26187
|
- isOneShot
|
|
26188
|
+
- isDeferred
|
|
26162
26189
|
additionalProperties: false
|
|
26163
26190
|
description: Schedule object
|
|
26164
26191
|
required:
|
|
@@ -26231,8 +26258,9 @@ paths:
|
|
|
26231
26258
|
- type: string
|
|
26232
26259
|
- type: "null"
|
|
26233
26260
|
description:
|
|
26234
|
-
Inference profile (llm.profiles key) the schedule's runs use; null
|
|
26235
|
-
|
|
26261
|
+
Inference profile (llm.profiles key) the schedule's runs use; null re-pins the schedule to the currently
|
|
26262
|
+
resolved default profile. Workflow-mode schedules resolve a model per workflow step, so the pin is
|
|
26263
|
+
recorded but does not govern their runs.
|
|
26236
26264
|
responses:
|
|
26237
26265
|
"200":
|
|
26238
26266
|
description: Successful response
|
|
@@ -26350,6 +26378,8 @@ paths:
|
|
|
26350
26378
|
- type: "null"
|
|
26351
26379
|
isOneShot:
|
|
26352
26380
|
type: boolean
|
|
26381
|
+
isDeferred:
|
|
26382
|
+
type: boolean
|
|
26353
26383
|
required:
|
|
26354
26384
|
- id
|
|
26355
26385
|
- name
|
|
@@ -26381,6 +26411,7 @@ paths:
|
|
|
26381
26411
|
- wakeConversationId
|
|
26382
26412
|
- workflowName
|
|
26383
26413
|
- isOneShot
|
|
26414
|
+
- isDeferred
|
|
26384
26415
|
additionalProperties: false
|
|
26385
26416
|
description: Updated schedule list
|
|
26386
26417
|
required:
|
|
@@ -26516,6 +26547,8 @@ paths:
|
|
|
26516
26547
|
- type: "null"
|
|
26517
26548
|
isOneShot:
|
|
26518
26549
|
type: boolean
|
|
26550
|
+
isDeferred:
|
|
26551
|
+
type: boolean
|
|
26519
26552
|
required:
|
|
26520
26553
|
- id
|
|
26521
26554
|
- name
|
|
@@ -26547,6 +26580,7 @@ paths:
|
|
|
26547
26580
|
- wakeConversationId
|
|
26548
26581
|
- workflowName
|
|
26549
26582
|
- isOneShot
|
|
26583
|
+
- isDeferred
|
|
26550
26584
|
additionalProperties: false
|
|
26551
26585
|
description: Updated schedule list
|
|
26552
26586
|
required:
|
|
@@ -26682,6 +26716,8 @@ paths:
|
|
|
26682
26716
|
- type: "null"
|
|
26683
26717
|
isOneShot:
|
|
26684
26718
|
type: boolean
|
|
26719
|
+
isDeferred:
|
|
26720
|
+
type: boolean
|
|
26685
26721
|
required:
|
|
26686
26722
|
- id
|
|
26687
26723
|
- name
|
|
@@ -26713,6 +26749,7 @@ paths:
|
|
|
26713
26749
|
- wakeConversationId
|
|
26714
26750
|
- workflowName
|
|
26715
26751
|
- isOneShot
|
|
26752
|
+
- isDeferred
|
|
26716
26753
|
additionalProperties: false
|
|
26717
26754
|
description: Updated schedule list
|
|
26718
26755
|
required:
|
|
@@ -26985,6 +27022,8 @@ paths:
|
|
|
26985
27022
|
- type: "null"
|
|
26986
27023
|
isOneShot:
|
|
26987
27024
|
type: boolean
|
|
27025
|
+
isDeferred:
|
|
27026
|
+
type: boolean
|
|
26988
27027
|
required:
|
|
26989
27028
|
- id
|
|
26990
27029
|
- name
|
|
@@ -27016,11 +27055,52 @@ paths:
|
|
|
27016
27055
|
- wakeConversationId
|
|
27017
27056
|
- workflowName
|
|
27018
27057
|
- isOneShot
|
|
27058
|
+
- isDeferred
|
|
27019
27059
|
additionalProperties: false
|
|
27020
27060
|
description: Updated schedule list
|
|
27021
27061
|
required:
|
|
27022
27062
|
- schedules
|
|
27023
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
|
|
27024
27104
|
/v1/schedules/usage-summary:
|
|
27025
27105
|
get:
|
|
27026
27106
|
operationId: schedules_usagesummary_get
|
package/package.json
CHANGED
|
@@ -1,36 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Verifies
|
|
3
|
-
* `SendMessageOptions.config
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
133
|
-
expect(configs()).toHaveLength(
|
|
134
|
-
|
|
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("
|
|
140
|
-
// GIVEN
|
|
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
|
|
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: [
|
|
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
|
|
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:
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
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: "
|
|
810
|
+
expect(turn2Last.cache_control).toEqual({ type: "ephemeral", ttl: "5m" });
|
|
812
811
|
});
|
|
813
812
|
|
|
814
|
-
test("mutableLatestUserMessage: first turn
|
|
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
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
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
|
});
|