mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-10-01 01:38:07 +08:00
* feat(usage): expand experience tracking and log schema * fix(usage): preserve experience count event names * refactor(agent-evolution): use generic OpenViking tools * fix(usage): capture generic OpenViking tool events * feat(skills): guide cross-agent experience retrieval * fix(usage): address generic tool migration review
484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
import type { CaptureMode } from "./client.js";
|
||
|
||
export const MEMORY_TRIGGERS = [
|
||
/remember|preference|prefer|important|decision|decided|always|never/i,
|
||
/记住|偏好|喜欢|喜爱|崇拜|讨厌|害怕|重要|决定|总是|永远|优先|习惯|爱好|擅长|最爱|不喜欢/i,
|
||
/[\w.-]+@[\w.-]+\.\w+/,
|
||
/\+\d{10,}/,
|
||
/(?:我|my)\s*(?:是|叫|名字|name|住在|live|来自|from|生日|birthday|电话|phone|邮箱|email)/i,
|
||
/(?:我|i)\s*(?:喜欢|崇拜|讨厌|害怕|擅长|不会|爱|恨|想要|需要|希望|觉得|认为|相信)/i,
|
||
/(?:favorite|favourite|love|hate|enjoy|dislike|admire|idol|fan of)/i,
|
||
];
|
||
|
||
const CJK_CHAR_REGEX = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
|
||
const RELEVANT_MEMORIES_BLOCK_RE = /<relevant-memories>[\s\S]*?<\/relevant-memories>/gi;
|
||
const OPENVIKING_CONTEXT_BLOCK_RE = /<openviking-context\b[^>]*>[\s\S]*?<\/openviking-context>/gi;
|
||
const CONVERSATION_METADATA_BLOCK_RE =
|
||
/(?:^|\n)\s*(?:Conversation info|Conversation metadata|会话信息|对话信息)\s*(?:\([^)]+\))?\s*:\s*```[\s\S]*?```/gi;
|
||
/** Strips "Sender (untrusted metadata): ```json ... ```" so capture sends clean text to OpenViking extract. */
|
||
const SENDER_METADATA_BLOCK_RE = /Sender\s*\([^)]*\)\s*:\s*```[\s\S]*?```/gi;
|
||
const FENCED_JSON_BLOCK_RE = /```json\s*([\s\S]*?)```/gi;
|
||
const METADATA_JSON_KEY_RE =
|
||
/"(session|sessionid|sessionkey|conversationid|channel|sender|userid|agentid|timestamp|timezone)"\s*:/gi;
|
||
const LEADING_TIMESTAMP_PREFIX_RE = /^\s*(?!\[\[)\[(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[a-z]*\s+)?(?:\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{2,4})(?:[T\s]\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{1,2}(?::\d{2})?)?(?:\s*[A-Z]{1,5}(?:[+-]\d{1,2})?)?)?\s*\]\s*/i;
|
||
const COMPACTED_SYSTEM_MSG_RE = /^System:\s*\[.*?\]\s*Compacted\s*(.+)$/i;
|
||
const COMMAND_TEXT_RE = /^\/[a-z0-9_-]{1,64}\b/i;
|
||
const NON_CONTENT_TEXT_RE = /^[\p{P}\p{S}\s]+$/u;
|
||
const SUBAGENT_CONTEXT_RE = /^\s*\[Subagent Context\]/i;
|
||
const MEMORY_INTENT_RE = /记住|记下|remember|save|store|偏好|preference|规则|rule|事实|fact/i;
|
||
const QUESTION_CUE_RE =
|
||
/[??]|\b(?:what|when|where|who|why|how|which|can|could|would|did|does|is|are)\b|^(?:请问|能否|可否|怎么|如何|什么时候|谁|什么|哪|是否)/i;
|
||
function resolveCaptureMinLength(text: string): number {
|
||
return CJK_CHAR_REGEX.test(text) ? 4 : 10;
|
||
}
|
||
|
||
function looksLikeMetadataJsonBlock(content: string): boolean {
|
||
const matchedKeys = new Set<string>();
|
||
const matches = content.matchAll(METADATA_JSON_KEY_RE);
|
||
for (const match of matches) {
|
||
const key = (match[1] ?? "").toLowerCase();
|
||
if (key) {
|
||
matchedKeys.add(key);
|
||
}
|
||
}
|
||
return matchedKeys.size >= 3;
|
||
}
|
||
|
||
const TOOL_PLACEHOLDER_RE = /^\s*\[tool(?::\s*|Use:\s*)[^\]]+\]\s*$/i;
|
||
|
||
export function sanitizeUserTextForCapture(text: string): string {
|
||
// Drop legacy synthetic tool placeholders before they reach memory extraction.
|
||
if (TOOL_PLACEHOLDER_RE.test(text)) {
|
||
return "";
|
||
}
|
||
// 处理 Compactor 系统消息,提取实际用户输入
|
||
// 格式: "System: [时间] Compacted ... Context ... [时间] 实际内容"
|
||
if (COMPACTED_SYSTEM_MSG_RE.test(text)) {
|
||
const match = text.match(COMPACTED_SYSTEM_MSG_RE);
|
||
if (match) {
|
||
return match[1].replace(/\s+/g, " ").trim();
|
||
}
|
||
return "";
|
||
}
|
||
return text
|
||
.replace(OPENVIKING_CONTEXT_BLOCK_RE, " ")
|
||
.replace(RELEVANT_MEMORIES_BLOCK_RE, " ")
|
||
.replace(CONVERSATION_METADATA_BLOCK_RE, " ")
|
||
.replace(SENDER_METADATA_BLOCK_RE, " ")
|
||
.replace(FENCED_JSON_BLOCK_RE, (full, inner) =>
|
||
looksLikeMetadataJsonBlock(String(inner ?? "")) ? " " : full,
|
||
)
|
||
.replace(LEADING_TIMESTAMP_PREFIX_RE, "")
|
||
.replace(SUBAGENT_CONTEXT_RE, "")
|
||
.replace(/\u0000/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
export function stripOpenVikingContextInjection(text: string): string {
|
||
return text
|
||
.replace(OPENVIKING_CONTEXT_BLOCK_RE, " ")
|
||
.replace(RELEVANT_MEMORIES_BLOCK_RE, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
export function looksLikeQuestionOnlyText(text: string): boolean {
|
||
if (!QUESTION_CUE_RE.test(text) || MEMORY_INTENT_RE.test(text)) {
|
||
return false;
|
||
}
|
||
// Multi-speaker transcripts often contain many "?" but should still be captured.
|
||
const speakerTags = text.match(/[A-Za-z\u4e00-\u9fa5]{2,20}:\s/g) ?? [];
|
||
if (speakerTags.length >= 2 || text.length > 280) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export function compileSessionPattern(pattern: string): RegExp {
|
||
const escaped = pattern
|
||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||
.replace(/\*\*/g, "\u0000")
|
||
.replace(/\*/g, "[^:]*")
|
||
.replace(/\u0000/g, ".*");
|
||
return new RegExp(`^${escaped}$`);
|
||
}
|
||
|
||
export function compileSessionPatterns(patterns: string[]): RegExp[] {
|
||
return patterns.map((pattern) => compileSessionPattern(pattern));
|
||
}
|
||
|
||
export function matchesSessionPattern(sessionRef: string, patterns: RegExp[]): boolean {
|
||
return patterns.some((pattern) => pattern.test(sessionRef));
|
||
}
|
||
|
||
export function resolveSessionPatternCandidate(params: {
|
||
sessionId?: string;
|
||
sessionKey?: string;
|
||
}): string | undefined {
|
||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||
if (sessionKey) {
|
||
return sessionKey;
|
||
}
|
||
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
|
||
return sessionId || undefined;
|
||
}
|
||
|
||
export function shouldBypassSession(
|
||
params: {
|
||
sessionId?: string;
|
||
sessionKey?: string;
|
||
},
|
||
patterns: RegExp[],
|
||
): boolean {
|
||
if (patterns.length === 0) {
|
||
return false;
|
||
}
|
||
const candidate = resolveSessionPatternCandidate(params);
|
||
if (!candidate) {
|
||
return false;
|
||
}
|
||
return matchesSessionPattern(candidate, patterns);
|
||
}
|
||
|
||
function normalizeDedupeText(text: string): string {
|
||
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function normalizeCaptureDedupeText(text: string): string {
|
||
return normalizeDedupeText(text).replace(/[\p{P}\p{S}]+/gu, " ").replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
export function pickRecentUniqueTexts(texts: string[], limit: number): string[] {
|
||
if (limit <= 0 || texts.length === 0) {
|
||
return [];
|
||
}
|
||
const seen = new Set<string>();
|
||
const picked: string[] = [];
|
||
for (let i = texts.length - 1; i >= 0; i -= 1) {
|
||
const text = texts[i];
|
||
const key = normalizeCaptureDedupeText(text);
|
||
if (!key || seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
picked.push(text);
|
||
if (picked.length >= limit) {
|
||
break;
|
||
}
|
||
}
|
||
return picked.reverse();
|
||
}
|
||
|
||
export function getCaptureDecision(text: string, mode: CaptureMode, captureMaxLength: number): {
|
||
shouldCapture: boolean;
|
||
reason: string;
|
||
normalizedText: string;
|
||
} {
|
||
const trimmed = text.trim();
|
||
const normalizedText = sanitizeUserTextForCapture(trimmed);
|
||
const hadSanitization = normalizedText !== trimmed;
|
||
if (!normalizedText) {
|
||
return {
|
||
shouldCapture: false,
|
||
reason: /<relevant-memories>/i.test(trimmed) ? "injected_memory_context_only" : "empty_text",
|
||
normalizedText: "",
|
||
};
|
||
}
|
||
|
||
const compactText = normalizedText.replace(/\s+/g, "");
|
||
const minLength = resolveCaptureMinLength(compactText);
|
||
if (compactText.length < minLength || normalizedText.length > captureMaxLength) {
|
||
return {
|
||
shouldCapture: false,
|
||
reason: "length_out_of_range",
|
||
normalizedText,
|
||
};
|
||
}
|
||
|
||
if (COMMAND_TEXT_RE.test(normalizedText)) {
|
||
return {
|
||
shouldCapture: false,
|
||
reason: "command_text",
|
||
normalizedText,
|
||
};
|
||
}
|
||
|
||
if (NON_CONTENT_TEXT_RE.test(normalizedText)) {
|
||
return {
|
||
shouldCapture: false,
|
||
reason: "non_content_text",
|
||
normalizedText,
|
||
};
|
||
}
|
||
if (SUBAGENT_CONTEXT_RE.test(normalizedText)) {
|
||
return {
|
||
shouldCapture: false,
|
||
reason: "subagent_context",
|
||
normalizedText,
|
||
};
|
||
}
|
||
if (looksLikeQuestionOnlyText(normalizedText)) {
|
||
return {
|
||
shouldCapture: false,
|
||
reason: "question_text",
|
||
normalizedText,
|
||
};
|
||
}
|
||
|
||
if (mode === "keyword") {
|
||
for (const trigger of MEMORY_TRIGGERS) {
|
||
if (trigger.test(normalizedText)) {
|
||
return {
|
||
shouldCapture: true,
|
||
reason: hadSanitization
|
||
? `matched_trigger_after_sanitize:${trigger.toString()}`
|
||
: `matched_trigger:${trigger.toString()}`,
|
||
normalizedText,
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
shouldCapture: false,
|
||
reason: hadSanitization ? "no_trigger_matched_after_sanitize" : "no_trigger_matched",
|
||
normalizedText,
|
||
};
|
||
}
|
||
|
||
return {
|
||
shouldCapture: true,
|
||
reason: hadSanitization ? "semantic_candidate_after_sanitize" : "semantic_candidate",
|
||
normalizedText,
|
||
};
|
||
}
|
||
|
||
export function extractTextsFromUserMessages(messages: unknown[]): string[] {
|
||
const texts: string[] = [];
|
||
for (const msg of messages) {
|
||
if (!msg || typeof msg !== "object") {
|
||
continue;
|
||
}
|
||
const msgObj = msg as Record<string, unknown>;
|
||
if (msgObj.role !== "user") {
|
||
continue;
|
||
}
|
||
const content = msgObj.content;
|
||
if (typeof content === "string") {
|
||
texts.push(content);
|
||
continue;
|
||
}
|
||
if (Array.isArray(content)) {
|
||
for (const block of content) {
|
||
if (!block || typeof block !== "object") {
|
||
continue;
|
||
}
|
||
const blockObj = block as Record<string, unknown>;
|
||
if (blockObj.type === "text" && typeof blockObj.text === "string") {
|
||
texts.push(blockObj.text);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return texts;
|
||
}
|
||
|
||
function formatToolResultContent(content: unknown): string {
|
||
if (typeof content === "string") return content.trim();
|
||
if (Array.isArray(content)) {
|
||
const parts: string[] = [];
|
||
for (const block of content) {
|
||
const b = block as Record<string, unknown>;
|
||
if (b?.type === "text" && typeof b.text === "string") {
|
||
parts.push((b.text as string).trim());
|
||
}
|
||
}
|
||
return parts.join("\n");
|
||
}
|
||
if (content !== undefined && content !== null) {
|
||
try {
|
||
return JSON.stringify(content);
|
||
} catch {
|
||
return String(content);
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* 提取消息中的一个 part 的文本内容,并清理时间戳等噪音
|
||
*/
|
||
function extractPartText(content: unknown): string {
|
||
if (typeof content === "string") {
|
||
return content.trim();
|
||
}
|
||
if (Array.isArray(content)) {
|
||
const parts: string[] = [];
|
||
for (const block of content) {
|
||
const b = block as Record<string, unknown>;
|
||
if (b?.type === "text" && typeof b.text === "string") {
|
||
parts.push((b.text as string).trim());
|
||
}
|
||
}
|
||
return parts.join(" ");
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* 结构化消息类型 - 用于 afterTurn 发送到 OpenViking
|
||
*/
|
||
type ExtractedMessage = {
|
||
role: "user" | "assistant";
|
||
parts: Array<{
|
||
type: "text";
|
||
text: string;
|
||
} | {
|
||
type: "tool";
|
||
toolCallId?: string;
|
||
toolName: string;
|
||
toolInput?: Record<string, unknown>;
|
||
toolOutput: string;
|
||
toolStatus: string;
|
||
}>;
|
||
};
|
||
|
||
/**
|
||
* 提取从 startIndex 开始的新消息,返回结构化消息。
|
||
* - 用户输入 → type: "text"
|
||
* - 工具结果 → type: "tool"
|
||
* - 跳过 system 消息
|
||
* - 清理时间戳前缀(如 [Fri 2026-04-10 17:20 GMT+8])
|
||
*/
|
||
export function extractNewTurnMessages(
|
||
messages: unknown[],
|
||
startIndex: number,
|
||
): { messages: ExtractedMessage[]; newCount: number } {
|
||
const result: ExtractedMessage[] = [];
|
||
let count = 0;
|
||
|
||
// First pass: collect toolUse inputs indexed by toolCallId/toolUseId
|
||
// Scan all messages (including after startIndex) to find toolUse before each toolResult
|
||
const toolUseInputs: Record<string, Record<string, unknown>> = {};
|
||
for (let i = 0; i < messages.length; i++) {
|
||
const msg = messages[i] as Record<string, unknown>;
|
||
if (!msg || typeof msg !== "object") continue;
|
||
const role = msg.role as string;
|
||
if (role === "assistant") {
|
||
const content = msg.content;
|
||
if (Array.isArray(content)) {
|
||
for (const block of content) {
|
||
const b = block as Record<string, unknown>;
|
||
// Handle toolCall, toolUse, tool_call types
|
||
if (b?.type === "toolCall" || b?.type === "toolUse" || b?.type === "tool_call") {
|
||
const id = (b.id as string) || (b.toolUseId as string) || (b.toolCallId as string);
|
||
// Try multiple field names for tool input: arguments, input, toolInput
|
||
const input = b.arguments ?? b.input ?? b.toolInput;
|
||
if (id && input && typeof input === "object") {
|
||
toolUseInputs[id] = input as Record<string, unknown>;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let i = startIndex; i < messages.length; i++) {
|
||
const msg = messages[i] as Record<string, unknown>;
|
||
if (!msg || typeof msg !== "object") continue;
|
||
|
||
const role = msg.role as string;
|
||
if (!role || role === "system") continue;
|
||
|
||
count++;
|
||
|
||
// toolResult -> type: "tool"
|
||
if (role === "toolResult") {
|
||
const toolName = typeof msg.toolName === "string" ? msg.toolName : "tool";
|
||
const output = formatToolResultContent(msg.content) || "";
|
||
// Try multiple field names for tool call ID
|
||
const toolCallId = (msg.toolCallId as string) || (msg.toolUseId as string) || (msg.tool_call_id as string);
|
||
const toolInput = toolCallId && toolUseInputs[toolCallId]
|
||
? toolUseInputs[toolCallId]
|
||
: (typeof msg.toolInput === "object" && msg.toolInput !== null
|
||
? msg.toolInput as Record<string, unknown>
|
||
: undefined);
|
||
if (output) {
|
||
result.push({
|
||
role: "assistant",
|
||
parts: [{
|
||
type: "tool",
|
||
toolCallId: toolCallId || undefined,
|
||
toolName,
|
||
toolInput,
|
||
toolOutput: output,
|
||
toolStatus: msg.isError === true ? "error" : "completed",
|
||
}],
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// user/assistant -> type: "text"
|
||
const content = msg.content;
|
||
const text = extractPartText(content);
|
||
|
||
if (text) {
|
||
// 使用 sanitizeUserTextForCapture 清理所有噪音(Sender 元数据、时间戳等)
|
||
const cleanedText = sanitizeUserTextForCapture(text);
|
||
if (cleanedText) {
|
||
// 保持原始 role,assistant 保持 assistant,user 保持 user
|
||
const ovRole: "user" | "assistant" = role === "assistant" ? "assistant" : "user";
|
||
result.push({
|
||
role: ovRole,
|
||
parts: [{
|
||
type: "text",
|
||
text: cleanedText,
|
||
}],
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return { messages: result, newCount: count };
|
||
}
|
||
|
||
export function extractLatestUserText(messages: unknown[] | undefined): string {
|
||
if (!messages || messages.length === 0) {
|
||
return "";
|
||
}
|
||
const texts = extractTextsFromUserMessages(messages);
|
||
for (let i = texts.length - 1; i >= 0; i -= 1) {
|
||
const normalized = sanitizeUserTextForCapture(texts[i] ?? "");
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* Backward-compatible wrapper around extractNewTurnMessages.
|
||
* Returns flat text strings in the legacy `[role]: text` format.
|
||
* @deprecated Use extractNewTurnMessages for structured output.
|
||
*/
|
||
export function extractNewTurnTexts(
|
||
messages: unknown[],
|
||
startIndex: number,
|
||
): { texts: string[]; newCount: number } {
|
||
const { messages: extracted, newCount } = extractNewTurnMessages(messages, startIndex);
|
||
const texts: string[] = [];
|
||
for (const msg of extracted) {
|
||
for (const part of msg.parts) {
|
||
if (part.type === "text") {
|
||
texts.push(`[${msg.role}]: ${part.text}`);
|
||
} else if (part.type === "tool") {
|
||
if (part.toolInput && Object.keys(part.toolInput).length > 0) {
|
||
texts.push(`[toolUse: ${part.toolName}] ${JSON.stringify(part.toolInput)}`);
|
||
}
|
||
texts.push(`[${part.toolName} result]: ${part.toolOutput}`);
|
||
}
|
||
}
|
||
}
|
||
return { texts, newCount };
|
||
}
|