Files
wehub-resource-sync c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:02:24 +08:00

396 lines
14 KiB
Python

"""Turn agent trace evidence into agent-profile session-context lessons.
Flow:
1. LIVE (cheap, no LLM) — runs after each trace is stored; turns an errored step into a
failure_lessons candidate built straight from its error text.
2. BATCH (LLM) — proposes reasoning-heavy sections (tool_rules, success_patterns,
workflow_state, environment_facts, and richer failure_lessons) over bounded pending trace
windows. It runs in two places: periodically from the trace-write path (every
TRACE_EXTRACTION_INTERVAL traces, awaited inline in SessionManager.add_agent_trace_step), and
again at improve/session end to flush any pending tail. Because the periodic pass is awaited
inline, a trace write that crosses the interval pays the latency of one LLM call.
Both feed the shared deterministic applier (apply_candidate_updates), so lessons are
confidence-gated and de-duplicated the same way QA lessons are. Fail-open: extraction never
raises into the trace write path — a failure just means no lesson this time.
"""
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from cognee.infrastructure.llm.LLMGateway import LLMGateway
from cognee.infrastructure.llm.prompts import read_query_prompt
from cognee.infrastructure.session.session_context_builder import (
apply_candidate_updates,
coerce_active_context_entries,
)
from cognee.infrastructure.session.session_context_models import (
MAX_CONTEXT_CONTENT_CHARS,
AgentContextExtraction,
CandidateFailureLessonUpdate,
ContextProfile,
)
from cognee.modules.agent_memory.sanitization import sanitize_value
from cognee.shared.logging_utils import get_logger
logger = get_logger("agent_context_extraction")
# Deterministic candidates have no natural confidence; an errored step is a strong signal,
# so it clears the MIN_CANDIDATE_CONFIDENCE (0.75) gate comfortably.
LIVE_FAILURE_CONFIDENCE = 0.85
# Batch pass: how many recent trace steps to read, and how much of each output to keep.
BATCH_TRACE_LIMIT = 40
BATCH_RETURN_CHARS = 600
BATCH_PROMPT_FILE = "agent_context_extraction_system.txt"
# Bound store growth across repeated improve() runs: cap new lessons per run, and show the
# extractor the lessons that already exist so it does not re-propose or reword them.
MAX_BATCH_LESSONS = 5
EXISTING_LESSONS_SHOWN = 40
MAX_ERROR_TEXT_CHARS = 600
# Mid-session extraction: every N new traces, re-read a small overlap so the LLM sees enough
# sequence context to infer patterns without running on every tool call.
TRACE_EXTRACTION_INTERVAL = 10
TRACE_EXTRACTION_OVERLAP = 3
TRACE_EXTRACTION_STATE_ID = "__agent_context_extraction_state__"
TRACE_EXTRACTION_STATE_KIND = "agent_context_extraction_state"
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
_BEARER_RE = re.compile(r"(?i)\b(Bearer)\s+[A-Za-z0-9._~+/=-]+")
_SECRET_ASSIGNMENT_RE = re.compile(
r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|secret)"
r"\s*([=:])\s*([^\s,;]+)"
)
_LONG_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
_UUID_RE = re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
_LONG_NUMBER_RE = re.compile(r"\b\d{8,}\b")
@dataclass(frozen=True, slots=True)
class TraceExtractionPlan:
"""A bounded trace window to extract and the watermark to save afterward."""
total_trace_count: int
window_size: int
def _sanitize_error_text(error_message: str, max_chars: int = MAX_ERROR_TEXT_CHARS) -> str:
"""Redact sensitive and volatile values before errors become reusable context."""
error = " ".join(str(error_message or "").split())
error = _BEARER_RE.sub(r"\1 [redacted]", error)
error = _SECRET_ASSIGNMENT_RE.sub(r"\1\2[redacted]", error)
error = _JWT_RE.sub("[redacted]", error)
error = _LONG_HEX_RE.sub("[redacted]", error)
error = _UUID_RE.sub("[uuid]", error)
error = _LONG_NUMBER_RE.sub("[number]", error)
return error[:max_chars]
def _failure_lesson_content(origin_function: str, error_message: str) -> str:
"""Build a compact failure lesson from an errored trace step."""
origin = origin_function.strip() or "a tool"
error = _sanitize_error_text(error_message, max_chars=MAX_CONTEXT_CONTENT_CHARS)
return f"{origin} failed: {error}"[:MAX_CONTEXT_CONTENT_CHARS]
def build_live_agent_candidates(
*, origin_function: str, status: str, error_message: str
) -> list[CandidateFailureLessonUpdate]:
"""Deterministic, no-LLM candidates from one trace step.
Scoped to the single section whose content already sits on the trace: an errored step with
an error message becomes a failure_lessons candidate. Everything else is the batch pass's job.
"""
if status != "error" or not error_message.strip():
return []
content = _failure_lesson_content(origin_function, error_message)
return [CandidateFailureLessonUpdate(content=content, confidence=LIVE_FAILURE_CONFIDENCE)]
async def extract_live_agent_context(
*,
session_manager,
user_id: str,
session_id: str,
trace_id: str,
origin_function: str,
status: str,
error_message: str,
) -> list[str]:
"""Store agent lessons derivable from one just-saved trace step. Fail-open -> []."""
try:
candidates = build_live_agent_candidates(
origin_function=origin_function, status=status, error_message=error_message
)
if not candidates:
return []
return await apply_candidate_updates(
session_manager=session_manager,
user_id=user_id,
session_id=session_id,
source_id=trace_id,
candidates=candidates,
)
except Exception as error:
logger.warning("Live agent-context extraction failed open: %s", error)
return []
def _trace_window_size(pending_trace_count: int, overlap: int, max_window: int) -> int:
"""Return the bounded trace window size for pending-trace extraction."""
return max(1, min(max_window, pending_trace_count + overlap))
def _extract_state_row(raw_entries: list) -> dict | None:
"""Find this session's internal agent-extraction watermark row, if present."""
for raw in raw_entries or []:
if not isinstance(raw, dict):
continue
if raw.get("id") == TRACE_EXTRACTION_STATE_ID:
return raw
if raw.get("kind") == TRACE_EXTRACTION_STATE_KIND:
return raw
return None
async def _get_processed_trace_count(session_manager, user_id: str, session_id: str) -> int:
"""Read the count watermark. Missing or malformed state means no traces processed yet."""
raw_entries = await session_manager.get_session_context_entries(
user_id=user_id, session_id=session_id
)
row = _extract_state_row(raw_entries)
if row is None:
return 0
try:
return max(0, int(row.get("processed_trace_count") or 0))
except (TypeError, ValueError):
return 0
async def _save_processed_trace_count(
session_manager, user_id: str, session_id: str, processed_trace_count: int
) -> None:
"""Persist the count watermark as an internal non-rendered session-context row."""
payload = {
"id": TRACE_EXTRACTION_STATE_ID,
"kind": TRACE_EXTRACTION_STATE_KIND,
"processed_trace_count": max(0, int(processed_trace_count)),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
updated = await session_manager.update_session_context_entry(
user_id=user_id,
session_id=session_id,
entry_id=TRACE_EXTRACTION_STATE_ID,
merge=payload,
)
if not updated:
await session_manager.create_session_context_entry(
user_id=user_id,
session_id=session_id,
entry_dump=payload,
)
async def _plan_pending_extraction(
*,
session_manager,
user_id: str,
session_id: str,
min_new_traces: int,
overlap: int,
max_window: int,
) -> TraceExtractionPlan | None:
"""Load trace-count state and decide whether a new extraction window is due."""
total_trace_count = await session_manager.get_agent_trace_count(
user_id=user_id, session_id=session_id
)
processed_count = await _get_processed_trace_count(session_manager, user_id, session_id)
pending_count = max(0, total_trace_count - processed_count)
if pending_count < min_new_traces:
return None
return TraceExtractionPlan(
total_trace_count=total_trace_count,
window_size=_trace_window_size(pending_count, overlap, max_window),
)
def _trace_line(entry) -> str:
"""Render one trace step as a compact line for the batch prompt."""
parts = [f"- {entry.origin_function} [{entry.status}]"]
if entry.session_feedback:
parts.append(f"feedback: {entry.session_feedback}")
if entry.error_message:
parts.append(f"error: {_sanitize_error_text(entry.error_message)}")
sanitized_return = sanitize_value(entry.method_return_value)
if sanitized_return not in (None, "", {}, []):
serialized = json.dumps(sanitized_return, ensure_ascii=False)[:BATCH_RETURN_CHARS]
parts.append(f"output: {serialized}")
return " | ".join(parts)
def build_trace_batch(traces) -> str:
"""Join trace steps into one prompt blob; empty when there is nothing to summarize."""
lines = [_trace_line(entry) for entry in traces]
return "\n".join(line for line in lines if line.strip())
async def _existing_agent_lessons(session_manager, user_id: str, session_id: str) -> list:
"""Load this session's current agent-profile lessons (capped for the prompt)."""
raw_entries = await session_manager.get_session_context_entries(
user_id=user_id, session_id=session_id
)
agent_entries = [
entry
for entry in coerce_active_context_entries(raw_entries)
if entry.context_profile == ContextProfile.AGENT.value
]
return agent_entries[:EXISTING_LESSONS_SHOWN]
def build_extraction_input(trace_batch: str, existing_lessons: list) -> str:
"""Combine already-stored lessons and new traces into one prompt input.
Showing existing lessons lets the model avoid re-proposing or rewording them, which is what
keeps the store from growing across repeated improve() runs.
"""
sections = []
if existing_lessons:
rendered = "\n".join(f"- [{entry.section}] {entry.content}" for entry in existing_lessons)
sections.append(
"EXISTING LESSONS (already saved — do not repeat or reword these):\n" + rendered
)
sections.append("TRACES:\n" + trace_batch)
return "\n\n".join(sections)
async def _extract_batch_from_traces(
*,
session_manager,
user_id: str,
session_id: str,
traces: list,
) -> list[str]:
"""LLM batch pass over an already-selected trace window. Raises on infrastructure errors."""
if not traces:
return []
batch = build_trace_batch(traces)
if not batch.strip():
return []
system_prompt = read_query_prompt(BATCH_PROMPT_FILE)
if not system_prompt:
raise RuntimeError("Batch agent-context extraction: system prompt not found")
existing = await _existing_agent_lessons(session_manager, user_id, session_id)
result = await LLMGateway.acreate_structured_output(
text_input=build_extraction_input(batch, existing),
system_prompt=system_prompt,
response_model=AgentContextExtraction,
)
candidates = list(result.lessons)[:MAX_BATCH_LESSONS]
if not candidates:
return []
# No single source trace for a batch lesson; link none and rely on content dedup.
return await apply_candidate_updates(
session_manager=session_manager,
user_id=user_id,
session_id=session_id,
source_id="",
candidates=candidates,
)
async def extract_batch_agent_context(
*,
session_manager,
user_id: str,
session_id: str,
last_n: int = BATCH_TRACE_LIMIT,
) -> list[str]:
"""LLM batch pass: read recent traces, propose new agent lessons, and store them.
The prompt is shown the session's existing agent lessons so the model only proposes
genuinely new ones, and the output is capped at ``MAX_BATCH_LESSONS`` — together these bound
store growth across repeated improve() runs. Exact-content dedup in the applier is the final
backstop. Fail-open -> [].
"""
try:
traces = await session_manager.get_agent_trace_session(
user_id=user_id, session_id=session_id, last_n=last_n
)
return await _extract_batch_from_traces(
session_manager=session_manager,
user_id=user_id,
session_id=session_id,
traces=traces,
)
except Exception as error:
logger.warning("Batch agent-context extraction failed open: %s", error)
return []
async def extract_pending_agent_context(
*,
session_manager,
user_id: str,
session_id: str,
min_new_traces: int = TRACE_EXTRACTION_INTERVAL,
overlap: int = TRACE_EXTRACTION_OVERLAP,
max_window: int = BATCH_TRACE_LIMIT,
) -> list[str]:
"""Extract agent lessons from unprocessed traces using one shared watermark policy.
``min_new_traces`` controls the trigger:
- trace-write path uses the interval default, so extraction happens periodically;
- improve/session-end callers can pass ``1`` to flush any pending traces before distillation.
The LLM receives only the latest ``pending + overlap`` trace steps, capped by ``max_window``.
The watermark advances only after the extraction attempt completes without raising.
Fail-open -> [].
"""
if min_new_traces <= 0:
return []
try:
plan = await _plan_pending_extraction(
session_manager=session_manager,
user_id=user_id,
session_id=session_id,
min_new_traces=min_new_traces,
overlap=overlap,
max_window=max_window,
)
if plan is None:
return []
traces = await session_manager.get_agent_trace_session(
user_id=user_id,
session_id=session_id,
last_n=plan.window_size,
)
touched = await _extract_batch_from_traces(
session_manager=session_manager,
user_id=user_id,
session_id=session_id,
traces=traces,
)
await _save_processed_trace_count(
session_manager,
user_id,
session_id,
processed_trace_count=plan.total_trace_count,
)
return touched
except Exception as error:
logger.warning("Pending agent-context extraction failed open: %s", error)
return []