@librechat/agents 3.3.6 → 3.3.8
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/graphs/MultiAgentGraph.cjs +21 -4
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/messages/format.cjs +124 -15
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/injected.cjs +10 -1
- package/dist/cjs/messages/injected.cjs.map +1 -1
- package/dist/cjs/prompts/activityLabel.cjs +29 -1
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
- package/dist/cjs/run.cjs +7 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +55 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/intentArg.cjs +78 -52
- package/dist/cjs/tools/intentArg.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +5 -5
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +21 -4
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/main.mjs +2 -2
- package/dist/esm/messages/format.mjs +124 -15
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/injected.mjs +10 -1
- package/dist/esm/messages/injected.mjs.map +1 -1
- package/dist/esm/prompts/activityLabel.mjs +29 -1
- package/dist/esm/prompts/activityLabel.mjs.map +1 -1
- package/dist/esm/run.mjs +7 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +55 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/intentArg.mjs +77 -53
- package/dist/esm/tools/intentArg.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +5 -5
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/messages/format.d.ts +9 -8
- package/dist/types/prompts/activityLabel.d.ts +8 -1
- package/dist/types/run.d.ts +1 -1
- package/dist/types/tools/intentArg.d.ts +74 -12
- package/dist/types/tools/search/tool.d.ts +5 -5
- package/dist/types/types/activityLabel.d.ts +8 -0
- package/dist/types/types/stream.d.ts +27 -2
- package/package.json +1 -1
- package/src/graphs/MultiAgentGraph.ts +18 -4
- package/src/messages/format.ts +222 -50
- package/src/messages/formatAgentMessages.test.ts +308 -6
- package/src/messages/injected.test.ts +18 -1
- package/src/messages/injected.ts +8 -1
- package/src/prompts/activityLabel.ts +48 -0
- package/src/run.ts +10 -1
- package/src/specs/activity-label-prompt.test.ts +93 -0
- package/src/summarization/__tests__/node.test.ts +188 -0
- package/src/summarization/node.ts +67 -0
- package/src/tools/__tests__/intentArg.test.ts +101 -25
- package/src/tools/intentArg.ts +102 -68
- package/src/tools/search/outcome.test.ts +1 -1
- package/src/tools/search/tool.ts +5 -5
- package/src/types/activityLabel.ts +8 -0
- package/src/types/stream.ts +28 -2
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
2
|
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
3
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
4
|
import type * as t from '@/types';
|
|
4
5
|
import {
|
|
5
6
|
createSummarizeNode,
|
|
6
7
|
DEFAULT_SUMMARIZATION_PROMPT,
|
|
7
8
|
DEFAULT_UPDATE_SUMMARIZATION_PROMPT,
|
|
8
9
|
} from '@/summarization/node';
|
|
10
|
+
import { convertInjectedMessages } from '@/messages/injected';
|
|
9
11
|
import { Constants, GraphEvents, Providers } from '@/common';
|
|
10
12
|
import { AgentContext } from '@/agents/AgentContext';
|
|
11
13
|
import * as providers from '@/llm/providers';
|
|
@@ -945,6 +947,192 @@ describe('recency window — first-turn protection', () => {
|
|
|
945
947
|
expect((result.messages![2] as AIMessage).content).toBe('turn 2 reply');
|
|
946
948
|
});
|
|
947
949
|
|
|
950
|
+
describe('summary coverage', () => {
|
|
951
|
+
const runCompaction = async (
|
|
952
|
+
messages: BaseMessage[],
|
|
953
|
+
turns = 1
|
|
954
|
+
): Promise<t.SummaryContentBlock | undefined> => {
|
|
955
|
+
captureEvents();
|
|
956
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
957
|
+
class {
|
|
958
|
+
constructor() {
|
|
959
|
+
return mockInvokeModel('Summary of older turns');
|
|
960
|
+
}
|
|
961
|
+
} as never
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
let summaryBlock: t.SummaryContentBlock | undefined;
|
|
965
|
+
const graph = mockGraph((_stepId, result) => {
|
|
966
|
+
if (result.type === 'summary') {
|
|
967
|
+
summaryBlock = result.summary;
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
const summarizeNode = createSummarizeNode({
|
|
971
|
+
agentContext: createAgentContext({
|
|
972
|
+
summarizationConfig: { retainRecent: { turns } },
|
|
973
|
+
} as never),
|
|
974
|
+
graph: graph as never,
|
|
975
|
+
generateStepId,
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
await summarizeNode(
|
|
979
|
+
{
|
|
980
|
+
messages,
|
|
981
|
+
summarizationRequest: {
|
|
982
|
+
remainingContextTokens: 0,
|
|
983
|
+
agentId: 'agent_0',
|
|
984
|
+
},
|
|
985
|
+
},
|
|
986
|
+
{} as RunnableConfig
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
return summaryBlock;
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
it('records the first retained message as the coverage anchor', async () => {
|
|
993
|
+
const summaryBlock = await runCompaction([
|
|
994
|
+
new HumanMessage({ content: 'turn 1 query', id: 'm1' }),
|
|
995
|
+
new AIMessage({ content: 'turn 1 reply', id: 'm2' }),
|
|
996
|
+
new HumanMessage({ content: 'turn 2 query', id: 'm3' }),
|
|
997
|
+
new AIMessage({ content: 'turn 2 reply', id: 'm4' }),
|
|
998
|
+
]);
|
|
999
|
+
|
|
1000
|
+
expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm3' });
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('skips a retained message that carries no source id', async () => {
|
|
1004
|
+
const summaryBlock = await runCompaction([
|
|
1005
|
+
new HumanMessage({ content: 'turn 1 query', id: 'm1' }),
|
|
1006
|
+
new AIMessage({ content: 'turn 1 reply', id: 'm2' }),
|
|
1007
|
+
new HumanMessage({ content: 'turn 2 query' }),
|
|
1008
|
+
new AIMessage({ content: 'turn 2 reply', id: 'm4' }),
|
|
1009
|
+
]);
|
|
1010
|
+
|
|
1011
|
+
expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm4' });
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it('omits coverage when no retained message carries a source id', async () => {
|
|
1015
|
+
const summaryBlock = await runCompaction([
|
|
1016
|
+
new HumanMessage('turn 1 query'),
|
|
1017
|
+
new AIMessage('turn 1 reply'),
|
|
1018
|
+
new HumanMessage('turn 2 query'),
|
|
1019
|
+
new AIMessage('turn 2 reply'),
|
|
1020
|
+
]);
|
|
1021
|
+
|
|
1022
|
+
expect(summaryBlock?.coverage).toBeUndefined();
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
/** A steer expands one source message into pre-steer, steer, and post-steer
|
|
1026
|
+
* messages sharing its ID, and the recency split lands on the steer. The
|
|
1027
|
+
* straddling message is the anchor, so it survives whole. */
|
|
1028
|
+
it('anchors on a source id that straddles the recency boundary', async () => {
|
|
1029
|
+
const summaryBlock = await runCompaction([
|
|
1030
|
+
new HumanMessage({ content: 'turn 1 query', id: 'm1' }),
|
|
1031
|
+
new AIMessage({ content: 'pre-steer reply', id: 'm2' }),
|
|
1032
|
+
new HumanMessage({
|
|
1033
|
+
content: 'steer',
|
|
1034
|
+
id: 'm2',
|
|
1035
|
+
additional_kwargs: { role: 'user', source: 'steer' },
|
|
1036
|
+
}),
|
|
1037
|
+
new AIMessage({ content: 'post-steer reply', id: 'm2' }),
|
|
1038
|
+
]);
|
|
1039
|
+
|
|
1040
|
+
expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm2' });
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
/** A steer carries `source: 'steer'` but is replayed from a payload entry
|
|
1044
|
+
* and stamped with its ID, so it is a valid anchor. When compaction lands
|
|
1045
|
+
* before any post-steer message exists it is the *only* retained entry —
|
|
1046
|
+
* treating every marked message as synthetic drops it. */
|
|
1047
|
+
it('anchors on a retained steer with no post-steer message', async () => {
|
|
1048
|
+
const summaryBlock = await runCompaction([
|
|
1049
|
+
new HumanMessage({ content: 'turn 1 query', id: 'm1' }),
|
|
1050
|
+
new AIMessage({ content: 'pre-steer reply', id: 'm2' }),
|
|
1051
|
+
new HumanMessage({
|
|
1052
|
+
content: 'steer',
|
|
1053
|
+
id: 'm2',
|
|
1054
|
+
additional_kwargs: { role: 'user', source: 'steer' },
|
|
1055
|
+
}),
|
|
1056
|
+
]);
|
|
1057
|
+
|
|
1058
|
+
expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm2' });
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
/** `formatAgentMessages` reconstructs skill bodies inside its payload loop
|
|
1062
|
+
* and keeps processing payload entries after, so this unstamped entry — a
|
|
1063
|
+
* reducer UUID by the time compaction sees it — precedes stamped messages.
|
|
1064
|
+
* Anchoring on it would resolve to nothing on the next run. */
|
|
1065
|
+
it('skips a reconstructed skill body to reach the stamped message behind it', async () => {
|
|
1066
|
+
const summaryBlock = await runCompaction(
|
|
1067
|
+
[
|
|
1068
|
+
new HumanMessage({ content: 'turn 1 query', id: 'm1' }),
|
|
1069
|
+
new AIMessage({ content: 'turn 1 reply', id: 'm2' }),
|
|
1070
|
+
new HumanMessage({
|
|
1071
|
+
content: 'skill body',
|
|
1072
|
+
id: 'reducer-uuid',
|
|
1073
|
+
additional_kwargs: {
|
|
1074
|
+
role: 'user',
|
|
1075
|
+
isMeta: true,
|
|
1076
|
+
source: 'skill',
|
|
1077
|
+
skillName: 'demo',
|
|
1078
|
+
},
|
|
1079
|
+
}),
|
|
1080
|
+
new HumanMessage({ content: 'turn 2 query', id: 'm3' }),
|
|
1081
|
+
new AIMessage({ content: 'turn 2 reply', id: 'm4' }),
|
|
1082
|
+
],
|
|
1083
|
+
2
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm3' });
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
/** `InjectedMessage` leaves both `isMeta` and `source` optional, so a bare
|
|
1090
|
+
* injected turn carries no marker of its own — and an injected `steer` is
|
|
1091
|
+
* otherwise indistinguishable from a replayed one. `convertInjectedMessages`
|
|
1092
|
+
* records `injected` on everything it builds, which decides both. */
|
|
1093
|
+
it.each([
|
|
1094
|
+
['a bare injected turn', { role: 'user' as const, content: 'injected' }],
|
|
1095
|
+
[
|
|
1096
|
+
'an injected steer',
|
|
1097
|
+
{
|
|
1098
|
+
role: 'user' as const,
|
|
1099
|
+
content: 'injected steer',
|
|
1100
|
+
source: 'steer' as const,
|
|
1101
|
+
},
|
|
1102
|
+
],
|
|
1103
|
+
])('skips %s when anchoring', async (_label, injected) => {
|
|
1104
|
+
const [converted] = convertInjectedMessages([injected]);
|
|
1105
|
+
converted.id = 'reducer-uuid';
|
|
1106
|
+
|
|
1107
|
+
const summaryBlock = await runCompaction(
|
|
1108
|
+
[
|
|
1109
|
+
new HumanMessage({ content: 'turn 1 query', id: 'm1' }),
|
|
1110
|
+
new AIMessage({ content: 'turn 1 reply', id: 'm2' }),
|
|
1111
|
+
converted,
|
|
1112
|
+
new HumanMessage({ content: 'turn 2 query', id: 'm3' }),
|
|
1113
|
+
new AIMessage({ content: 'turn 2 reply', id: 'm4' }),
|
|
1114
|
+
],
|
|
1115
|
+
2
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm3' });
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('anchors on the straddling id when it is the only source', async () => {
|
|
1122
|
+
const summaryBlock = await runCompaction([
|
|
1123
|
+
new AIMessage({ content: 'pre-steer reply', id: 'm1' }),
|
|
1124
|
+
new HumanMessage({
|
|
1125
|
+
content: 'steer',
|
|
1126
|
+
id: 'm1',
|
|
1127
|
+
additional_kwargs: { role: 'user', source: 'steer' },
|
|
1128
|
+
}),
|
|
1129
|
+
new AIMessage({ content: 'post-steer reply', id: 'm1' }),
|
|
1130
|
+
]);
|
|
1131
|
+
|
|
1132
|
+
expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm1' });
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
948
1136
|
it('keeps the masked tail content (does not re-inject restored tool payloads into state)', async () => {
|
|
949
1137
|
captureEvents();
|
|
950
1138
|
|
|
@@ -381,10 +381,75 @@ function computeSummaryTokenCount(
|
|
|
381
381
|
return 0;
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Names the first retained message so the summary declares its own extent
|
|
386
|
+
* rather than leaving the next run to infer coverage from where the block
|
|
387
|
+
* happens to sit.
|
|
388
|
+
*
|
|
389
|
+
* Anchored to the retained side, not the covered side. One source message can
|
|
390
|
+
* expand into several messages — a steer splits an assistant entry into
|
|
391
|
+
* pre-steer, steer, and post-steer entries sharing its ID — and the recency
|
|
392
|
+
* split lands on any human-type message, including the steer. Naming the last
|
|
393
|
+
* *covered* message would then name a half-covered ID with no correct reading;
|
|
394
|
+
* naming the first *retained* message makes that same message the anchor, so it
|
|
395
|
+
* survives whole and everything before it is unambiguously covered.
|
|
396
|
+
*
|
|
397
|
+
* Synthetic entries are skipped, because they can sit *before* a resolvable
|
|
398
|
+
* one. `formatAgentMessages` reconstructs skill bodies inside its payload loop
|
|
399
|
+
* (see the `pendingSkillNames` block) and keeps processing payload entries
|
|
400
|
+
* afterwards, so an unstamped skill body — which `messagesStateReducer` then
|
|
401
|
+
* gives a UUID no payload entry carries — is followed by stamped messages.
|
|
402
|
+
* Anchoring on the UUID would look resolvable at write time and degrade to
|
|
403
|
+
* positional trimming on read, dropping the retained tail. Skipping it reaches
|
|
404
|
+
* the stamped message behind it.
|
|
405
|
+
*
|
|
406
|
+
* `convertInjectedMessages` records `injected` on everything it builds, which is
|
|
407
|
+
* what makes this decidable: `isMeta` and `source` are both optional on
|
|
408
|
+
* `InjectedMessage`, so a bare entry carries no marker of its own, and an
|
|
409
|
+
* injected `source: 'steer'` is otherwise indistinguishable from a replayed one.
|
|
410
|
+
* The remaining `isMeta`/`source` checks cover the constructors that build
|
|
411
|
+
* synthetic entries directly instead of going through that funnel — hook context
|
|
412
|
+
* in `ToolNode` and `StandardGraph`, handoff cues, reconstructed skill bodies.
|
|
413
|
+
*
|
|
414
|
+
* `steer` is exempt from the `source` check because a replayed steer *is*
|
|
415
|
+
* stamped from its payload entry; rejecting every marked `source` once dropped
|
|
416
|
+
* exactly those retained steers. Injected steers are still caught, by `injected`.
|
|
417
|
+
*
|
|
418
|
+
* Known limitation: a payload entry that omits `messageId` is never stamped, so
|
|
419
|
+
* the reducer's UUID is recorded and cannot resolve on the next run. There is no
|
|
420
|
+
* write-time fix — such an entry has no stable ID to name in the next payload
|
|
421
|
+
* either — and the reader's positional fallback is what `main` already does, so
|
|
422
|
+
* the anchor degrades rather than misleads.
|
|
423
|
+
*/
|
|
424
|
+
function isSyntheticContext(message: BaseMessage): boolean {
|
|
425
|
+
const { additional_kwargs: kwargs } = message;
|
|
426
|
+
if (kwargs.injected === true || kwargs.isMeta === true) {
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
return kwargs.source != null && kwargs.source !== 'steer';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function resolveSummaryCoverage(
|
|
433
|
+
messagesToRetain: BaseMessage[]
|
|
434
|
+
): t.SummaryCoverage | undefined {
|
|
435
|
+
for (let i = 0; i < messagesToRetain.length; i++) {
|
|
436
|
+
const message = messagesToRetain[i];
|
|
437
|
+
if (isSyntheticContext(message)) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const id = message.id?.trim();
|
|
441
|
+
if (id != null && id !== '') {
|
|
442
|
+
return { retainedFromMessageId: id };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
384
448
|
/** Constructs the SummaryContentBlock persisted in the run step and dispatched to events. */
|
|
385
449
|
function buildSummaryBlock(params: {
|
|
386
450
|
summaryText: string;
|
|
387
451
|
tokenCount: number;
|
|
452
|
+
coverage?: t.SummaryCoverage;
|
|
388
453
|
stepId: string;
|
|
389
454
|
stepIndex: number;
|
|
390
455
|
modelName?: string;
|
|
@@ -400,6 +465,7 @@ function buildSummaryBlock(params: {
|
|
|
400
465
|
} as t.MessageContentComplex,
|
|
401
466
|
],
|
|
402
467
|
tokenCount: params.tokenCount,
|
|
468
|
+
...(params.coverage != null ? { coverage: params.coverage } : {}),
|
|
403
469
|
summaryVersion: params.summaryVersion,
|
|
404
470
|
boundary: {
|
|
405
471
|
messageId: params.stepId,
|
|
@@ -1145,6 +1211,7 @@ export function createSummarizeNode({
|
|
|
1145
1211
|
const summaryBlock = buildSummaryBlock({
|
|
1146
1212
|
summaryText,
|
|
1147
1213
|
tokenCount,
|
|
1214
|
+
coverage: resolveSummaryCoverage(messagesToRetain),
|
|
1148
1215
|
stepId,
|
|
1149
1216
|
stepIndex: runStep.index,
|
|
1150
1217
|
modelName: clientConfig.modelName,
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
INTENT_ARG,
|
|
5
5
|
INTENT_PROPERTY,
|
|
6
6
|
INTENT_DESCRIPTION,
|
|
7
|
+
INTENT_LABEL_MARKER,
|
|
8
|
+
withoutIntent,
|
|
7
9
|
withIntent,
|
|
8
10
|
readIntent,
|
|
9
11
|
stripIntent,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
readOutcomeFields,
|
|
12
14
|
resolveToolOutcome,
|
|
13
15
|
} from '../intentArg';
|
|
16
|
+
import { ReadFileToolSchema } from '../ReadFile';
|
|
14
17
|
|
|
15
18
|
describe('withIntent', () => {
|
|
16
19
|
const base: JsonSchemaType = {
|
|
@@ -72,7 +75,83 @@ describe('withIntent', () => {
|
|
|
72
75
|
it('carries the model-facing instruction', () => {
|
|
73
76
|
expect(INTENT_PROPERTY.description).toBe(INTENT_DESCRIPTION);
|
|
74
77
|
expect(INTENT_DESCRIPTION).toContain('FIRST');
|
|
75
|
-
|
|
78
|
+
/** Sibling differentiation is the headline case; models emit identical
|
|
79
|
+
* labels for parallel calls without it. */
|
|
80
|
+
expect(INTENT_DESCRIPTION).toContain('Sibling calls to one tool must differ');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('opens with the exported marker so host strip passes can key on it', () => {
|
|
84
|
+
expect(INTENT_DESCRIPTION.startsWith(INTENT_LABEL_MARKER)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('stays terse — it is repeated per tool, per request', () => {
|
|
88
|
+
expect(INTENT_DESCRIPTION.length).toBeLessThanOrEqual(300);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('withoutIntent', () => {
|
|
93
|
+
it('removes the injected label — the opt-out for embedders that render none', () => {
|
|
94
|
+
const withLabel = withIntent({
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: { query: { type: 'string' } },
|
|
97
|
+
required: ['query'],
|
|
98
|
+
});
|
|
99
|
+
const stripped = withoutIntent(withLabel);
|
|
100
|
+
expect(Object.keys(stripped?.properties ?? {})).toEqual(['query']);
|
|
101
|
+
expect(stripped?.required).toEqual(['query']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('never removes a tool-owned business `intent` parameter', () => {
|
|
105
|
+
const business: JsonSchemaType = {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: { intent: { type: 'string', description: 'CRM intent category' } },
|
|
108
|
+
required: ['intent'],
|
|
109
|
+
};
|
|
110
|
+
expect(withoutIntent(business)).toBe(business);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('is a no-op on schemas without the label', () => {
|
|
114
|
+
const plain: JsonSchemaType = { type: 'object', properties: { q: { type: 'string' } } };
|
|
115
|
+
expect(withoutIntent(plain)).toBe(plain);
|
|
116
|
+
expect(withoutIntent(undefined)).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('prunes intent from `required` too, so the schema stays valid', () => {
|
|
120
|
+
/** Strict-mode normalization lists every property in `required`; leaving
|
|
121
|
+
* a dangling entry yields invalid JSON Schema the provider rejects. */
|
|
122
|
+
const strict: JsonSchemaType = {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: { intent: { ...INTENT_PROPERTY }, path: { type: 'string' } },
|
|
125
|
+
required: ['intent', 'path'],
|
|
126
|
+
};
|
|
127
|
+
const stripped = withoutIntent(strict);
|
|
128
|
+
expect(Object.keys(stripped?.properties ?? {})).toEqual(['path']);
|
|
129
|
+
expect(stripped?.required).toEqual(['path']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('drops `required` entirely when intent was its only entry', () => {
|
|
133
|
+
const onlyIntent: JsonSchemaType = {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: { intent: { ...INTENT_PROPERTY } },
|
|
136
|
+
required: ['intent'],
|
|
137
|
+
};
|
|
138
|
+
const stripped = withoutIntent(onlyIntent);
|
|
139
|
+
expect(stripped?.required).toBeUndefined();
|
|
140
|
+
expect(Object.keys(stripped?.properties ?? {})).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('accepts a readonly `as const` native schema without a cast', () => {
|
|
144
|
+
/** ReadFileToolSchema is declared `as const`, so `required` is a readonly
|
|
145
|
+
* tuple — this call is the compile-time assertion that the advertised
|
|
146
|
+
* opt-out is usable on the schemas it exists for. */
|
|
147
|
+
const stripped = withoutIntent(ReadFileToolSchema);
|
|
148
|
+
expect(Object.keys(stripped?.properties ?? {})).toEqual(['path']);
|
|
149
|
+
expect(stripped?.required).toEqual(['path']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('round-trips with withIntent', () => {
|
|
153
|
+
const base: JsonSchemaType = { type: 'object', properties: { q: { type: 'string' } } };
|
|
154
|
+
expect(withoutIntent(withIntent(base))).toEqual(base);
|
|
76
155
|
});
|
|
77
156
|
});
|
|
78
157
|
|
|
@@ -130,7 +209,7 @@ describe('applyOutcome', () => {
|
|
|
130
209
|
});
|
|
131
210
|
|
|
132
211
|
it('ignores a blank outcome', () => {
|
|
133
|
-
expect(applyOutcome(intent, { outcome: ' ' })).toBe(
|
|
212
|
+
expect(applyOutcome(intent, { outcome: ' ' })).toBe(intent);
|
|
134
213
|
});
|
|
135
214
|
|
|
136
215
|
it('applies outcome_patch to the first occurrence only, case-sensitive', () => {
|
|
@@ -141,34 +220,31 @@ describe('applyOutcome', () => {
|
|
|
141
220
|
).toBe('Searched for Searching patterns');
|
|
142
221
|
expect(
|
|
143
222
|
applyOutcome(intent, { outcome_patch: { from: 'searching', to: 'searched' } })
|
|
144
|
-
).toBe(
|
|
223
|
+
).toBe(intent);
|
|
145
224
|
});
|
|
146
225
|
|
|
147
226
|
it('ignores a patch whose from is empty or absent from the intent', () => {
|
|
148
|
-
expect(applyOutcome(intent, { outcome_patch: { from: '', to: 'x' } })).toBe(
|
|
149
|
-
'Searched for OAuth handling'
|
|
150
|
-
);
|
|
227
|
+
expect(applyOutcome(intent, { outcome_patch: { from: '', to: 'x' } })).toBe(intent);
|
|
151
228
|
expect(applyOutcome(intent, { outcome_patch: { from: 'Grepping', to: 'Grepped' } })).toBe(
|
|
152
|
-
|
|
229
|
+
intent
|
|
153
230
|
);
|
|
154
231
|
});
|
|
155
232
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
expect(applyOutcome('Searching')).toBe('Searched');
|
|
233
|
+
/**
|
|
234
|
+
* There is no mechanical tense rewrite: a closed English verb list would
|
|
235
|
+
* never fire for non-English labels, and would fire for some siblings but
|
|
236
|
+
* not others inside one group. Completion is a UI state, not a tense.
|
|
237
|
+
*/
|
|
238
|
+
it('returns the intent unchanged when the tool authored no outcome', () => {
|
|
239
|
+
for (const label of [
|
|
240
|
+
'Reading the callback router',
|
|
241
|
+
'Recording the OAuth callback location',
|
|
242
|
+
'searching for OAuth handling',
|
|
243
|
+
'Buscando el manejo de OAuth',
|
|
244
|
+
'Searching',
|
|
245
|
+
]) {
|
|
246
|
+
expect(applyOutcome(label)).toBe(label);
|
|
247
|
+
}
|
|
172
248
|
});
|
|
173
249
|
|
|
174
250
|
it('returns undefined with neither intent nor outcome', () => {
|
|
@@ -215,7 +291,7 @@ describe('resolveToolOutcome', () => {
|
|
|
215
291
|
expect(oversized?.length).toBe(256);
|
|
216
292
|
expect(oversized?.endsWith('…')).toBe(true);
|
|
217
293
|
expect(resolveToolOutcome(args, { outcome: ' \n \t ' })).toBe(
|
|
218
|
-
'
|
|
294
|
+
'Searching for OAuth handling'
|
|
219
295
|
);
|
|
220
296
|
});
|
|
221
297
|
|
|
@@ -240,7 +316,7 @@ describe('resolveToolOutcome', () => {
|
|
|
240
316
|
).toBe('Search failed for OAuth handling');
|
|
241
317
|
});
|
|
242
318
|
|
|
243
|
-
it('never
|
|
319
|
+
it('never reuses the in-flight intent as a failed call\'s settled label', () => {
|
|
244
320
|
expect(
|
|
245
321
|
resolveToolOutcome(
|
|
246
322
|
args,
|