@gt-fe/eap-runtime 0.0.28 → 0.0.30
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/graph/adapters/langgraph.adapter.js +163 -131
- package/dist/graph/hitl/hitl-handler.js +6 -0
- package/dist/graph/trace-span.d.ts +15 -1
- package/dist/graph/trace-span.js +69 -1
- package/dist/runtime/agent-runtime.d.ts +12 -1
- package/dist/runtime/agent-runtime.js +35 -0
- package/dist/runtime/dev-runtime.js +1 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/types.d.ts +13 -0
- package/dist/session/apply-session-compression.js +5 -1
- package/dist/session/index.d.ts +1 -0
- package/dist/session/index.js +1 -0
- package/dist/session/session-title.d.ts +27 -0
- package/dist/session/session-title.js +86 -0
- package/package.json +2 -2
|
@@ -36,7 +36,7 @@ import { pickContextFields } from '../../token/context-window.js';
|
|
|
36
36
|
import { isHitlDecision, lastAssistantHasUnansweredToolCalls } from '../hitl/hitl-handler.js';
|
|
37
37
|
import { classifySkillRead } from './skill-read.js';
|
|
38
38
|
import { skillEntryInvocationFromTool } from './skill-entry-span.js';
|
|
39
|
-
import { withGraphSpan } from '../trace-span.js';
|
|
39
|
+
import { llmSpanInput, llmSpanOutputFromResult, withGraphSpan } from '../trace-span.js';
|
|
40
40
|
import { executionTraceFromState, getExecutionStream, getExecutionTrace, runWithExecutionTrace, } from '../execution-trace-context.js';
|
|
41
41
|
import { isRetryableToolFailure, resolveToolRetryConfig, sleepToolRetry, toolRetryDelayMs, } from '../../tool/tool-retry.js';
|
|
42
42
|
/**
|
|
@@ -788,17 +788,30 @@ export class LangGraphAdapter {
|
|
|
788
788
|
case 'tool':
|
|
789
789
|
return this.createToolNodeFunction(node);
|
|
790
790
|
case 'condition':
|
|
791
|
-
return async (state) =>
|
|
791
|
+
return async (state) => {
|
|
792
792
|
const conditionExpr = node.config?.['conditionExpr']
|
|
793
793
|
?? node.config?.['conditionFn'];
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
794
|
+
const branchValues = node.config?.['branchValues']
|
|
795
|
+
?? this.extractBranchValuesFromDsl(node.id);
|
|
796
|
+
const fallback = node.config?.['fallback'] ?? '__default__';
|
|
797
|
+
return withGraphSpan(state, {
|
|
798
|
+
name: node.id,
|
|
799
|
+
type: 'condition',
|
|
800
|
+
extra: {
|
|
801
|
+
input: {
|
|
802
|
+
conditionExpr: conditionExpr ?? null,
|
|
803
|
+
branchValues,
|
|
804
|
+
fallback,
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
}, async () => {
|
|
808
|
+
if (conditionExpr) {
|
|
809
|
+
const result = await this.conditionEvaluator.evaluate(conditionExpr, state, branchValues, fallback);
|
|
810
|
+
return { currentNode: node.id, __conditionResult: result };
|
|
811
|
+
}
|
|
812
|
+
return { currentNode: node.id };
|
|
813
|
+
}, { outputFrom: (result) => result['__conditionResult'] });
|
|
814
|
+
};
|
|
802
815
|
case 'subgraph':
|
|
803
816
|
return this.createSubGraphNodeFunction(node);
|
|
804
817
|
case 'human':
|
|
@@ -1019,136 +1032,155 @@ export class LangGraphAdapter {
|
|
|
1019
1032
|
lastAssistantHasUnansweredToolCalls(rawMessages)) {
|
|
1020
1033
|
return { currentNode: node.id };
|
|
1021
1034
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
:
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
},
|
|
1049
|
-
});
|
|
1050
|
-
if (loadedTools && loadedTools !== loadedToolsBase) {
|
|
1051
|
-
state['loadedTools'] = loadedTools;
|
|
1052
|
-
}
|
|
1053
|
-
// [DEBUG] 打印 loadedTools 实际状态
|
|
1054
|
-
if (process.env.EAP_LLM_DEBUG === '1') {
|
|
1055
|
-
const toolNames = loadedTools ? Object.keys(loadedTools) : [];
|
|
1056
|
-
console.log(`[LLM-DEBUG] node=${node.id} loadedTools=${toolNames.length}:`, toolNames);
|
|
1057
|
-
}
|
|
1058
|
-
const catalog = includeSkillCatalog ? this.buildSkillCatalog(skillDefs) : undefined;
|
|
1059
|
-
const toolCatalog = disableTools ? undefined : buildToolCatalog(loadedTools);
|
|
1060
|
-
const jail = resolveJailFromState({
|
|
1061
|
-
agentDir: state['agentDir'],
|
|
1062
|
-
userId,
|
|
1063
|
-
executionContext: execCtx,
|
|
1064
|
-
loadedTools,
|
|
1065
|
-
skillDefinitions: skillDefs,
|
|
1066
|
-
});
|
|
1067
|
-
if (this.overlayExtraRoots.length > 0) {
|
|
1068
|
-
jail.extraRoots = [...(jail.extraRoots ?? []), ...this.overlayExtraRoots];
|
|
1069
|
-
}
|
|
1070
|
-
const workingDirectory = jail.workdir;
|
|
1071
|
-
const jailRoots = resolveJailRoots(jail);
|
|
1072
|
-
const skillDirs = Object.values(skillDefs ?? {})
|
|
1073
|
-
.filter((skill) => Boolean(skill.bundlePath))
|
|
1074
|
-
.map((skill) => ({
|
|
1075
|
-
skillId: skill.skillId,
|
|
1076
|
-
bundlePath: skill.bundlePath,
|
|
1077
|
-
entryFile: skill.entryFile,
|
|
1078
|
-
}));
|
|
1079
|
-
const promptInputs = resolveLlmPromptInputs({
|
|
1080
|
-
silent,
|
|
1081
|
-
agentSystemPrompt: state['agentSystemPrompt'],
|
|
1082
|
-
nodePrompt: resolvedPrompt,
|
|
1035
|
+
const promptRoots = Array.isArray(state['promptRoots']) ? state['promptRoots'] : [];
|
|
1036
|
+
const resolvedPrompt = resolveGraphPrompt(baseConfig.systemPrompt, promptRoots);
|
|
1037
|
+
const loadedToolsBase = state['loadedTools'];
|
|
1038
|
+
const agentCode = typeof state['agentCode'] === 'string' ? state['agentCode'] : undefined;
|
|
1039
|
+
const skillDefs = state['skillDefinitions'];
|
|
1040
|
+
const execCtx = state['executionContext'];
|
|
1041
|
+
const userId = (typeof execCtx?.userId === 'string' && execCtx.userId) || state['userId'];
|
|
1042
|
+
const loadedTools = disableTools
|
|
1043
|
+
? loadedToolsBase
|
|
1044
|
+
: await hydrateMcpTools(loadedToolsBase, this.toolExecutor, {
|
|
1045
|
+
authContext: {
|
|
1046
|
+
userId: String(userId ?? ''),
|
|
1047
|
+
delegationToken: String(execCtx?.delegationToken ?? state['delegationToken'] ?? ''),
|
|
1048
|
+
agentCode: String(agentCode ?? ''),
|
|
1049
|
+
},
|
|
1050
|
+
correlationId: typeof state['correlationId'] === 'string' ? state['correlationId'] : undefined,
|
|
1051
|
+
taskId: typeof state['taskId'] === 'string' ? state['taskId'] : undefined,
|
|
1052
|
+
onListFailure: ({ toolset, error }) => {
|
|
1053
|
+
this.emitToolExecutionEvents({
|
|
1054
|
+
toolCallId: `mcp-list:${toolset}`,
|
|
1055
|
+
toolName: MCP_LIST_TOOL_NAME,
|
|
1056
|
+
toolset,
|
|
1057
|
+
status: 'error',
|
|
1058
|
+
error,
|
|
1059
|
+
});
|
|
1060
|
+
},
|
|
1083
1061
|
});
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1062
|
+
if (loadedTools && loadedTools !== loadedToolsBase) {
|
|
1063
|
+
state['loadedTools'] = loadedTools;
|
|
1064
|
+
}
|
|
1065
|
+
// [DEBUG] 打印 loadedTools 实际状态
|
|
1066
|
+
if (process.env.EAP_LLM_DEBUG === '1') {
|
|
1067
|
+
const toolNames = loadedTools ? Object.keys(loadedTools) : [];
|
|
1068
|
+
console.log(`[LLM-DEBUG] node=${node.id} loadedTools=${toolNames.length}:`, toolNames);
|
|
1069
|
+
}
|
|
1070
|
+
const catalog = includeSkillCatalog ? this.buildSkillCatalog(skillDefs) : undefined;
|
|
1071
|
+
const toolCatalog = disableTools ? undefined : buildToolCatalog(loadedTools);
|
|
1072
|
+
const jail = resolveJailFromState({
|
|
1073
|
+
agentDir: state['agentDir'],
|
|
1074
|
+
userId,
|
|
1075
|
+
executionContext: execCtx,
|
|
1076
|
+
loadedTools,
|
|
1077
|
+
skillDefinitions: skillDefs,
|
|
1078
|
+
});
|
|
1079
|
+
if (this.overlayExtraRoots.length > 0) {
|
|
1080
|
+
jail.extraRoots = [...(jail.extraRoots ?? []), ...this.overlayExtraRoots];
|
|
1081
|
+
}
|
|
1082
|
+
const workingDirectory = jail.workdir;
|
|
1083
|
+
const jailRoots = resolveJailRoots(jail);
|
|
1084
|
+
const skillDirs = Object.values(skillDefs ?? {})
|
|
1085
|
+
.filter((skill) => Boolean(skill.bundlePath))
|
|
1086
|
+
.map((skill) => ({
|
|
1087
|
+
skillId: skill.skillId,
|
|
1088
|
+
bundlePath: skill.bundlePath,
|
|
1089
|
+
entryFile: skill.entryFile,
|
|
1090
|
+
}));
|
|
1091
|
+
const promptInputs = resolveLlmPromptInputs({
|
|
1092
|
+
silent,
|
|
1093
|
+
agentSystemPrompt: state['agentSystemPrompt'],
|
|
1094
|
+
nodePrompt: resolvedPrompt,
|
|
1095
|
+
});
|
|
1096
|
+
const assembled = this.systemPromptBuilder.build({
|
|
1097
|
+
agentPrompt: promptInputs.agentPrompt,
|
|
1098
|
+
nodePrompt: promptInputs.nodePrompt,
|
|
1099
|
+
skillCatalog: catalog,
|
|
1100
|
+
toolCatalog,
|
|
1101
|
+
agentCode,
|
|
1102
|
+
userId,
|
|
1103
|
+
workingDirectory,
|
|
1104
|
+
os: { platform: os.platform(), release: os.release() },
|
|
1105
|
+
jailRoots,
|
|
1106
|
+
skillDirs,
|
|
1107
|
+
});
|
|
1108
|
+
const apiTools = disableTools ? [] : loadedToolsToApiTools(loadedTools);
|
|
1109
|
+
const finalConfig = {
|
|
1110
|
+
...baseConfig,
|
|
1111
|
+
systemPrompt: assembled.text,
|
|
1112
|
+
systemSections: assembled.sections,
|
|
1113
|
+
turn: {
|
|
1114
|
+
sessionId: typeof state['sessionId'] === 'string' ? state['sessionId'] : undefined,
|
|
1115
|
+
taskId: typeof state['taskId'] === 'string' ? state['taskId'] : undefined,
|
|
1116
|
+
nodeId: node.id,
|
|
1089
1117
|
agentCode,
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
// 流式旁路:有本次 stream 袋时启用 LLM token 推送;silent 节点不推给用户
|
|
1112
|
-
const stream = getExecutionStream();
|
|
1113
|
-
const enableStream = Boolean(stream) && !silent;
|
|
1114
|
-
if (stream) {
|
|
1115
|
-
stream.reasoningStartedThisTurn = false;
|
|
1116
|
-
stream.streamedTextThisTurn = false;
|
|
1117
|
-
}
|
|
1118
|
-
const llmNode = new LLMNode(node.id, finalConfig, this.llmProvider, enableStream, enableStream
|
|
1119
|
-
? (text) => {
|
|
1120
|
-
const live = getExecutionStream();
|
|
1121
|
-
if (!live || !text)
|
|
1122
|
-
return;
|
|
1123
|
-
const timestamp = new Date().toISOString();
|
|
1124
|
-
if (!live.reasoningStartedThisTurn) {
|
|
1125
|
-
live.reasoningStartedThisTurn = true;
|
|
1126
|
-
live.iteration += 1;
|
|
1127
|
-
live.sink({
|
|
1128
|
-
type: 'reasoning.started',
|
|
1129
|
-
taskId: undefined,
|
|
1130
|
-
correlationId: undefined,
|
|
1131
|
-
timestamp,
|
|
1132
|
-
payload: {
|
|
1133
|
-
iterationCount: live.iteration,
|
|
1134
|
-
model: finalConfig.model,
|
|
1135
|
-
},
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
live.streamedTextThisTurn = true;
|
|
1118
|
+
correlationId: typeof state['correlationId'] === 'string' ? state['correlationId'] : undefined,
|
|
1119
|
+
},
|
|
1120
|
+
tools: disableTools ? undefined : (apiTools.length > 0 ? apiTools : baseConfig.tools),
|
|
1121
|
+
toolChoice: disableTools ? 'none' : baseConfig.toolChoice,
|
|
1122
|
+
};
|
|
1123
|
+
// 流式旁路:有本次 stream 袋时启用 LLM token 推送;silent 节点不推给用户
|
|
1124
|
+
const stream = getExecutionStream();
|
|
1125
|
+
const enableStream = Boolean(stream) && !silent;
|
|
1126
|
+
if (stream) {
|
|
1127
|
+
stream.reasoningStartedThisTurn = false;
|
|
1128
|
+
stream.streamedTextThisTurn = false;
|
|
1129
|
+
}
|
|
1130
|
+
const llmNode = new LLMNode(node.id, finalConfig, this.llmProvider, enableStream, enableStream
|
|
1131
|
+
? (text) => {
|
|
1132
|
+
const live = getExecutionStream();
|
|
1133
|
+
if (!live || !text)
|
|
1134
|
+
return;
|
|
1135
|
+
const timestamp = new Date().toISOString();
|
|
1136
|
+
if (!live.reasoningStartedThisTurn) {
|
|
1137
|
+
live.reasoningStartedThisTurn = true;
|
|
1138
|
+
live.iteration += 1;
|
|
1139
1139
|
live.sink({
|
|
1140
|
-
type: '
|
|
1140
|
+
type: 'reasoning.started',
|
|
1141
1141
|
taskId: undefined,
|
|
1142
1142
|
correlationId: undefined,
|
|
1143
1143
|
timestamp,
|
|
1144
1144
|
payload: {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
node: node.id,
|
|
1145
|
+
iterationCount: live.iteration,
|
|
1146
|
+
model: finalConfig.model,
|
|
1148
1147
|
},
|
|
1149
1148
|
});
|
|
1150
1149
|
}
|
|
1151
|
-
|
|
1150
|
+
live.streamedTextThisTurn = true;
|
|
1151
|
+
live.sink({
|
|
1152
|
+
type: 'response.chunk',
|
|
1153
|
+
taskId: undefined,
|
|
1154
|
+
correlationId: undefined,
|
|
1155
|
+
timestamp,
|
|
1156
|
+
payload: {
|
|
1157
|
+
content: text,
|
|
1158
|
+
index: live.chunkIndex++,
|
|
1159
|
+
node: node.id,
|
|
1160
|
+
},
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
: undefined, this.interceptors);
|
|
1164
|
+
const toolsForSpan = disableTools || !loadedTools
|
|
1165
|
+
? undefined
|
|
1166
|
+
: Object.values(loadedTools).map((t) => ({
|
|
1167
|
+
name: t.name,
|
|
1168
|
+
...(t.manifest.description?.trim()
|
|
1169
|
+
? { description: t.manifest.description.trim() }
|
|
1170
|
+
: {}),
|
|
1171
|
+
}));
|
|
1172
|
+
// 组装后再 createNode:input 必须是发给模型的 system/tools/messages;completeNode 不能补 input。
|
|
1173
|
+
return withGraphSpan(state, {
|
|
1174
|
+
name: node.id,
|
|
1175
|
+
type: 'llm',
|
|
1176
|
+
extra: {
|
|
1177
|
+
input: llmSpanInput({
|
|
1178
|
+
system: assembled.text,
|
|
1179
|
+
tools: toolsForSpan,
|
|
1180
|
+
messages: state['messages'],
|
|
1181
|
+
}),
|
|
1182
|
+
},
|
|
1183
|
+
}, async () => {
|
|
1152
1184
|
const next = await llmNode.execute(state);
|
|
1153
1185
|
const hydratedUpdate = loadedTools && loadedTools !== loadedToolsBase ? { loadedTools } : {};
|
|
1154
1186
|
if (!silent)
|
|
@@ -1163,7 +1195,7 @@ export class LangGraphAdapter {
|
|
|
1163
1195
|
silentOutput: raw,
|
|
1164
1196
|
...hydratedUpdate,
|
|
1165
1197
|
};
|
|
1166
|
-
});
|
|
1198
|
+
}, { outputFrom: llmSpanOutputFromResult });
|
|
1167
1199
|
};
|
|
1168
1200
|
}
|
|
1169
1201
|
/**
|
|
@@ -289,6 +289,12 @@ export class HitlHandler {
|
|
|
289
289
|
name: extras.toolName ?? 'hitl',
|
|
290
290
|
type: 'hitl',
|
|
291
291
|
parentSpanId: extras.toolSpanId ?? skillSpanId,
|
|
292
|
+
extra: {
|
|
293
|
+
input: {
|
|
294
|
+
question: config.question,
|
|
295
|
+
options: config.options,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
292
298
|
});
|
|
293
299
|
const persisted = {
|
|
294
300
|
taskId,
|
|
@@ -12,4 +12,18 @@ export declare function completeGraphSpan(state: Record<string, unknown>, spanId
|
|
|
12
12
|
export declare function withGraphSpan(state: Record<string, unknown>, spec: {
|
|
13
13
|
name: string;
|
|
14
14
|
type: ExecutionNodeEvent['type'];
|
|
15
|
-
|
|
15
|
+
extra?: Partial<Omit<ExecutionNodeEvent, 'traceId' | 'name' | 'type' | 'parentNodeId'>>;
|
|
16
|
+
}, run: () => Promise<Record<string, unknown>>, complete?: {
|
|
17
|
+
outputFrom?: (result: Record<string, unknown>) => unknown;
|
|
18
|
+
}): Promise<Record<string, unknown>>;
|
|
19
|
+
/** llm Span create 时的 input:组装后的 system、工具短目录、本回合 messages。 */
|
|
20
|
+
export declare function llmSpanInput(input: {
|
|
21
|
+
system?: string;
|
|
22
|
+
tools?: Array<{
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
}>;
|
|
26
|
+
messages: unknown;
|
|
27
|
+
}): Record<string, unknown>;
|
|
28
|
+
/** llm Span complete 的 output:本回合新增 assistant(含 toolCalls)或 silentOutput。 */
|
|
29
|
+
export declare function llmSpanOutputFromResult(result: Record<string, unknown>): unknown;
|
package/dist/graph/trace-span.js
CHANGED
|
@@ -43,15 +43,17 @@ export async function completeGraphSpan(state, spanId, params, collectorOverride
|
|
|
43
43
|
warnTraceSpanFailure('completeNode', { spanId, traceId: resolveExecutionTrace(state).traceId }, err);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
export async function withGraphSpan(state, spec, run) {
|
|
46
|
+
export async function withGraphSpan(state, spec, run, complete) {
|
|
47
47
|
const spanId = await startGraphSpan(state, spec);
|
|
48
48
|
try {
|
|
49
49
|
const result = await run();
|
|
50
50
|
const usage = Array.isArray(result['__llmTokenUsage'])
|
|
51
51
|
? result['__llmTokenUsage'].at(-1)
|
|
52
52
|
: undefined;
|
|
53
|
+
const output = complete?.outputFrom?.(result);
|
|
53
54
|
await completeGraphSpan(state, spanId, {
|
|
54
55
|
status: 'completed',
|
|
56
|
+
...(output !== undefined ? { output } : {}),
|
|
55
57
|
inputTokens: usage?.inputTokens,
|
|
56
58
|
outputTokens: usage?.outputTokens,
|
|
57
59
|
totalTokens: usage?.inputTokens != null || usage?.outputTokens != null
|
|
@@ -69,3 +71,69 @@ export async function withGraphSpan(state, spec, run) {
|
|
|
69
71
|
throw err;
|
|
70
72
|
}
|
|
71
73
|
}
|
|
74
|
+
function spanGraphMessages(raw) {
|
|
75
|
+
if (!Array.isArray(raw))
|
|
76
|
+
return [];
|
|
77
|
+
return raw.map((msg) => {
|
|
78
|
+
if (typeof msg !== 'object' || msg === null) {
|
|
79
|
+
return { role: 'user', content: String(msg) };
|
|
80
|
+
}
|
|
81
|
+
const m = msg;
|
|
82
|
+
const out = {
|
|
83
|
+
role: typeof m['role'] === 'string' ? m['role'] : 'user',
|
|
84
|
+
content: typeof m['content'] === 'string' ? m['content'] : '',
|
|
85
|
+
};
|
|
86
|
+
const toolCalls = m['toolCalls'] ?? m['tool_calls'];
|
|
87
|
+
if (toolCalls !== undefined)
|
|
88
|
+
out.toolCalls = toolCalls;
|
|
89
|
+
const toolCallId = typeof m['toolCallId'] === 'string'
|
|
90
|
+
? m['toolCallId']
|
|
91
|
+
: typeof m['tool_call_id'] === 'string'
|
|
92
|
+
? m['tool_call_id']
|
|
93
|
+
: undefined;
|
|
94
|
+
if (toolCallId)
|
|
95
|
+
out.toolCallId = toolCallId;
|
|
96
|
+
if (typeof m['toolName'] === 'string')
|
|
97
|
+
out.toolName = m['toolName'];
|
|
98
|
+
if (typeof m['success'] === 'boolean')
|
|
99
|
+
out.success = m['success'];
|
|
100
|
+
const reasoning = typeof m['reasoningContent'] === 'string'
|
|
101
|
+
? m['reasoningContent']
|
|
102
|
+
: typeof m['reasoning_content'] === 'string'
|
|
103
|
+
? m['reasoning_content']
|
|
104
|
+
: undefined;
|
|
105
|
+
if (reasoning)
|
|
106
|
+
out.reasoningContent = reasoning;
|
|
107
|
+
return out;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** llm Span create 时的 input:组装后的 system、工具短目录、本回合 messages。 */
|
|
111
|
+
export function llmSpanInput(input) {
|
|
112
|
+
const tools = input.tools?.filter((t) => t.name);
|
|
113
|
+
return {
|
|
114
|
+
...(input.system ? { system: input.system } : {}),
|
|
115
|
+
...(tools && tools.length > 0 ? { tools } : {}),
|
|
116
|
+
messages: spanGraphMessages(input.messages),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** llm Span complete 的 output:本回合新增 assistant(含 toolCalls)或 silentOutput。 */
|
|
120
|
+
export function llmSpanOutputFromResult(result) {
|
|
121
|
+
if (typeof result['silentOutput'] === 'string') {
|
|
122
|
+
return { content: result['silentOutput'] };
|
|
123
|
+
}
|
|
124
|
+
const messages = Array.isArray(result['messages']) ? result['messages'] : [];
|
|
125
|
+
const added = messages[0];
|
|
126
|
+
if (!added || typeof added !== 'object')
|
|
127
|
+
return undefined;
|
|
128
|
+
const m = added;
|
|
129
|
+
const out = {
|
|
130
|
+
content: typeof m['content'] === 'string' ? m['content'] : '',
|
|
131
|
+
};
|
|
132
|
+
const toolCalls = m['toolCalls'] ?? m['tool_calls'];
|
|
133
|
+
if (Array.isArray(toolCalls) && toolCalls.length > 0)
|
|
134
|
+
out.toolCalls = toolCalls;
|
|
135
|
+
if (typeof m['reasoningContent'] === 'string' && m['reasoningContent']) {
|
|
136
|
+
out.reasoningContent = m['reasoningContent'];
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
@@ -18,9 +18,10 @@ import type { MessageService } from '../session/message.service.js';
|
|
|
18
18
|
import type { SessionService, SessionRecord, MessageRecord } from '../session/session.service.js';
|
|
19
19
|
import { type ILlmTurnLog, type LlmTurnIndexEntry, type LlmTurnLogKind, type LlmTurnRecord } from '../debug/llm-turn-log.js';
|
|
20
20
|
import { type SessionCompressionOptions } from '../session/apply-session-compression.js';
|
|
21
|
+
import type { ILLMProvider } from '../graph/nodes/llm-node.js';
|
|
21
22
|
import type { SSEEvent } from '../sse/contracts/sse-emitter.contract.js';
|
|
22
23
|
import type { TaskService } from '../task/task.service.js';
|
|
23
|
-
import type { ExecuteParams, ExecuteResult, ForkParams, ForkOverrides, ForkResult, HitlResponse, ResumeHitlResult, SeedSessionMessagesParams, SeedSessionMessagesResult } from './types.js';
|
|
24
|
+
import type { ExecuteParams, ExecuteResult, ForkParams, ForkOverrides, ForkResult, HitlResponse, ResumeHitlResult, SeedSessionMessagesParams, SeedSessionMessagesResult, GenerateSessionTitleParams, GenerateSessionTitleResult } from './types.js';
|
|
24
25
|
export interface ForkRelationRecord {
|
|
25
26
|
originalTraceId: string;
|
|
26
27
|
forkedTraceId: string;
|
|
@@ -50,6 +51,11 @@ export interface AgentRuntimeDeps {
|
|
|
50
51
|
recordForkRelation?: RecordForkRelation;
|
|
51
52
|
/** 历史压缩;默认开。EAP_RUNTIME_COMPRESSION=0 关闭 */
|
|
52
53
|
sessionCompression?: SessionCompressionOptions;
|
|
54
|
+
/**
|
|
55
|
+
* 会话标题 completion。与图引擎共用同一实例。
|
|
56
|
+
* 未注入时回退 `sessionCompression.provider`。
|
|
57
|
+
*/
|
|
58
|
+
llmProvider?: ILLMProvider;
|
|
53
59
|
/** 长期记忆注入;默认关。见 EAP_RUNTIME_MEMORY */
|
|
54
60
|
sessionMemory?: import('../session/apply-session-memory.js').SessionMemoryOptions;
|
|
55
61
|
/** 未注入时不启动整图超时(测试桩) */
|
|
@@ -114,6 +120,11 @@ export declare class AgentRuntime {
|
|
|
114
120
|
listConversations(options?: {
|
|
115
121
|
userId?: string;
|
|
116
122
|
}): Promise<SessionRecord[]>;
|
|
123
|
+
/**
|
|
124
|
+
* 根据该 sessionId 的 Message 生成会话标题。不写库、不进 execute。
|
|
125
|
+
* 失败返回 `{ title: null }`。
|
|
126
|
+
*/
|
|
127
|
+
generateSessionTitle(params: GenerateSessionTitleParams): Promise<GenerateSessionTitleResult>;
|
|
117
128
|
private resolveTurnLog;
|
|
118
129
|
/** 同步执行 Graph(经 InvokeOrchestrator,内含 loadedTools 注入) */
|
|
119
130
|
execute(params: ExecuteParams): Promise<ExecuteResult>;
|
|
@@ -18,6 +18,8 @@ import { createFileLlmTurnLogFromEnv, } from '../debug/llm-turn-log.js';
|
|
|
18
18
|
import { buildHitlWaitingTaskOutput, buildTurnTranscriptFromStreamEvents, loadModelFacingHistory, turnGraphMessagesAfterCompression, extractHitlThreadId, extractStreamReasoning, applyStreamTextDelta, createStreamAssistantBuckets, visibleStreamAssistantText, streamAssistantTexts, messageRecordsToGraphMessages, persistHitlWaitingPlaceholder, persistTurnTranscript, persistUserTurnMessage, resolveCheckpointThreadId, appendTerminalToolResult, } from '../session/persist-turn-messages.js';
|
|
19
19
|
import { iterSessionCompression, withCompressionSummaryModel, } from '../session/apply-session-compression.js';
|
|
20
20
|
import { applySessionMemory } from '../session/apply-session-memory.js';
|
|
21
|
+
import { buildSessionTitleUserPayload, invokeSessionTitle, } from '../session/session-title.js';
|
|
22
|
+
import { EchoLLMProvider } from '../providers/echo-llm-provider.js';
|
|
21
23
|
import { resolveTokenDetail, shouldEmitTokenSse } from '../token/token-detail.js';
|
|
22
24
|
import { AppError, ErrorCodes } from '@gt-fe/eap-contracts';
|
|
23
25
|
import { resolveExecutionContext } from '../governance/governance-client.js';
|
|
@@ -192,6 +194,39 @@ export class AgentRuntime {
|
|
|
192
194
|
}
|
|
193
195
|
return [];
|
|
194
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* 根据该 sessionId 的 Message 生成会话标题。不写库、不进 execute。
|
|
199
|
+
* 失败返回 `{ title: null }`。
|
|
200
|
+
*/
|
|
201
|
+
async generateSessionTitle(params) {
|
|
202
|
+
try {
|
|
203
|
+
const provider = this.deps.llmProvider ?? this.deps.sessionCompression?.provider;
|
|
204
|
+
if (!provider || provider instanceof EchoLLMProvider) {
|
|
205
|
+
return { title: null };
|
|
206
|
+
}
|
|
207
|
+
if (!this.deps.messageService) {
|
|
208
|
+
return { title: null };
|
|
209
|
+
}
|
|
210
|
+
const messages = await this.deps.messageService.getMessages(params.sessionId);
|
|
211
|
+
const payload = buildSessionTitleUserPayload(messages);
|
|
212
|
+
if (!payload) {
|
|
213
|
+
return { title: null };
|
|
214
|
+
}
|
|
215
|
+
let model = params.model?.trim() ?? '';
|
|
216
|
+
if (!model && params.agentCode && this.deps.graphLoader) {
|
|
217
|
+
const resolved = await this.deps.graphLoader.resolveDefaultModel(params.agentCode, params.agentVersion);
|
|
218
|
+
model = resolved?.trim() ?? '';
|
|
219
|
+
}
|
|
220
|
+
const title = await invokeSessionTitle(provider, payload, {
|
|
221
|
+
model,
|
|
222
|
+
sessionId: params.sessionId,
|
|
223
|
+
});
|
|
224
|
+
return { title };
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return { title: null };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
195
230
|
resolveTurnLog() {
|
|
196
231
|
return this.deps.turnLog ?? createFileLlmTurnLogFromEnv();
|
|
197
232
|
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { AgentRuntime, createAgentRuntime, type AgentRuntimeDeps, type ForkRelationRecord, type RecordForkRelation, type ConversationDetail, } from './agent-runtime.js';
|
|
2
|
-
export type { ExecuteParams, ExecuteResult, ForkParams, ForkOverrides, ForkResult, HitlResponse, ResumeHitlResult, SSEEvent, } from './types.js';
|
|
2
|
+
export type { ExecuteParams, ExecuteResult, ForkParams, ForkOverrides, ForkResult, HitlResponse, ResumeHitlResult, SSEEvent, GenerateSessionTitleParams, GenerateSessionTitleResult, } from './types.js';
|
|
3
3
|
export { createDevRuntime, type DevRuntime, type CreateDevRuntimeOptions, } from './dev-runtime.js';
|
|
4
4
|
export { assembleRuntimeStorage, resolveRuntimeStorageProfile, resolveDevStorageDirs, type RuntimeStorageProfile, type AssembleRuntimeStorageOptions, type AssembledRuntimeStorage, } from './storage-profile.js';
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -38,6 +38,19 @@ export interface ExecuteParams {
|
|
|
38
38
|
}
|
|
39
39
|
/** 同步执行结果 */
|
|
40
40
|
export type ExecuteResult = InvokeResponseData;
|
|
41
|
+
/** 会话标题生成入参。不启动图执行。 */
|
|
42
|
+
export interface GenerateSessionTitleParams {
|
|
43
|
+
sessionId: string;
|
|
44
|
+
/** 覆盖标题模型;未设则用 Agent `models.default` */
|
|
45
|
+
model?: string;
|
|
46
|
+
/** 用于解析 `models.default`;与 execute 的 agentCode 相同 */
|
|
47
|
+
agentCode?: string;
|
|
48
|
+
agentVersion?: string;
|
|
49
|
+
}
|
|
50
|
+
/** 已规范到 ≤50 字;失败为 null */
|
|
51
|
+
export interface GenerateSessionTitleResult {
|
|
52
|
+
title: string | null;
|
|
53
|
+
}
|
|
41
54
|
/** HITL 恢复结果(与 @gt-fe/eap-contracts ResumeHitlResult 对齐) */
|
|
42
55
|
export type { ResumeHitlResult };
|
|
43
56
|
/** HITL 用户响应(与 @gt-fe/eap-contracts ResumeHitlRequest 对齐;userId 必填) */
|
|
@@ -13,6 +13,7 @@ import { EchoLLMProvider } from '../providers/echo-llm-provider.js';
|
|
|
13
13
|
import { COMPRESSION_WINDOW_FACTOR, countRounds, DEFAULT_COMPRESSION_CONFIG, } from '../memory/message-compression.service.js';
|
|
14
14
|
import { SlidingWindowCompression } from '../memory/compression/sliding-window.compression.js';
|
|
15
15
|
import { emitRuntimeLog } from '../common/logger.js';
|
|
16
|
+
import { SESSION_TITLE_TURN_NODE_ID } from './session-title.js';
|
|
16
17
|
/** 摘要 LLM 写入 llm_turns 的 nodeId。lastTurnUsage 跳过,避免用摘要占用触发下一轮压缩。 */
|
|
17
18
|
export const COMPRESSION_TURN_NODE_ID = 'session-compression';
|
|
18
19
|
export function isRuntimeCompressionEnabled(explicit, env = process.env) {
|
|
@@ -256,8 +257,11 @@ async function lastTurnUsage(turnLog, sessionId) {
|
|
|
256
257
|
const index = await turnLog.list(sessionId);
|
|
257
258
|
for (let i = index.length - 1; i >= 0; i--) {
|
|
258
259
|
const entry = index[i];
|
|
259
|
-
if (!entry ||
|
|
260
|
+
if (!entry ||
|
|
261
|
+
entry.nodeId === COMPRESSION_TURN_NODE_ID ||
|
|
262
|
+
entry.nodeId === SESSION_TITLE_TURN_NODE_ID) {
|
|
260
263
|
continue;
|
|
264
|
+
}
|
|
261
265
|
if (typeof entry.used !== 'number')
|
|
262
266
|
continue;
|
|
263
267
|
return { used: entry.used, window: entry.window };
|
package/dist/session/index.d.ts
CHANGED
|
@@ -13,3 +13,4 @@ export { CompressionMiddleware, createCompressionMiddleware } from './compressio
|
|
|
13
13
|
export { applySessionCompression, COMPRESSION_TURN_NODE_ID, withCompressionSummaryModel, runSessionCompression, decideSessionCompression, iterSessionCompression, formatCompressionStartedMessage, formatCompressionCompletedMessage, isRuntimeCompressionEnabled, isRuntimeCompressionPersist, type SessionCompressionOptions, type RunSessionCompressionParams, type RunSessionCompressionResult, type SessionCompressionDecision, type SessionCompressionStep, } from './apply-session-compression.js';
|
|
14
14
|
export type { CompressionMiddlewareConfig } from './compression-middleware.js';
|
|
15
15
|
export { applySessionMemory, isRuntimeMemoryEnabled, type SessionMemoryOptions, } from './apply-session-memory.js';
|
|
16
|
+
export { SESSION_TITLE_MAX_CHARS, SESSION_TITLE_ASSISTANT_CHAR_BUDGET, SESSION_TITLE_TURN_NODE_ID, SESSION_TITLE_USER_CHAR_BUDGET, provisionalSessionTitle, normalizeGeneratedTitle, fitUserTexts, buildSessionTitleUserPayload, invokeSessionTitle, } from './session-title.js';
|
package/dist/session/index.js
CHANGED
|
@@ -12,3 +12,4 @@ export { SessionContextService } from './session-context.service.js';
|
|
|
12
12
|
export { CompressionMiddleware, createCompressionMiddleware } from './compression-middleware.js';
|
|
13
13
|
export { applySessionCompression, COMPRESSION_TURN_NODE_ID, withCompressionSummaryModel, runSessionCompression, decideSessionCompression, iterSessionCompression, formatCompressionStartedMessage, formatCompressionCompletedMessage, isRuntimeCompressionEnabled, isRuntimeCompressionPersist, } from './apply-session-compression.js';
|
|
14
14
|
export { applySessionMemory, isRuntimeMemoryEnabled, } from './apply-session-memory.js';
|
|
15
|
+
export { SESSION_TITLE_MAX_CHARS, SESSION_TITLE_ASSISTANT_CHAR_BUDGET, SESSION_TITLE_TURN_NODE_ID, SESSION_TITLE_USER_CHAR_BUDGET, provisionalSessionTitle, normalizeGeneratedTitle, fitUserTexts, buildSessionTitleUserPayload, invokeSessionTitle, } from './session-title.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话标题:从 Message 生成短标题字符串。
|
|
3
|
+
* 不写库、不进 execute。Hosting 负责落 sessions.title。
|
|
4
|
+
*
|
|
5
|
+
* @see docs/architecture/session-title-generation.md
|
|
6
|
+
*/
|
|
7
|
+
import type { GraphMessage } from '../graph/contracts/executable-graph.js';
|
|
8
|
+
import type { ILLMProvider } from '../graph/nodes/llm-node.js';
|
|
9
|
+
import type { MessageRecord } from './session.service.js';
|
|
10
|
+
/** 与 `@gt-fe/eap-contracts` `SESSION_TITLE_MAX_CHARS` 同值。 */
|
|
11
|
+
export declare const SESSION_TITLE_MAX_CHARS = 50;
|
|
12
|
+
export declare const SESSION_TITLE_USER_CHAR_BUDGET = 2000;
|
|
13
|
+
export declare const SESSION_TITLE_ASSISTANT_CHAR_BUDGET = 500;
|
|
14
|
+
/** 标题 LLM 写入 llm_turns 的 nodeId。压缩 lastTurnUsage 跳过。 */
|
|
15
|
+
export declare const SESSION_TITLE_TURN_NODE_ID = "session-title";
|
|
16
|
+
export declare function clipChars(text: string, max: number): string;
|
|
17
|
+
/** 临时标题:去换行后截到 50 字。 */
|
|
18
|
+
export declare function provisionalSessionTitle(userText: string): string;
|
|
19
|
+
export declare function normalizeGeneratedTitle(raw: string): string | null;
|
|
20
|
+
/** 超预算时丢掉最早的 user;只剩一条仍超则保留开头。 */
|
|
21
|
+
export declare function fitUserTexts(texts: string[], budget: number): string[];
|
|
22
|
+
export declare function buildSessionTitleUserPayload(messages: MessageRecord[]): string | null;
|
|
23
|
+
export declare function buildSessionTitlePromptMessages(payload: string): GraphMessage[];
|
|
24
|
+
export declare function invokeSessionTitle(provider: ILLMProvider, payload: string, options: {
|
|
25
|
+
model?: string;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
}): Promise<string | null>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话标题:从 Message 生成短标题字符串。
|
|
3
|
+
* 不写库、不进 execute。Hosting 负责落 sessions.title。
|
|
4
|
+
*
|
|
5
|
+
* @see docs/architecture/session-title-generation.md
|
|
6
|
+
*/
|
|
7
|
+
import { EchoLLMProvider } from '../providers/echo-llm-provider.js';
|
|
8
|
+
/** 与 `@gt-fe/eap-contracts` `SESSION_TITLE_MAX_CHARS` 同值。 */
|
|
9
|
+
export const SESSION_TITLE_MAX_CHARS = 50;
|
|
10
|
+
export const SESSION_TITLE_USER_CHAR_BUDGET = 2000;
|
|
11
|
+
export const SESSION_TITLE_ASSISTANT_CHAR_BUDGET = 500;
|
|
12
|
+
/** 标题 LLM 写入 llm_turns 的 nodeId。压缩 lastTurnUsage 跳过。 */
|
|
13
|
+
export const SESSION_TITLE_TURN_NODE_ID = 'session-title';
|
|
14
|
+
const TITLE_SYSTEM_PROMPT = '根据对话写一个不超过50个字的会话标题。跟随用户主要语言。只输出标题,不要引号、不要解释。';
|
|
15
|
+
export function clipChars(text, max) {
|
|
16
|
+
const chars = Array.from(text);
|
|
17
|
+
return chars.length <= max ? text : chars.slice(0, max).join('');
|
|
18
|
+
}
|
|
19
|
+
/** 临时标题:去换行后截到 50 字。 */
|
|
20
|
+
export function provisionalSessionTitle(userText) {
|
|
21
|
+
const collapsed = userText.replace(/\s+/g, ' ').trim();
|
|
22
|
+
return clipChars(collapsed, SESSION_TITLE_MAX_CHARS);
|
|
23
|
+
}
|
|
24
|
+
export function normalizeGeneratedTitle(raw) {
|
|
25
|
+
const stripped = raw
|
|
26
|
+
.replace(/^[\s"'「」『』《》]+|[\s"'「」『』《》]+$/g, '')
|
|
27
|
+
.replace(/\s+/g, ' ')
|
|
28
|
+
.trim();
|
|
29
|
+
if (!stripped)
|
|
30
|
+
return null;
|
|
31
|
+
return clipChars(stripped, SESSION_TITLE_MAX_CHARS);
|
|
32
|
+
}
|
|
33
|
+
function charLength(text) {
|
|
34
|
+
return Array.from(text).length;
|
|
35
|
+
}
|
|
36
|
+
/** 超预算时丢掉最早的 user;只剩一条仍超则保留开头。 */
|
|
37
|
+
export function fitUserTexts(texts, budget) {
|
|
38
|
+
const out = texts.filter((t) => t.length > 0);
|
|
39
|
+
while (out.length > 1 && out.reduce((n, t) => n + charLength(t), 0) > budget) {
|
|
40
|
+
out.shift();
|
|
41
|
+
}
|
|
42
|
+
if (out.length === 1 && charLength(out[0]) > budget) {
|
|
43
|
+
out[0] = clipChars(out[0], budget);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function buildSessionTitleUserPayload(messages) {
|
|
48
|
+
const users = messages
|
|
49
|
+
.filter((m) => m.role === 'user')
|
|
50
|
+
.map((m) => m.content.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
if (users.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
const fitted = fitUserTexts(users, SESSION_TITLE_USER_CHAR_BUDGET);
|
|
55
|
+
const parts = [...fitted];
|
|
56
|
+
const lastAssistant = [...messages]
|
|
57
|
+
.reverse()
|
|
58
|
+
.find((m) => m.role === 'assistant' && m.content.trim());
|
|
59
|
+
if (lastAssistant) {
|
|
60
|
+
parts.push(clipChars(lastAssistant.content.trim(), SESSION_TITLE_ASSISTANT_CHAR_BUDGET));
|
|
61
|
+
}
|
|
62
|
+
return parts.join('\n');
|
|
63
|
+
}
|
|
64
|
+
export function buildSessionTitlePromptMessages(payload) {
|
|
65
|
+
return [
|
|
66
|
+
{ role: 'system', content: TITLE_SYSTEM_PROMPT },
|
|
67
|
+
{ role: 'user', content: payload },
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
export async function invokeSessionTitle(provider, payload, options) {
|
|
71
|
+
if (provider instanceof EchoLLMProvider)
|
|
72
|
+
return null;
|
|
73
|
+
const rawModel = options.model?.trim() ?? '';
|
|
74
|
+
const model = rawModel.toLowerCase() === 'default' ? '' : rawModel;
|
|
75
|
+
const response = await provider.invoke(buildSessionTitlePromptMessages(payload), {
|
|
76
|
+
model,
|
|
77
|
+
temperature: 0.2,
|
|
78
|
+
maxTokens: 64,
|
|
79
|
+
toolChoice: 'none',
|
|
80
|
+
turn: {
|
|
81
|
+
sessionId: options.sessionId,
|
|
82
|
+
nodeId: SESSION_TITLE_TURN_NODE_ID,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
return normalizeGeneratedTitle(response.content ?? '');
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gt-fe/eap-runtime",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"description": "Enterprise Agent Platform — Runtime核心:Graph执行引擎、Task生命周期、Session管理、Checkpoint持久化",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"ioredis": "^5.4.0",
|
|
21
21
|
"pg": "^8.13.0",
|
|
22
22
|
"yaml": "^2.6.1",
|
|
23
|
-
"@gt-fe/eap-contracts": "0.0.
|
|
23
|
+
"@gt-fe/eap-contracts": "0.0.16"
|
|
24
24
|
},
|
|
25
25
|
"publishConfig": {
|
|
26
26
|
"registry": "https://registry.npmjs.org/",
|