@librechat/agents 3.7.15 → 3.7.17
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/dist/cjs/agents/AgentContext.cjs +91 -0
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/agents/projection.cjs +2 -1
- package/dist/cjs/agents/projection.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +47 -6
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +20 -7
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/preempt.cjs +1 -10
- package/dist/cjs/llm/preempt.cjs.map +1 -1
- package/dist/cjs/main.cjs +23 -3
- package/dist/cjs/messages/contextPruning.cjs +1 -1
- package/dist/cjs/messages/contextPruning.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +29 -1
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/fading.cjs +122 -0
- package/dist/cjs/messages/fading.cjs.map +1 -0
- package/dist/cjs/messages/format.cjs +1 -1
- package/dist/cjs/messages/index.cjs +1 -0
- package/dist/cjs/messages/prune.cjs +364 -236
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +27 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +213 -8
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/session/JsonlSessionStore.cjs +41 -0
- package/dist/cjs/session/JsonlSessionStore.cjs.map +1 -1
- package/dist/cjs/session/index.cjs +1 -1
- package/dist/cjs/stream.cjs +1 -1
- package/dist/cjs/summarization/node.cjs +1 -1
- package/dist/cjs/tools/ToolNode.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +2 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +2 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
- package/dist/cjs/utils/errors.cjs +3 -1
- package/dist/cjs/utils/errors.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +1 -1
- package/dist/cjs/utils/truncation.cjs +9 -0
- package/dist/cjs/utils/truncation.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +92 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/agents/projection.mjs +2 -1
- package/dist/esm/agents/projection.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +47 -6
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +20 -7
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +2 -2
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/preempt.mjs +1 -10
- package/dist/esm/llm/preempt.mjs.map +1 -1
- package/dist/esm/main.mjs +6 -5
- package/dist/esm/messages/contextPruning.mjs +1 -1
- package/dist/esm/messages/contextPruning.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +29 -2
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/fading.mjs +108 -0
- package/dist/esm/messages/fading.mjs.map +1 -0
- package/dist/esm/messages/format.mjs +1 -1
- package/dist/esm/messages/index.mjs +1 -0
- package/dist/esm/messages/prune.mjs +363 -235
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +27 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +213 -8
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/session/JsonlSessionStore.mjs +41 -0
- package/dist/esm/session/JsonlSessionStore.mjs.map +1 -1
- package/dist/esm/session/index.mjs +1 -1
- package/dist/esm/stream.mjs +1 -1
- package/dist/esm/summarization/node.mjs +1 -1
- package/dist/esm/tools/ToolNode.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +2 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +2 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
- package/dist/esm/utils/errors.mjs +3 -1
- package/dist/esm/utils/errors.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +1 -1
- package/dist/esm/utils/truncation.mjs +7 -1
- package/dist/esm/utils/truncation.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +32 -1
- package/dist/types/agents/projection.d.ts +3 -1
- package/dist/types/graphs/Graph.d.ts +14 -1
- package/dist/types/graphs/MultiAgentGraph.d.ts +7 -0
- package/dist/types/messages/contextPruning.d.ts +2 -0
- package/dist/types/messages/core.d.ts +2 -0
- package/dist/types/messages/fading.d.ts +81 -0
- package/dist/types/messages/index.d.ts +1 -0
- package/dist/types/messages/prune.d.ts +71 -24
- package/dist/types/run.d.ts +16 -0
- package/dist/types/session/AgentSession.d.ts +15 -0
- package/dist/types/session/JsonlSessionStore.d.ts +4 -1
- package/dist/types/session/types.d.ts +9 -0
- package/dist/types/tools/subagent/SubagentReplay.d.ts +3 -1
- package/dist/types/types/graph.d.ts +23 -0
- package/dist/types/types/run.d.ts +10 -0
- package/dist/types/utils/truncation.d.ts +9 -0
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +175 -1
- package/src/agents/projection.ts +4 -0
- package/src/graphs/Graph.ts +97 -11
- package/src/graphs/MultiAgentGraph.ts +54 -6
- package/src/llm/openai/utils/index.ts +3 -1
- package/src/llm/preempt.ts +4 -17
- package/src/messages/contextPruning.ts +7 -1
- package/src/messages/core.ts +52 -0
- package/src/messages/fading.ts +301 -0
- package/src/messages/index.ts +1 -0
- package/src/messages/prune.ts +719 -507
- package/src/run.ts +52 -0
- package/src/session/AgentSession.ts +397 -8
- package/src/session/JsonlSessionStore.ts +71 -0
- package/src/session/types.ts +10 -0
- package/src/tools/subagent/SubagentExecutor.ts +6 -0
- package/src/tools/subagent/SubagentReplay.ts +15 -2
- package/src/types/graph.ts +25 -0
- package/src/types/run.ts +10 -0
- package/src/utils/errors.ts +17 -1
- package/src/utils/truncation.ts +25 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { FadingTier } from '@/types/graph';
|
|
2
|
+
import {
|
|
3
|
+
calculateMaxToolCallInputChars,
|
|
4
|
+
calculateMaxToolResultChars,
|
|
5
|
+
} from '@/utils/truncation';
|
|
6
|
+
|
|
7
|
+
export const FADING_TIER_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
/** Context pressure at which observation masking activates. */
|
|
10
|
+
export const PRESSURE_THRESHOLD_MASKING = 0.8;
|
|
11
|
+
|
|
12
|
+
/** Smallest token budget the ladder can shrink to; keeps the emergency floor near 200 chars. */
|
|
13
|
+
export const FADING_MIN_BUDGET_TOKENS = 170;
|
|
14
|
+
|
|
15
|
+
/** Floor for masked (consumed) tool results. */
|
|
16
|
+
export const MASKED_RESULT_MIN_CHARS = 300;
|
|
17
|
+
|
|
18
|
+
/** Fraction of a fresh result's cap that a masked (consumed) result keeps. */
|
|
19
|
+
const MASKED_RESULT_CAP_RATIO = 0.1;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pressure bands expressed as extra rungs on top of the fit rung, so the
|
|
23
|
+
* budget factors land at ×0.5 (85 %), ×0.25 (90 %) and ×0.0625 (99 %).
|
|
24
|
+
*/
|
|
25
|
+
const PRESSURE_BAND_RUNGS: readonly (readonly [number, number])[] = [
|
|
26
|
+
[0.99, 4],
|
|
27
|
+
[0.9, 2],
|
|
28
|
+
[0.85, 1],
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export type FadingSignals = {
|
|
32
|
+
/** calibratedTotal / pruningBudget, measured before any truncation. */
|
|
33
|
+
contextPressure: number;
|
|
34
|
+
/** (pruningBudget − instruction tokens) ÷ calibrationRatio, in raw token space. */
|
|
35
|
+
effectiveRawTokens: number;
|
|
36
|
+
summarizationEnabled: boolean;
|
|
37
|
+
/** Largest number of parallel calls observed in one assistant exchange. */
|
|
38
|
+
toolExchangeWidth?: number;
|
|
39
|
+
/** Recovery paths force at least this rung on the current window's ladder. */
|
|
40
|
+
minRung?: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type FadingCaps = {
|
|
44
|
+
budgetTokens: number;
|
|
45
|
+
/** Cap for tool results the model has not answered yet. */
|
|
46
|
+
resultChars: number;
|
|
47
|
+
/** Cap for consumed tool results; equals `resultChars` until masking activates. */
|
|
48
|
+
consumedChars: number;
|
|
49
|
+
/** Cap for historical tool-call inputs. */
|
|
50
|
+
inputChars: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function floorBudgetTokens(window: number): number {
|
|
54
|
+
return Math.min(FADING_MIN_BUDGET_TOKENS, window);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Tier for a conversation that has never faded: the whole window, nothing masked. */
|
|
58
|
+
export function createFadingTier(window: number): FadingTier {
|
|
59
|
+
return { v: FADING_TIER_VERSION, budgetTokens: window, masked: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isFadingTier(value: unknown): value is FadingTier {
|
|
63
|
+
if (typeof value !== 'object' || value === null) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const { v, budgetTokens, masked, latched } = value as Partial<
|
|
67
|
+
Record<keyof FadingTier, unknown>
|
|
68
|
+
>;
|
|
69
|
+
return (
|
|
70
|
+
v === FADING_TIER_VERSION &&
|
|
71
|
+
typeof budgetTokens === 'number' &&
|
|
72
|
+
Number.isFinite(budgetTokens) &&
|
|
73
|
+
budgetTokens > 0 &&
|
|
74
|
+
typeof masked === 'boolean' &&
|
|
75
|
+
(latched === undefined || latched === true)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Deepest rung for a window: the point where the budget reaches its floor. */
|
|
80
|
+
export function maxFadingRung(window: number): number {
|
|
81
|
+
const floor = floorBudgetTokens(window);
|
|
82
|
+
if (!(floor > 0)) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
return Math.max(0, Math.ceil(Math.log2(window / floor)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Token budget at a rung: the window halved per rung, never below the floor. */
|
|
89
|
+
export function fadingBudgetTokens(window: number, rung: number): number {
|
|
90
|
+
return Math.max(floorBudgetTokens(window), Math.floor(window / 2 ** rung));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Restores a tier persisted by the host. The budget is absolute, so a tier
|
|
95
|
+
* survives a mid-run budget correction and the return to the normal window
|
|
96
|
+
* on the next run; it is only clamped to the current window. Anything
|
|
97
|
+
* invalid starts fresh.
|
|
98
|
+
*/
|
|
99
|
+
export function seedFadingTier(window: number, seed?: unknown): FadingTier {
|
|
100
|
+
if (!isFadingTier(seed)) {
|
|
101
|
+
return createFadingTier(window);
|
|
102
|
+
}
|
|
103
|
+
/** The ladder never produces a budget below its floor, so a persisted one
|
|
104
|
+
* is corrupt; clamping it up keeps every cap positive. */
|
|
105
|
+
const budgetTokens = Math.max(
|
|
106
|
+
floorBudgetTokens(window),
|
|
107
|
+
Math.min(seed.budgetTokens, window)
|
|
108
|
+
);
|
|
109
|
+
/** Only a tier that was tightened, masked, or clamped carries information.
|
|
110
|
+
* A fresh tier re-seeded through a pruner rebuild must stay fresh, or it
|
|
111
|
+
* would be reported and pin later runs to this window. */
|
|
112
|
+
const informative =
|
|
113
|
+
seed.latched === true ||
|
|
114
|
+
seed.masked ||
|
|
115
|
+
budgetTokens !== seed.budgetTokens ||
|
|
116
|
+
budgetTokens < window;
|
|
117
|
+
if (!informative) {
|
|
118
|
+
return createFadingTier(window);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
v: FADING_TIER_VERSION,
|
|
122
|
+
budgetTokens,
|
|
123
|
+
masked: seed.masked,
|
|
124
|
+
latched: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Shallowest rung whose fresh-result cap is at most `targetChars`. */
|
|
129
|
+
export function fadingRungForResultChars(
|
|
130
|
+
window: number,
|
|
131
|
+
targetChars: number,
|
|
132
|
+
maxToolResultChars?: number
|
|
133
|
+
): number {
|
|
134
|
+
const deepest = maxFadingRung(window);
|
|
135
|
+
for (let rung = 0; rung < deepest; rung++) {
|
|
136
|
+
const windowResultChars = calculateMaxToolResultChars(
|
|
137
|
+
fadingBudgetTokens(window, rung)
|
|
138
|
+
);
|
|
139
|
+
const effectiveResultChars =
|
|
140
|
+
maxToolResultChars == null
|
|
141
|
+
? windowResultChars
|
|
142
|
+
: Math.min(windowResultChars, maxToolResultChars);
|
|
143
|
+
if (effectiveResultChars <= targetChars) {
|
|
144
|
+
return rung;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return deepest;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Shallowest rung at which the configured number of parallel tool-call inputs
|
|
152
|
+
* and fresh results fit together within `rawTokens`, the effective budget in
|
|
153
|
+
* raw token space. This prevents orphan repair from dropping results merely
|
|
154
|
+
* because their shared assistant message used the room left by a configured
|
|
155
|
+
* result cap.
|
|
156
|
+
*/
|
|
157
|
+
export function fadingRungForBudget(
|
|
158
|
+
window: number,
|
|
159
|
+
rawTokens: number,
|
|
160
|
+
maxToolResultChars?: number,
|
|
161
|
+
toolExchangeWidth = 1
|
|
162
|
+
): number {
|
|
163
|
+
if (!(rawTokens > 0)) {
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
const targetChars = Math.floor(rawTokens) * 4;
|
|
167
|
+
const width = Math.max(1, Math.floor(toolExchangeWidth));
|
|
168
|
+
const deepest = maxFadingRung(window);
|
|
169
|
+
for (let rung = 0; rung < deepest; rung++) {
|
|
170
|
+
const budgetTokens = fadingBudgetTokens(window, rung);
|
|
171
|
+
const windowResultChars = calculateMaxToolResultChars(budgetTokens);
|
|
172
|
+
const resultChars =
|
|
173
|
+
maxToolResultChars == null
|
|
174
|
+
? windowResultChars
|
|
175
|
+
: Math.min(windowResultChars, maxToolResultChars);
|
|
176
|
+
const inputChars = calculateMaxToolCallInputChars(budgetTokens);
|
|
177
|
+
if (width * (resultChars + inputChars) <= targetChars) {
|
|
178
|
+
return rung;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return deepest;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Shallowest rung at which one complete tool exchange (a fresh result plus its
|
|
186
|
+
* call input) fits within `targetChars`. The emergency pass uses it so a small
|
|
187
|
+
* configured result cap cannot satisfy the target while inputs stay uncapped.
|
|
188
|
+
*/
|
|
189
|
+
export function fadingRungForExchangeChars(
|
|
190
|
+
window: number,
|
|
191
|
+
targetChars: number,
|
|
192
|
+
maxToolResultChars?: number
|
|
193
|
+
): number {
|
|
194
|
+
const deepest = maxFadingRung(window);
|
|
195
|
+
for (let rung = 0; rung < deepest; rung++) {
|
|
196
|
+
const budgetTokens = fadingBudgetTokens(window, rung);
|
|
197
|
+
const windowResultChars = calculateMaxToolResultChars(budgetTokens);
|
|
198
|
+
const resultChars =
|
|
199
|
+
maxToolResultChars == null
|
|
200
|
+
? windowResultChars
|
|
201
|
+
: Math.min(windowResultChars, maxToolResultChars);
|
|
202
|
+
if (resultChars + calculateMaxToolCallInputChars(budgetTokens) <= targetChars) {
|
|
203
|
+
return rung;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return deepest;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Character caps for a tier; a pure function of `(budgetTokens, masked)`. */
|
|
210
|
+
export function resolveFadingCaps(
|
|
211
|
+
tier: FadingTier,
|
|
212
|
+
maxToolResultChars?: number
|
|
213
|
+
): FadingCaps {
|
|
214
|
+
const { budgetTokens } = tier;
|
|
215
|
+
const windowResultChars = calculateMaxToolResultChars(budgetTokens);
|
|
216
|
+
const resultChars =
|
|
217
|
+
maxToolResultChars != null
|
|
218
|
+
? Math.min(windowResultChars, maxToolResultChars)
|
|
219
|
+
: windowResultChars;
|
|
220
|
+
const consumedChars = tier.masked
|
|
221
|
+
? Math.min(
|
|
222
|
+
resultChars,
|
|
223
|
+
Math.max(
|
|
224
|
+
MASKED_RESULT_MIN_CHARS,
|
|
225
|
+
Math.floor(resultChars * MASKED_RESULT_CAP_RATIO)
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
: resultChars;
|
|
229
|
+
return {
|
|
230
|
+
budgetTokens,
|
|
231
|
+
resultChars,
|
|
232
|
+
consumedChars,
|
|
233
|
+
inputChars: calculateMaxToolCallInputChars(budgetTokens),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Advances a tier from this call's signals on the current window's ladder.
|
|
239
|
+
* The budget only ever shrinks and masking only ever activates, which is the
|
|
240
|
+
* hysteresis: drift in calibration, instruction overhead or message count
|
|
241
|
+
* can escalate once at a rung boundary but never oscillate. Returns the same
|
|
242
|
+
* object when nothing changes.
|
|
243
|
+
*
|
|
244
|
+
* - The fit rung guarantees the widest observed parallel tool exchange fits
|
|
245
|
+
* the effective budget as a complete input/result batch.
|
|
246
|
+
* - Pressure bands add rungs on top of the fit rung when summarization is
|
|
247
|
+
* off, mirroring the staged budget factors of progressive context fading.
|
|
248
|
+
* - A window larger than the latched budget (model switch) does not loosen
|
|
249
|
+
* the tier; only compaction, which rewrites the prefix anyway, resets it.
|
|
250
|
+
*/
|
|
251
|
+
export function resolveFadingTier(
|
|
252
|
+
tier: FadingTier,
|
|
253
|
+
window: number,
|
|
254
|
+
signals: FadingSignals,
|
|
255
|
+
maxToolResultChars?: number
|
|
256
|
+
): FadingTier {
|
|
257
|
+
const fitRung = fadingRungForBudget(
|
|
258
|
+
window,
|
|
259
|
+
signals.effectiveRawTokens,
|
|
260
|
+
maxToolResultChars,
|
|
261
|
+
signals.toolExchangeWidth
|
|
262
|
+
);
|
|
263
|
+
const bandRung = signals.summarizationEnabled
|
|
264
|
+
? 0
|
|
265
|
+
: (PRESSURE_BAND_RUNGS.find(
|
|
266
|
+
([threshold]) => signals.contextPressure >= threshold
|
|
267
|
+
)?.[1] ?? 0);
|
|
268
|
+
const rung = Math.min(
|
|
269
|
+
maxFadingRung(window),
|
|
270
|
+
Math.max(fitRung + bandRung, signals.minRung ?? 0)
|
|
271
|
+
);
|
|
272
|
+
const budgetTokens = Math.min(
|
|
273
|
+
tier.budgetTokens,
|
|
274
|
+
fadingBudgetTokens(window, rung)
|
|
275
|
+
);
|
|
276
|
+
const masked =
|
|
277
|
+
tier.masked || signals.contextPressure >= PRESSURE_THRESHOLD_MASKING;
|
|
278
|
+
if (budgetTokens === tier.budgetTokens && masked === tier.masked) {
|
|
279
|
+
return tier;
|
|
280
|
+
}
|
|
281
|
+
return { v: FADING_TIER_VERSION, budgetTokens, masked, latched: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether a tier carries information a host should persist: masking has
|
|
286
|
+
* activated or the budget sits below the pruner's window. A fresh tier only
|
|
287
|
+
* seeds what the next run derives on its own.
|
|
288
|
+
*/
|
|
289
|
+
export function isInformativeFadingTier(
|
|
290
|
+
tier: FadingTier | undefined,
|
|
291
|
+
window: number | undefined
|
|
292
|
+
): tier is FadingTier {
|
|
293
|
+
if (tier == null) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return (
|
|
297
|
+
tier.latched === true ||
|
|
298
|
+
tier.masked ||
|
|
299
|
+
(window != null && window > 0 && tier.budgetTokens < window)
|
|
300
|
+
);
|
|
301
|
+
}
|
package/src/messages/index.ts
CHANGED