Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit 91cfdaee72
200 changed files with 25589 additions and 0 deletions

551
src/context/engine.py Normal file
View File

@@ -0,0 +1,551 @@
"""Context Engine — the central intelligence of the system.
Builds structured prompts from session state. Never includes raw tool
outputs. Handles compaction, artifact summarization, and selective
rehydration.
"""
from __future__ import annotations
import logging
import time
from collections import defaultdict
from typing import Any
from ..config import settings
logger = logging.getLogger(__name__)
from ..models.agent import AgentProfile
from ..models.artifacts import ArtifactSummary
from ..memory.store import MemoryStore
from ..models.context import (
ContextPackage,
ContextSection,
ContextSectionType,
MemoryDocument,
MemoryType,
)
from ..models.session import SessionState, TaskState
from .compactor import ContextCompactor, estimate_tokens
class ContextEngine:
"""Assembles the context package that gets sent to the model.
The engine enforces a strict contract:
- Raw tool outputs NEVER appear in the context
- Each section has a priority for compaction
- Immutable rules are always included in full
"""
def __init__(
self,
compactor: ContextCompactor | None = None,
memory_store: MemoryStore | None = None,
) -> None:
self.compactor = compactor or ContextCompactor(
max_tokens=settings.context_max_tokens
)
self.memory = memory_store
# Debug history: last N context builds per session
self._history: dict[str, list[dict[str, Any]]] = defaultdict(list)
self._max_history = 20
# ------------------------------------------------------------------
# Public — build context for a model call
# ------------------------------------------------------------------
async def build_context(
self,
session: SessionState,
agent: AgentProfile,
artifacts: list[ArtifactSummary] | None = None,
working_items: list[dict[str, Any]] | None = None,
extra_instructions: str = "",
) -> ContextPackage:
"""Build a full ContextPackage for the given agent and session."""
sections: list[ContextSection] = []
allowed = set(agent.context_sections)
# 1. Immutable rules — highest priority, never trimmed
if "immutable_rules" in allowed:
sections.append(self._build_immutable_rules(session, agent))
# 2. Project profile
if "project_profile" in allowed:
sections.append(self._build_project_profile(session))
# 3. Knowledge base — loaded from memory store
if "knowledge_base" in allowed and self.memory:
kb_section = await self._build_knowledge_base(session)
if kb_section:
sections.append(kb_section)
# 4. Task state
if "task_state" in allowed and session.current_task:
sections.append(self._build_task_state(session.current_task))
# 5. Artifact memory — summarised, never raw
if "artifact_memory" in allowed and artifacts:
sections.append(self._build_artifact_memory(artifacts))
# 6. Working context — recent relevant items
if "working_context" in allowed:
sections.append(
self._build_working_context(working_items or [], extra_instructions)
)
# Compact to fit budget
sections = self.compactor.compact_sections(sections)
# Assemble system prompt from sections
system_prompt = self._assemble_system_prompt(sections)
# Build messages (just user message — no chat history)
messages = self._build_messages(session)
total_tokens = estimate_tokens(system_prompt) + sum(
estimate_tokens(m.get("content", "")) for m in messages
)
package = ContextPackage(
sections=sections,
system_prompt=system_prompt,
messages=messages,
total_token_estimate=total_tokens,
)
# --- Debug: log and store context build ---
section_summary = []
for s in sections:
section_summary.append({
"type": s.section_type.value,
"priority": s.priority,
"tokens": s.token_estimate,
"chars": len(s.content),
"preview": s.content[:150].replace("\n", " "),
})
debug_entry = {
"timestamp": time.time(),
"agent": agent.role.value,
"agent_name": agent.name,
"total_tokens": total_tokens,
"sections": section_summary,
"sections_count": len(sections),
"compacted": len(sections) < len(allowed),
"system_prompt_tokens": estimate_tokens(system_prompt),
"user_message_preview": messages[0]["content"][:200] if messages else "",
"artifacts_count": len(artifacts) if artifacts else 0,
"working_items_count": len(working_items) if working_items else 0,
}
history = self._history[session.session_id]
history.append(debug_entry)
if len(history) > self._max_history:
self._history[session.session_id] = history[-self._max_history:]
logger.info(
"Context built for [%s/%s] — %d sections, ~%d tokens, artifacts=%d, working_items=%d",
session.session_id[:8],
agent.role.value,
len(sections),
total_tokens,
len(artifacts) if artifacts else 0,
len(working_items) if working_items else 0,
)
for s in section_summary:
logger.debug(
" Section [%s] prio=%d tokens=%d chars=%d",
s["type"], s["priority"], s["tokens"], s["chars"],
)
return package
def get_debug_history(self, session_id: str) -> list[dict[str, Any]]:
"""Return the context build history for a session."""
return list(self._history.get(session_id, []))
def get_last_context_debug(self, session_id: str) -> dict[str, Any] | None:
"""Return the most recent context build for a session."""
history = self._history.get(session_id, [])
return history[-1] if history else None
def rehydrate_artifact(
self,
artifact: ArtifactSummary,
full_content: str,
) -> ContextSection:
"""Selectively rehydrate an artifact into a full context section.
Used when the agent explicitly needs the full content of a
specific artifact (e.g. reviewing generated code).
"""
content = (
f"## Rehydrated Artifact: {artifact.title}\n"
f"Type: {artifact.artifact_type} | Source: {artifact.source_tool}\n"
f"---\n{full_content}\n---"
)
return ContextSection(
section_type=ContextSectionType.WORKING_CONTEXT,
content=content,
priority=5,
token_estimate=estimate_tokens(content),
)
def summarize_tool_output(
self,
tool_name: str,
raw_output: str,
session_id: str,
task_id: str,
) -> ArtifactSummary:
"""Delegate to compactor — raw output never enters context."""
return self.compactor.summarize_tool_output(
tool_name=tool_name,
raw_output=raw_output,
session_id=session_id,
task_id=task_id,
)
# ------------------------------------------------------------------
# Section builders
# ------------------------------------------------------------------
def _build_immutable_rules(
self, session: SessionState, agent: AgentProfile
) -> ContextSection:
parts = [
"# System Rules (Immutable)",
"",
agent.system_prompt,
"",
]
if session.immutable_rules:
parts.append("## Session Rules")
for rule in session.immutable_rules:
parts.append(f"- {rule}")
parts.extend(
[
"",
"## Contrato de Contexto",
"- NUNCA recibirás salidas crudas de herramientas en tu contexto.",
"- Los resultados de herramientas se resumen como artefactos.",
"- Solicita rehidratación si necesitas el contenido completo.",
"- Mantén las respuestas enfocadas en el paso actual.",
"- Responde SIEMPRE en español.",
]
)
content = "\n".join(parts)
return ContextSection(
section_type=ContextSectionType.IMMUTABLE_RULES,
content=content,
priority=100,
token_estimate=estimate_tokens(content),
)
def _build_project_profile(self, session: SessionState) -> ContextSection:
if not session.project_profile:
content = "# Project Profile\nNo project profile configured."
else:
lines = ["# Project Profile"]
for key, value in session.project_profile.items():
lines.append(f"- **{key}**: {value}")
content = "\n".join(lines)
return ContextSection(
section_type=ContextSectionType.PROJECT_PROFILE,
content=content,
priority=80,
token_estimate=estimate_tokens(content),
)
async def _build_knowledge_base(
self, session: SessionState
) -> ContextSection | None:
"""Load relevant knowledge documents from the memory store.
Uses keyword matching against the task objective and step
description to select only the most relevant docs.
Max budget: ~15k tokens for knowledge.
"""
if not self.memory:
return None
# Build search terms from current context
search_terms = self._extract_search_terms(session)
if not search_terms:
# No task → load summaries of all docs (lightweight)
return await self._build_knowledge_summaries_only()
all_docs: list[MemoryDocument] = []
all_docs.extend(await self.memory.list_documents(
namespace="knowledge",
memory_type=MemoryType.DOCUMENT,
))
all_docs.extend(await self.memory.list_documents(
namespace=f"knowledge:{session.session_id}",
memory_type=MemoryType.DOCUMENT,
))
if not all_docs:
return None
# Score each doc by relevance
scored = self._score_docs(all_docs, search_terms)
# Select top docs within token budget
max_kb_tokens = 15_000
selected: list[tuple[MemoryDocument, int]] = []
token_budget = max_kb_tokens
for doc, score in scored:
if score == 0:
continue
doc_tokens = estimate_tokens(doc.content)
if doc_tokens > token_budget:
# Include summary instead of full content
summary_tokens = estimate_tokens(doc.summary or doc.title)
if summary_tokens < token_budget:
selected.append((doc, -1)) # -1 = summary only
token_budget -= summary_tokens
continue
selected.append((doc, score))
token_budget -= doc_tokens
if not selected:
return await self._build_knowledge_summaries_only()
# Build section
full_docs = [(d, s) for d, s in selected if s > 0]
summary_docs = [(d, s) for d, s in selected if s == -1]
lines = [
"# Knowledge Base",
f"_{len(full_docs)} relevant doc(s) loaded, "
f"{len(summary_docs)} summarized, "
f"{len(all_docs) - len(selected)} filtered out_",
"",
]
for doc, _ in full_docs:
lines.append(f"## {doc.title}")
lines.append(doc.content)
lines.append("")
if summary_docs:
lines.append("## Other Available Docs (summaries)")
for doc, _ in summary_docs:
lines.append(f"- **{doc.title}**: {doc.summary[:200]}")
lines.append("")
content = "\n".join(lines)
return ContextSection(
section_type=ContextSectionType.KNOWLEDGE_BASE,
content=content,
priority=60,
token_estimate=estimate_tokens(content),
)
async def _build_knowledge_summaries_only(self) -> ContextSection | None:
"""Lightweight: only doc titles and summaries (no full content)."""
if not self.memory:
return None
docs = await self.memory.list_documents(
namespace="knowledge", memory_type=MemoryType.DOCUMENT
)
if not docs:
return None
lines = ["# Knowledge Base (summaries)", ""]
for doc in docs:
lines.append(f"- **{doc.title}**: {doc.summary[:150]}")
content = "\n".join(lines)
return ContextSection(
section_type=ContextSectionType.KNOWLEDGE_BASE,
content=content,
priority=60,
token_estimate=estimate_tokens(content),
)
def _extract_search_terms(self, session: SessionState) -> set[str]:
"""Extract keywords from the current task for doc matching."""
terms: set[str] = set()
if not session.current_task:
return terms
text = session.current_task.objective.lower()
step = session.current_task.current_step()
if step:
text += " " + step.description.lower()
# Split into words, filter short/common ones
stopwords = {
"de", "la", "el", "en", "un", "una", "los", "las", "del", "al",
"por", "para", "con", "que", "como", "cómo", "qué", "es", "son",
"se", "su", "más", "ya", "si", "no", "este", "esta", "esto",
"the", "a", "an", "is", "are", "and", "or", "to", "in", "of",
"for", "on", "with", "how", "what", "do", "does", "can",
}
for word in text.split():
word = word.strip(".,;:!?¿¡()[]{}\"'`")
if len(word) >= 3 and word not in stopwords:
terms.add(word)
return terms
@staticmethod
def _score_docs(
docs: list[MemoryDocument], terms: set[str]
) -> list[tuple[MemoryDocument, int]]:
"""Score docs by keyword match against title, tags, and content."""
scored: list[tuple[MemoryDocument, int]] = []
for doc in docs:
score = 0
title_lower = doc.title.lower()
tags_lower = " ".join(doc.tags).lower()
content_lower = doc.content[:2000].lower()
for term in terms:
# Title match = high weight
if term in title_lower:
score += 10
# Tag match = medium weight
if term in tags_lower:
score += 5
# Content match = low weight
if term in content_lower:
score += 1
scored.append((doc, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored
def _build_task_state(self, task: TaskState) -> ContextSection:
lines = [
"# Current Task",
f"**Objective**: {task.objective}",
f"**Status**: {task.status}",
f"**Step**: {task.current_step_index + 1}/{len(task.plan)}",
]
current = task.current_step()
if current:
lines.extend(
[
"",
"## Current Step",
f"- Description: {current.description}",
f"- Agent: {current.agent_role}",
f"- Status: {current.status}",
]
)
if task.facts_extracted:
lines.append("")
lines.append("## Established Facts")
for fact in task.facts_extracted[-10:]:
lines.append(f"- {fact}")
if task.constraints:
lines.append("")
lines.append("## Constraints")
for c in task.constraints:
lines.append(f"- {c}")
# Show plan overview (compact)
if task.plan:
lines.append("")
lines.append("## Plan Overview")
for i, step in enumerate(task.plan):
marker = "" if i == task.current_step_index else "·"
status_label = step.status.value
lines.append(
f" {marker} Step {i + 1} [{status_label}]: {step.description}"
)
content = "\n".join(lines)
return ContextSection(
section_type=ContextSectionType.TASK_STATE,
content=content,
priority=70,
token_estimate=estimate_tokens(content),
)
def _build_artifact_memory(
self, artifacts: list[ArtifactSummary]
) -> ContextSection:
content = self.compactor.compact_artifact_summaries(
artifacts, max_chars=settings.artifact_summary_max_chars
)
return ContextSection(
section_type=ContextSectionType.ARTIFACT_MEMORY,
content=content,
priority=50,
token_estimate=estimate_tokens(content),
)
def _build_working_context(
self,
items: list[dict[str, Any]],
extra_instructions: str,
) -> ContextSection:
lines = ["# Working Context"]
if extra_instructions:
lines.append(f"\n{extra_instructions}")
for item in items[: settings.working_context_max_items]:
role = item.get("role", "info")
content_val = item.get("content", "")
lines.append(f"[{role}] {content_val}")
content = "\n".join(lines)
return ContextSection(
section_type=ContextSectionType.WORKING_CONTEXT,
content=content,
priority=30,
token_estimate=estimate_tokens(content),
)
# ------------------------------------------------------------------
# Assembly
# ------------------------------------------------------------------
def _assemble_system_prompt(self, sections: list[ContextSection]) -> str:
"""Combine sections into a single system prompt string."""
parts: list[str] = []
# Order: rules → profile → task → artifacts → working
order = [
ContextSectionType.IMMUTABLE_RULES,
ContextSectionType.PROJECT_PROFILE,
ContextSectionType.KNOWLEDGE_BASE,
ContextSectionType.TASK_STATE,
ContextSectionType.ARTIFACT_MEMORY,
ContextSectionType.WORKING_CONTEXT,
]
section_map: dict[ContextSectionType, ContextSection] = {
s.section_type: s for s in sections
}
for st in order:
if st in section_map:
parts.append(section_map[st].content)
return "\n\n---\n\n".join(parts)
def _build_messages(self, session: SessionState) -> list[dict[str, Any]]:
"""Build the messages array. We do NOT include chat history.
The user message is the current task objective (or a sentinel
if no task is active).
"""
if session.current_task:
step = session.current_task.current_step()
if step:
user_content = (
f"Execute this step: {step.description}\n"
f"Overall objective: {session.current_task.objective}"
)
else:
user_content = session.current_task.objective
else:
user_content = "Awaiting task assignment."
return [{"role": "user", "content": user_content}]