@librechat/agents 3.4.3 → 3.4.5
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/Graph.cjs +27 -14
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -1
- package/dist/cjs/hitl/askUserQuestions.cjs +66 -0
- package/dist/cjs/hitl/askUserQuestions.cjs.map +1 -0
- package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs +46 -0
- package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs.map +1 -0
- package/dist/cjs/hitl/index.cjs +2 -0
- package/dist/cjs/instrumentation.cjs +3 -3
- package/dist/cjs/langfuse.cjs +3 -3
- package/dist/cjs/langfuseRuntimeScope.cjs +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +2 -2
- package/dist/cjs/main.cjs +11 -1
- package/dist/cjs/messages/assistantPhase.cjs +59 -0
- package/dist/cjs/messages/assistantPhase.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +1 -0
- package/dist/cjs/prompts/activityLabel.cjs +76 -0
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
- package/dist/cjs/run.cjs +200 -10
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +1 -1
- package/dist/cjs/stream.cjs +45 -8
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +3 -3
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +81 -6
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/types/hitl.cjs +13 -0
- package/dist/cjs/types/hitl.cjs.map +1 -0
- package/dist/cjs/utils/callbacks.cjs +8 -0
- package/dist/cjs/utils/callbacks.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +27 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
- package/dist/esm/hitl/askUserQuestions.mjs +66 -0
- package/dist/esm/hitl/askUserQuestions.mjs.map +1 -0
- package/dist/esm/hitl/askUserQuestionsInterrupt.mjs +43 -0
- package/dist/esm/hitl/askUserQuestionsInterrupt.mjs.map +1 -0
- package/dist/esm/hitl/index.mjs +2 -0
- package/dist/esm/instrumentation.mjs +3 -3
- package/dist/esm/langfuse.mjs +3 -3
- package/dist/esm/langfuseRuntimeScope.mjs +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +2 -2
- package/dist/esm/main.mjs +5 -2
- package/dist/esm/messages/assistantPhase.mjs +57 -0
- package/dist/esm/messages/assistantPhase.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +1 -0
- package/dist/esm/prompts/activityLabel.mjs +74 -1
- package/dist/esm/prompts/activityLabel.mjs.map +1 -1
- package/dist/esm/run.mjs +202 -12
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +1 -1
- package/dist/esm/stream.mjs +45 -8
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +3 -3
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +81 -6
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/types/hitl.mjs +13 -0
- package/dist/esm/types/hitl.mjs.map +1 -0
- package/dist/esm/utils/callbacks.mjs +8 -1
- package/dist/esm/utils/callbacks.mjs.map +1 -1
- package/dist/types/hitl/askUserQuestions.d.ts +24 -0
- package/dist/types/hitl/askUserQuestionsInterrupt.d.ts +11 -0
- package/dist/types/hitl/index.d.ts +2 -0
- package/dist/types/messages/assistantPhase.d.ts +22 -0
- package/dist/types/messages/index.d.ts +1 -0
- package/dist/types/prompts/activityLabel.d.ts +21 -1
- package/dist/types/run.d.ts +15 -2
- package/dist/types/types/activityLabel.d.ts +63 -0
- package/dist/types/types/assistantPhase.d.ts +6 -0
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/hitl.d.ts +31 -2
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/stream.d.ts +11 -0
- package/dist/types/utils/callbacks.d.ts +1 -0
- package/package.json +2 -1
- package/src/graphs/Graph.ts +33 -9
- package/src/graphs/__tests__/Graph.reasoning.test.ts +57 -0
- package/src/hitl/askUserQuestions.ts +126 -0
- package/src/hitl/askUserQuestionsInterrupt.ts +115 -0
- package/src/hitl/index.ts +6 -0
- package/src/messages/assistantPhase.test.ts +75 -0
- package/src/messages/assistantPhase.ts +91 -0
- package/src/messages/index.ts +1 -0
- package/src/prompts/activityLabel.ts +177 -1
- package/src/run.ts +403 -21
- package/src/specs/activity-label-prompt.test.ts +123 -1
- package/src/specs/activity-phase-label.test.ts +306 -0
- package/src/specs/ask-user-questions.live.test.ts +185 -0
- package/src/specs/ask-user-questions.test.ts +293 -0
- package/src/stream.ts +69 -12
- package/src/tools/__tests__/SubagentExecutor.test.ts +436 -0
- package/src/tools/subagent/SubagentExecutor.ts +160 -8
- package/src/types/activityLabel.ts +65 -0
- package/src/types/assistantPhase.ts +6 -0
- package/src/types/graph.ts +8 -0
- package/src/types/hitl.ts +36 -2
- package/src/types/index.ts +1 -0
- package/src/types/stream.ts +9 -0
- package/src/utils/callbacks.ts +21 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { interrupt } from '@langchain/langgraph';
|
|
2
|
+
import type {
|
|
3
|
+
AskUserQuestionBatchItem,
|
|
4
|
+
AskUserQuestionRequest,
|
|
5
|
+
AskUserQuestionsInterruptPayload,
|
|
6
|
+
AskUserQuestionsRequest,
|
|
7
|
+
AskUserQuestionsResolution,
|
|
8
|
+
} from '@/types/hitl';
|
|
9
|
+
import {
|
|
10
|
+
ASK_USER_QUESTION_ID_PATTERN,
|
|
11
|
+
isAskUserQuestionRequest,
|
|
12
|
+
MAX_ASK_USER_QUESTIONS,
|
|
13
|
+
} from './askUserQuestionsInterrupt';
|
|
14
|
+
|
|
15
|
+
function validateQuestions(
|
|
16
|
+
questions: readonly AskUserQuestionBatchItem[]
|
|
17
|
+
): AskUserQuestionBatchItem {
|
|
18
|
+
if (questions.length === 0) {
|
|
19
|
+
throw new RangeError('askUserQuestions requires at least one question.');
|
|
20
|
+
}
|
|
21
|
+
if (questions.length > MAX_ASK_USER_QUESTIONS) {
|
|
22
|
+
throw new RangeError(
|
|
23
|
+
`askUserQuestions accepts at most ${MAX_ASK_USER_QUESTIONS} questions.`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ids = new Set<string>();
|
|
28
|
+
for (const question of questions) {
|
|
29
|
+
if (!isAskUserQuestionRequest(question)) {
|
|
30
|
+
throw new TypeError(
|
|
31
|
+
'askUserQuestions requires each question and option to have valid string fields.'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (!ASK_USER_QUESTION_ID_PATTERN.test(question.id)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'askUserQuestions requires each question id to match [A-Za-z][A-Za-z0-9_-]{0,63}.'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (ids.has(question.id)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`askUserQuestions requires unique question ids; received "${question.id}" more than once.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
ids.add(question.id);
|
|
45
|
+
}
|
|
46
|
+
return questions[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AskUserQuestionsResolutionCandidate {
|
|
50
|
+
answers?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateResolution(
|
|
54
|
+
value: unknown,
|
|
55
|
+
questions: readonly AskUserQuestionBatchItem[]
|
|
56
|
+
): AskUserQuestionsResolution {
|
|
57
|
+
if (typeof value !== 'object' || value === null) {
|
|
58
|
+
throw new TypeError('askUserQuestions requires an answers object.');
|
|
59
|
+
}
|
|
60
|
+
const answers = (value as AskUserQuestionsResolutionCandidate).answers;
|
|
61
|
+
if (
|
|
62
|
+
typeof answers !== 'object' ||
|
|
63
|
+
answers === null ||
|
|
64
|
+
Array.isArray(answers)
|
|
65
|
+
) {
|
|
66
|
+
throw new TypeError('askUserQuestions requires an answers object.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const validated: Record<string, string> = {};
|
|
70
|
+
for (const question of questions) {
|
|
71
|
+
const descriptor = Object.getOwnPropertyDescriptor(answers, question.id);
|
|
72
|
+
const answer: unknown = descriptor?.value;
|
|
73
|
+
if (descriptor == null || typeof answer !== 'string') {
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
`askUserQuestions requires a string answer for question id "${question.id}".`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
validated[question.id] = answer;
|
|
79
|
+
}
|
|
80
|
+
return { answers: validated };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Suspend once to collect answers to several related questions. The first
|
|
85
|
+
* question is also included in the legacy `question` field so existing hosts
|
|
86
|
+
* can render a useful fallback during a staged rollout.
|
|
87
|
+
*
|
|
88
|
+
* Question ids must be non-empty and unique within the batch. The helper
|
|
89
|
+
* accepts at most four questions so hosts can render the interaction as one
|
|
90
|
+
* focused decision surface rather than an unbounded form.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* const { answers } = askUserQuestions({
|
|
95
|
+
* questions: [
|
|
96
|
+
* { id: 'environment', question: 'Which environment?' },
|
|
97
|
+
* { id: 'region', question: 'Which region?' },
|
|
98
|
+
* ],
|
|
99
|
+
* });
|
|
100
|
+
* return `Deploy to ${answers.environment} in ${answers.region}`;
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export function askUserQuestions(
|
|
104
|
+
request: AskUserQuestionsRequest,
|
|
105
|
+
options?: { toolCallId?: string }
|
|
106
|
+
): AskUserQuestionsResolution {
|
|
107
|
+
const first = validateQuestions(request.questions);
|
|
108
|
+
const fallback: AskUserQuestionRequest = {
|
|
109
|
+
question: first.question,
|
|
110
|
+
...(first.description != null && { description: first.description }),
|
|
111
|
+
...(first.options != null && { options: first.options }),
|
|
112
|
+
...(first.multiSelect != null && { multiSelect: first.multiSelect }),
|
|
113
|
+
};
|
|
114
|
+
const payload: AskUserQuestionsInterruptPayload = {
|
|
115
|
+
type: 'ask_user_question',
|
|
116
|
+
question: fallback,
|
|
117
|
+
questions: request.questions,
|
|
118
|
+
...(options?.toolCallId != null &&
|
|
119
|
+
options.toolCallId !== '' && { tool_call_id: options.toolCallId }),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const resolution = interrupt<AskUserQuestionsInterruptPayload, unknown>(
|
|
123
|
+
payload
|
|
124
|
+
);
|
|
125
|
+
return validateResolution(resolution, request.questions);
|
|
126
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AskUserQuestionBatchItem,
|
|
3
|
+
AskUserQuestionOption,
|
|
4
|
+
AskUserQuestionRequest,
|
|
5
|
+
AskUserQuestionsInterruptPayload,
|
|
6
|
+
} from '@/types/hitl';
|
|
7
|
+
import { isAskUserQuestionInterrupt } from '@/types/hitl';
|
|
8
|
+
|
|
9
|
+
/** Maximum questions supported by one batched clarification interaction. */
|
|
10
|
+
export const MAX_ASK_USER_QUESTIONS = 4;
|
|
11
|
+
|
|
12
|
+
/** Safe identifier format for answer-map keys in a batched question. */
|
|
13
|
+
export const ASK_USER_QUESTION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
14
|
+
|
|
15
|
+
interface AskUserQuestionOptionCandidate {
|
|
16
|
+
label?: unknown;
|
|
17
|
+
value?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AskUserQuestionCandidate {
|
|
21
|
+
question?: unknown;
|
|
22
|
+
description?: unknown;
|
|
23
|
+
options?: unknown;
|
|
24
|
+
multiSelect?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AskUserQuestionBatchItemCandidate extends AskUserQuestionCandidate {
|
|
28
|
+
id?: unknown;
|
|
29
|
+
header?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isAskUserQuestionOption(
|
|
33
|
+
value: unknown
|
|
34
|
+
): value is AskUserQuestionOption {
|
|
35
|
+
if (typeof value !== 'object' || value === null) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const option = value as AskUserQuestionOptionCandidate;
|
|
39
|
+
return typeof option.label === 'string' && typeof option.value === 'string';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isAskUserQuestionOptions(
|
|
43
|
+
value: unknown
|
|
44
|
+
): value is AskUserQuestionOption[] {
|
|
45
|
+
if (!Array.isArray(value)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
for (let index = 0; index < value.length; index++) {
|
|
49
|
+
if (!Object.hasOwn(value, index) || !isAskUserQuestionOption(value[index])) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isAskUserQuestionRequest(
|
|
57
|
+
value: unknown
|
|
58
|
+
): value is AskUserQuestionRequest {
|
|
59
|
+
if (typeof value !== 'object' || value === null) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const question = value as AskUserQuestionCandidate;
|
|
63
|
+
return (
|
|
64
|
+
typeof question.question === 'string' &&
|
|
65
|
+
(question.description === undefined ||
|
|
66
|
+
typeof question.description === 'string') &&
|
|
67
|
+
(question.options === undefined ||
|
|
68
|
+
isAskUserQuestionOptions(question.options)) &&
|
|
69
|
+
(question.multiSelect === undefined ||
|
|
70
|
+
typeof question.multiSelect === 'boolean')
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isAskUserQuestionBatchItem(
|
|
75
|
+
value: unknown
|
|
76
|
+
): value is AskUserQuestionBatchItem {
|
|
77
|
+
if (!isAskUserQuestionRequest(value)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const question = value as AskUserQuestionBatchItemCandidate;
|
|
81
|
+
return (
|
|
82
|
+
typeof question.id === 'string' &&
|
|
83
|
+
ASK_USER_QUESTION_ID_PATTERN.test(question.id) &&
|
|
84
|
+
(question.header === undefined || typeof question.header === 'string')
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Type guard for the batched form of an `ask_user_question` interrupt. Hosts
|
|
90
|
+
* use this to select the multi-question UI and `AskUserQuestionsResolution`.
|
|
91
|
+
*/
|
|
92
|
+
export function isAskUserQuestionsInterrupt(
|
|
93
|
+
payload: unknown
|
|
94
|
+
): payload is AskUserQuestionsInterruptPayload {
|
|
95
|
+
if (
|
|
96
|
+
!isAskUserQuestionInterrupt(payload) ||
|
|
97
|
+
!isAskUserQuestionRequest(payload.question) ||
|
|
98
|
+
(payload.tool_call_id !== undefined &&
|
|
99
|
+
typeof payload.tool_call_id !== 'string') ||
|
|
100
|
+
!Array.isArray(payload.questions) ||
|
|
101
|
+
payload.questions.length === 0 ||
|
|
102
|
+
payload.questions.length > MAX_ASK_USER_QUESTIONS
|
|
103
|
+
) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ids = new Set<string>();
|
|
108
|
+
for (const question of payload.questions) {
|
|
109
|
+
if (!isAskUserQuestionBatchItem(question) || ids.has(question.id)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
ids.add(question.id);
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
package/src/hitl/index.ts
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAssistantTextPhase,
|
|
3
|
+
getMessageCreationContentMetadata,
|
|
4
|
+
splitAssistantTextContentByPhase,
|
|
5
|
+
} from './assistantPhase';
|
|
6
|
+
import { ContentTypes } from '@/common';
|
|
7
|
+
|
|
8
|
+
describe('assistant phase metadata', () => {
|
|
9
|
+
it('reads provider-native Open Responses phase metadata', () => {
|
|
10
|
+
const part = {
|
|
11
|
+
type: ContentTypes.TEXT,
|
|
12
|
+
text: '',
|
|
13
|
+
phase: 'commentary' as const,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
expect(getAssistantTextPhase(part)).toBe('commentary');
|
|
17
|
+
expect(getMessageCreationContentMetadata([part])).toEqual({
|
|
18
|
+
content_type: ContentTypes.TEXT,
|
|
19
|
+
phase: 'commentary',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('reads LangChain standard-content extras', () => {
|
|
24
|
+
const part = {
|
|
25
|
+
type: ContentTypes.TEXT,
|
|
26
|
+
text: '',
|
|
27
|
+
extras: { phase: 'final_answer' as const },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
expect(getMessageCreationContentMetadata([part])).toEqual({
|
|
31
|
+
content_type: ContentTypes.TEXT,
|
|
32
|
+
phase: 'final_answer',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('announces reasoning without inventing a text phase', () => {
|
|
37
|
+
expect(
|
|
38
|
+
getMessageCreationContentMetadata([
|
|
39
|
+
{ type: ContentTypes.THINK, think: 'checking' },
|
|
40
|
+
])
|
|
41
|
+
).toEqual({ content_type: ContentTypes.THINK });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('preserves every provider phase in a multi-block text chunk', () => {
|
|
45
|
+
const commentary = {
|
|
46
|
+
type: ContentTypes.TEXT,
|
|
47
|
+
text: 'I will inspect the session path.',
|
|
48
|
+
phase: 'commentary' as const,
|
|
49
|
+
};
|
|
50
|
+
const moreCommentary = {
|
|
51
|
+
type: ContentTypes.TEXT,
|
|
52
|
+
text: 'The refresh branch is stale.',
|
|
53
|
+
phase: 'commentary' as const,
|
|
54
|
+
};
|
|
55
|
+
const finalAnswer = {
|
|
56
|
+
type: ContentTypes.TEXT,
|
|
57
|
+
text: 'The refresh path is fixed.',
|
|
58
|
+
phase: 'final_answer' as const,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const groups = splitAssistantTextContentByPhase([
|
|
62
|
+
commentary,
|
|
63
|
+
moreCommentary,
|
|
64
|
+
finalAnswer,
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
expect(groups).toEqual([[commentary, moreCommentary], [finalAnswer]]);
|
|
68
|
+
expect(
|
|
69
|
+
groups.map((group) => getMessageCreationContentMetadata(group))
|
|
70
|
+
).toEqual([
|
|
71
|
+
{ content_type: ContentTypes.TEXT, phase: 'commentary' },
|
|
72
|
+
{ content_type: ContentTypes.TEXT, phase: 'final_answer' },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { AssistantTextPhase } from '@/types/assistantPhase';
|
|
2
|
+
import type { MessageContentComplex } from '@/types/stream';
|
|
3
|
+
import { ContentTypes } from '@/common';
|
|
4
|
+
|
|
5
|
+
export type MessageCreationContentMetadata = {
|
|
6
|
+
content_type?: ContentTypes.TEXT | ContentTypes.THINK;
|
|
7
|
+
phase?: AssistantTextPhase;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function toAssistantTextPhase(value: unknown): AssistantTextPhase | undefined {
|
|
11
|
+
return value === 'commentary' || value === 'final_answer' ? value : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Reads both provider-native and LangChain standard-content phase fields. */
|
|
15
|
+
export function getAssistantTextPhase(
|
|
16
|
+
contentPart: MessageContentComplex
|
|
17
|
+
): AssistantTextPhase | undefined {
|
|
18
|
+
return (
|
|
19
|
+
toAssistantTextPhase(contentPart.phase) ??
|
|
20
|
+
toAssistantTextPhase(contentPart.extras?.phase)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Keeps provider-authored text phases in distinct message-creation steps.
|
|
26
|
+
* Open Responses may return commentary and final-answer blocks in one chunk;
|
|
27
|
+
* collapsing the array into one step would assign the first block's phase to
|
|
28
|
+
* every block and hide the boundary that closes an activity phase.
|
|
29
|
+
*/
|
|
30
|
+
export function splitAssistantTextContentByPhase(
|
|
31
|
+
content: MessageContentComplex[]
|
|
32
|
+
): MessageContentComplex[][] {
|
|
33
|
+
const groups: MessageContentComplex[][] = [];
|
|
34
|
+
for (const contentPart of content) {
|
|
35
|
+
const phase = getAssistantTextPhase(contentPart);
|
|
36
|
+
const currentGroup = groups.at(-1);
|
|
37
|
+
if (
|
|
38
|
+
currentGroup == null ||
|
|
39
|
+
getAssistantTextPhase(currentGroup[0]) !== phase
|
|
40
|
+
) {
|
|
41
|
+
groups.push([contentPart]);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
currentGroup.push(contentPart);
|
|
45
|
+
}
|
|
46
|
+
return groups;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isTextPart(contentPart: MessageContentComplex): boolean {
|
|
50
|
+
return contentPart.type?.startsWith(ContentTypes.TEXT) ?? false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isReasoningPart(contentPart: MessageContentComplex): boolean {
|
|
54
|
+
return (
|
|
55
|
+
contentPart.type === ContentTypes.THINK ||
|
|
56
|
+
(contentPart.type?.startsWith(ContentTypes.THINKING) ?? false) ||
|
|
57
|
+
(contentPart.type?.startsWith(ContentTypes.REASONING) ?? false) ||
|
|
58
|
+
(contentPart.type?.startsWith(ContentTypes.REASONING_CONTENT) ?? false) ||
|
|
59
|
+
contentPart.type === 'redacted_thinking'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Derives additive message-creation metadata before a content delta is
|
|
65
|
+
* dispatched. The fallback covers string-only providers whose semantic lane
|
|
66
|
+
* is tracked by the stream handler rather than represented on a block.
|
|
67
|
+
*/
|
|
68
|
+
export function getMessageCreationContentMetadata(
|
|
69
|
+
content: string | MessageContentComplex[] | undefined,
|
|
70
|
+
fallbackContentType?: ContentTypes.TEXT | ContentTypes.THINK
|
|
71
|
+
): MessageCreationContentMetadata {
|
|
72
|
+
if (!Array.isArray(content)) {
|
|
73
|
+
return fallbackContentType == null
|
|
74
|
+
? {}
|
|
75
|
+
: { content_type: fallbackContentType };
|
|
76
|
+
}
|
|
77
|
+
const textPart = content.find(isTextPart);
|
|
78
|
+
if (textPart != null) {
|
|
79
|
+
const phase = getAssistantTextPhase(textPart);
|
|
80
|
+
return {
|
|
81
|
+
content_type: ContentTypes.TEXT,
|
|
82
|
+
...(phase == null ? {} : { phase }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (content.some(isReasoningPart)) {
|
|
86
|
+
return { content_type: ContentTypes.THINK };
|
|
87
|
+
}
|
|
88
|
+
return fallbackContentType == null
|
|
89
|
+
? {}
|
|
90
|
+
: { content_type: fallbackContentType };
|
|
91
|
+
}
|
package/src/messages/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActivityLabelToolEntry,
|
|
3
|
+
ActivityPhaseEntry,
|
|
4
|
+
} from '@/types/activityLabel';
|
|
1
5
|
import type { ResolvedLangfuseToolOutputTracingConfig } from '@/langfuseRuntimeContext';
|
|
2
|
-
import type { ActivityLabelToolEntry } from '@/types/activityLabel';
|
|
3
6
|
import { shouldRedactTool } from '@/langfuseToolOutputTracing';
|
|
4
7
|
|
|
5
8
|
/**
|
|
@@ -25,6 +28,29 @@ Examples:
|
|
|
25
28
|
- Read project config and dependency manifests
|
|
26
29
|
- Attempted database migration, hit permission errors`;
|
|
27
30
|
|
|
31
|
+
/** Default system prompt for a run-wide parent activity phase. */
|
|
32
|
+
export const ACTIVITY_PHASE_LABEL_PROMPT = `Summarize what this phase of an agent run accomplished. The result appears as the header of one collapsed parent group containing several activities.
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
- One line, 8 to 18 words, past tense
|
|
36
|
+
- Lead with the concrete outcome and name the most distinctive subject
|
|
37
|
+
- Synthesize the phase; do not enumerate, count, or restate individual activities
|
|
38
|
+
- Describe failures plainly when they are the phase's material outcome
|
|
39
|
+
- Never mention tool names, calls, arguments, reasoning, commentary, or activity counts
|
|
40
|
+
- Output only the summary — no quotes, no trailing punctuation, no preamble
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
- Reconciled authentication behavior and fixed the failing session refresh path
|
|
44
|
+
- Compared deployment options and documented the safest production rollout
|
|
45
|
+
- Investigated database latency but could not confirm the suspected index regression
|
|
46
|
+
|
|
47
|
+
Bad examples:
|
|
48
|
+
- Used three tools to inspect files and run tests
|
|
49
|
+
- Searched code, read configuration, and updated middleware`;
|
|
50
|
+
|
|
51
|
+
/** Hard ceiling across every activity/context section in one phase request. */
|
|
52
|
+
export const ACTIVITY_PHASE_PROMPT_MAX_LENGTH = 12_000;
|
|
53
|
+
|
|
28
54
|
/** Truncates a serialized value for the label prompt. */
|
|
29
55
|
export function truncateForLabel(value: string, maxLength: number): string {
|
|
30
56
|
if (value.length <= maxLength) {
|
|
@@ -105,6 +131,10 @@ const PREVIOUS_LABEL_LIMIT = 200;
|
|
|
105
131
|
* produce one, and the cap keeps a 200-call programmatic batch from
|
|
106
132
|
* building an enormous prompt out of per-field-bounded pieces. */
|
|
107
133
|
const MAX_PROMPT_ENTRIES = 12;
|
|
134
|
+
const MAX_PHASE_ACTIVITIES = 12;
|
|
135
|
+
const MAX_PHASE_CONTEXT = 3;
|
|
136
|
+
const MAX_PHASE_TOOL_ENTRIES = 6;
|
|
137
|
+
export const ACTIVITY_PHASE_LABEL_MAX_LENGTH = 160;
|
|
108
138
|
|
|
109
139
|
export type BuildActivityLabelPromptParams = {
|
|
110
140
|
entries: ActivityLabelToolEntry[];
|
|
@@ -240,3 +270,149 @@ export function buildActivityLabelPrompt({
|
|
|
240
270
|
sections.push('Header:');
|
|
241
271
|
return sections.join('\n\n');
|
|
242
272
|
}
|
|
273
|
+
|
|
274
|
+
export type BuildActivityPhaseLabelPromptParams = {
|
|
275
|
+
activities: ActivityPhaseEntry[];
|
|
276
|
+
totalActivityCount?: number;
|
|
277
|
+
charLimit: number;
|
|
278
|
+
assistantContext?: string[];
|
|
279
|
+
redaction?: ResolvedLangfuseToolOutputTracingConfig;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Builds bounded, redaction-aware evidence for a parent activity phase.
|
|
284
|
+
* Committed child labels are preferred; raw tool/reasoning evidence is only
|
|
285
|
+
* used when no child label exists.
|
|
286
|
+
*/
|
|
287
|
+
export function buildActivityPhaseLabelPrompt({
|
|
288
|
+
activities,
|
|
289
|
+
totalActivityCount,
|
|
290
|
+
charLimit,
|
|
291
|
+
assistantContext,
|
|
292
|
+
redaction,
|
|
293
|
+
}: BuildActivityPhaseLabelPromptParams): string {
|
|
294
|
+
const freeFormSuppressed =
|
|
295
|
+
redaction != null &&
|
|
296
|
+
(redaction.enabled === false || redaction.redactedToolNames.size > 0);
|
|
297
|
+
const sections: string[] = [];
|
|
298
|
+
if (
|
|
299
|
+
!freeFormSuppressed &&
|
|
300
|
+
assistantContext != null &&
|
|
301
|
+
assistantContext.length > 0
|
|
302
|
+
) {
|
|
303
|
+
const context = assistantContext
|
|
304
|
+
.slice(-MAX_PHASE_CONTEXT)
|
|
305
|
+
.map((text) =>
|
|
306
|
+
truncateForLabel(text.replace(/\s+/g, ' ').trim(), charLimit)
|
|
307
|
+
)
|
|
308
|
+
.filter((text) => text.length > 0);
|
|
309
|
+
if (context.length > 0) {
|
|
310
|
+
sections.push(
|
|
311
|
+
'Intermediate assistant context (do not quote or restate):\n' +
|
|
312
|
+
context.map((text) => `- ${text}`).join('\n')
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let hasDescribableEvidence = false;
|
|
318
|
+
const activityLines = activities
|
|
319
|
+
.slice(0, MAX_PHASE_ACTIVITIES)
|
|
320
|
+
.map((activity, index) => {
|
|
321
|
+
let status = 'completed';
|
|
322
|
+
if (activity.status === 'error') {
|
|
323
|
+
status = 'failed';
|
|
324
|
+
} else if (activity.status === 'partial') {
|
|
325
|
+
status = 'partial';
|
|
326
|
+
}
|
|
327
|
+
if (
|
|
328
|
+
!freeFormSuppressed &&
|
|
329
|
+
activity.label != null &&
|
|
330
|
+
activity.label.trim() !== ''
|
|
331
|
+
) {
|
|
332
|
+
hasDescribableEvidence = true;
|
|
333
|
+
return `${index + 1}. ${status}: ${truncateForLabel(activity.label.replace(/\s+/g, ' ').trim(), charLimit)}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const evidence: string[] = [];
|
|
337
|
+
if (
|
|
338
|
+
!freeFormSuppressed &&
|
|
339
|
+
activity.thinkingExcerpts != null &&
|
|
340
|
+
activity.thinkingExcerpts.length > 0
|
|
341
|
+
) {
|
|
342
|
+
evidence.push(
|
|
343
|
+
...activity.thinkingExcerpts
|
|
344
|
+
.slice(0, MAX_THINKING_EXCERPTS)
|
|
345
|
+
.map((excerpt) =>
|
|
346
|
+
truncateForLabel(excerpt.replace(/\s+/g, ' ').trim(), charLimit)
|
|
347
|
+
)
|
|
348
|
+
.filter((excerpt) => excerpt.length > 0)
|
|
349
|
+
.map((excerpt) => `context=${excerpt}`)
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (activity.entries != null && activity.entries.length > 0) {
|
|
353
|
+
evidence.push(
|
|
354
|
+
...activity.entries.slice(0, MAX_PHASE_TOOL_ENTRIES).map((entry) => {
|
|
355
|
+
const entryRedacted =
|
|
356
|
+
redaction != null && shouldRedactTool(entry.toolName, redaction);
|
|
357
|
+
const input = truncateForLabel(
|
|
358
|
+
serializeForLabel(entry.toolInput, charLimit),
|
|
359
|
+
charLimit
|
|
360
|
+
);
|
|
361
|
+
let outcome: string;
|
|
362
|
+
if (entryRedacted) {
|
|
363
|
+
outcome = redaction.redactionText;
|
|
364
|
+
} else if (entry.status === 'error') {
|
|
365
|
+
outcome = `ERROR: ${truncateForLabel(
|
|
366
|
+
entry.error ?? 'unknown error',
|
|
367
|
+
charLimit
|
|
368
|
+
)}`;
|
|
369
|
+
} else {
|
|
370
|
+
outcome = truncateForLabel(
|
|
371
|
+
serializeForLabel(entry.toolOutput, charLimit),
|
|
372
|
+
charLimit
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return `${entry.toolName}(${input}) → ${outcome}`;
|
|
376
|
+
})
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (evidence.length > 0) {
|
|
380
|
+
hasDescribableEvidence = true;
|
|
381
|
+
}
|
|
382
|
+
return `${index + 1}. ${status}${evidence.length > 0 ? `: ${evidence.join('; ')}` : ''}`;
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (!hasDescribableEvidence) {
|
|
386
|
+
return '';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const activityCount = Math.max(activities.length, totalActivityCount ?? 0);
|
|
390
|
+
if (activityCount > MAX_PHASE_ACTIVITIES) {
|
|
391
|
+
activityLines.push(
|
|
392
|
+
`${MAX_PHASE_ACTIVITIES + 1}. …and ${activityCount - MAX_PHASE_ACTIVITIES} more activities`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
sections.push(
|
|
396
|
+
'Activities in this phase (synthesize; do not restate):\n' +
|
|
397
|
+
activityLines.join('\n')
|
|
398
|
+
);
|
|
399
|
+
const terminalCue = '\n\nPhase summary:';
|
|
400
|
+
const evidence = sections.join('\n\n');
|
|
401
|
+
const prompt = evidence + terminalCue;
|
|
402
|
+
if (prompt.length <= ACTIVITY_PHASE_PROMPT_MAX_LENGTH) {
|
|
403
|
+
return prompt;
|
|
404
|
+
}
|
|
405
|
+
const evidenceLimit =
|
|
406
|
+
ACTIVITY_PHASE_PROMPT_MAX_LENGTH - terminalCue.length - 1;
|
|
407
|
+
return `${evidence.slice(0, evidenceLimit).trimEnd()}…${terminalCue}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Normalizes a model result for safe single-row persistence and display. */
|
|
411
|
+
export function normalizeActivityPhaseLabel(label: string): string {
|
|
412
|
+
const normalized = label
|
|
413
|
+
.replace(/\s+/g, ' ')
|
|
414
|
+
.trim()
|
|
415
|
+
.replace(/^["']|["']$/g, '')
|
|
416
|
+
.replace(/[.!?]+$/g, '');
|
|
417
|
+
return truncateForLabel(normalized, ACTIVITY_PHASE_LABEL_MAX_LENGTH);
|
|
418
|
+
}
|