This commit is contained in:
Jordan Diaz
2026-04-09 20:46:03 +00:00
parent 4c73d848bb
commit 237dc00379
10 changed files with 1049 additions and 1216 deletions

View File

@@ -34,11 +34,13 @@ if "openai" not in sys.modules:
sys.modules["openai"] = openai_stub
from src.config import Settings, settings
from src.context.compactor import ContextCompactor
from src.context.engine import ContextEngine
from src.models.agent import AgentProfile
from src.models.artifacts import ArtifactSummary
from src.models.session import SessionState
from src.orchestrator.engine import OrchestratorEngine
from src.orchestrator.agents.base import BaseAgent
class TestSettingsBudget:
@@ -134,6 +136,110 @@ class TestContextEngine:
assert "## Artifacts" in package.system_prompt
assert "Resumen del archivo" in package.system_prompt
def test_build_messages_prefers_recent_raw_conversation_over_synthetic_history(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
task_history=[
{
"task_id": "prev1",
"objective": "Revísame la home y dime qué módulo ves más flojo",
"status": "completed",
"summary": "User: home → Agent: el módulo más flojo es Desplegables",
"facts": [],
"key_data": {"sections": ["u30mz"]},
"tools_used": ["get_module_config_vars"],
}
],
recent_messages=[
{"role": "user", "content": "Revísame la home y dime qué módulo ves más flojo"},
{"role": "assistant", "content": "El módulo más flojo es Desplegables."},
],
)
session.begin_task("Céntrate solo en ese módulo y dime qué cambiarías")
messages = ContextEngine()._build_messages(session)
assert messages[0]["content"] == "Revísame la home y dime qué módulo ves más flojo"
assert messages[1]["content"] == "El módulo más flojo es Desplegables."
assert "[HISTORIAL DE CONVERSACIÓN ANTERIOR" not in messages[0]["content"]
assert "Desplegables" in messages[-1]["content"]
def test_build_context_keeps_recent_raw_conversation_across_tasks(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
recent_messages=[
{"role": "user", "content": "Revísame la home y dime qué módulo ves más flojo"},
{"role": "assistant", "content": "El módulo más flojo es Desplegables."},
],
task_history=[
{
"task_id": "prev1",
"objective": "Revísame la home y dime qué módulo ves más flojo",
"status": "completed",
"summary": "User: home → Agent: el módulo más flojo es Desplegables",
"facts": [],
"key_data": {"sections": ["u30mz"]},
"tools_used": ["get_module_config_vars"],
"outcomes": ["El módulo más flojo es Desplegables."],
"focus_refs": [
{
"type": "module",
"label": "Desplegables",
"id": "u30mz",
"role": "primary_focus",
}
],
}
],
)
session.begin_task("Céntrate solo en ese módulo y dime qué cambiarías")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
package = asyncio.run(ContextEngine().build_context(session=session, agent=agent))
assert package.messages[0]["content"] == "Revísame la home y dime qué módulo ves más flojo"
assert package.messages[1]["content"] == "El módulo más flojo es Desplegables."
assert "Resolved Follow-up Context" in package.system_prompt
assert "Desplegables" in package.messages[-1]["content"]
def test_classify_followup_mode_detects_transform_requests(self):
mode = ContextEngine._classify_followup_mode(
"Hazme una segunda versión más comercial, pero sin cambiar el foco."
)
assert mode == "transform"
def test_classify_followup_mode_detects_fetch_requests(self):
mode = ContextEngine._classify_followup_mode(
"Céntrate en ese módulo y revisa la configuración actual."
)
assert mode == "fetch_more"
def test_build_context_sets_transform_followup_mode_in_task_state(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
recent_messages=[
{"role": "user", "content": "Dame una propuesta para ese módulo"},
{"role": "assistant", "content": "La propuesta actual es esta."},
],
)
session.begin_task("Hazme una segunda versión más comercial, pero sin cambiar el foco.")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
package = asyncio.run(ContextEngine().build_context(session=session, agent=agent))
assert "**Follow-up Mode**: transform" in package.system_prompt
assert "No llames herramientas salvo que falte un dato factual critico" in package.system_prompt
class TestTaskHistoryTrim:
def test_trim_respects_entry_limit_and_token_budget(self, monkeypatch):
@@ -158,3 +264,209 @@ class TestTaskHistoryTrim:
assert len(trimmed) <= 3
assert trimmed[-1]["objective"] == "final"
assert all(entry["objective"] != "old" for entry in trimmed)
def test_append_recent_messages_keeps_user_and_raw_turn_messages(self):
merged = OrchestratorEngine._append_recent_messages(
existing=[
{"role": "user", "content": "Pregunta anterior"},
{"role": "assistant", "content": "Respuesta anterior"},
],
message="Nueva pregunta",
conversation=[
{"role": "assistant", "content": "Voy a revisarlo."},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado tool"},
{"role": "assistant", "content": "Respuesta final"},
],
)
assert [m["role"] for m in merged] == [
"user",
"assistant",
"user",
"assistant",
"tool",
"assistant",
]
assert merged[2]["content"] == "Nueva pregunta"
assert merged[4]["tool_call_id"] == "tool-1"
class TestConversationCompaction:
def test_compactor_preserves_last_user_and_compacts_old_tool_results(self):
compactor = ContextCompactor(max_tokens=999999)
messages = [
{"role": "user", "content": "Contexto anterior " * 10},
{"role": "assistant", "content": "Voy a revisar el modulo ahora mismo. " * 6},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
{"role": "assistant", "content": "He visto el resultado anterior. " * 6},
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
{"role": "user", "content": "Este es el ultimo mensaje del usuario y debe quedar intacto."},
]
compacted, meta = compactor.compact_conversation(
messages,
max_tokens=420,
recent_raw_limit=1,
raw_char_limit=120,
)
assert compacted[-1]["content"] == messages[-1]["content"]
assert compacted[2]["content"].startswith("[TOOL RESULT COMPACTADO]")
assert compacted[4]["content"].startswith("resultado reciente")
assert compacted[1]["content"] == messages[1]["content"]
assert meta["messages_compacted"] > 0
assert meta["raw_tool_results_kept"] == 1
assert meta["tool_messages_compacted"] > 0
assert meta["assistant_messages_compacted"] == 0
assert meta["user_messages_compacted"] == 0
def test_engine_reports_conversation_compaction_when_budget_is_small(self, monkeypatch):
monkeypatch.setattr(settings, "context_max_tokens", 1400)
monkeypatch.setattr(settings, "compaction_threshold_tokens", 1)
monkeypatch.setattr(settings, "knowledge_base_max_tokens", 0)
monkeypatch.setattr(settings, "tool_raw_output_max_chars", 120)
monkeypatch.setattr(settings, "conversation_recent_raw_limit", 1)
session = SessionState(immutable_rules=["No romper el proyecto"])
session.begin_task("Revisar modulo")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
conversation = [
{"role": "assistant", "content": "Respuesta intermedia " * 25},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
{"role": "assistant", "content": "Segunda respuesta " * 25},
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
]
engine = ContextEngine()
asyncio.run(
engine.build_context(
session=session,
agent=agent,
conversation=conversation,
)
)
debug = engine.get_last_context_debug(session.session_id)
assert debug is not None
assert debug["conversation_compaction"]["messages_compacted"] > 0
assert debug["message_tokens"] <= debug["message_tokens_before_compaction"]
def test_compactor_only_touches_user_messages_as_last_resort(self):
compactor = ContextCompactor(max_tokens=999999)
messages = [
{"role": "user", "content": "Contexto previo del usuario " * 8},
{"role": "assistant", "content": "Respuesta previa del asistente " * 6},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado viejo\n" * 80},
{"role": "user", "content": "Ultimo mensaje del usuario"},
]
compacted, meta = compactor.compact_conversation(
messages,
max_tokens=420,
recent_raw_limit=0,
raw_char_limit=120,
)
assert compacted[0]["content"] == messages[0]["content"]
assert compacted[1]["content"] == messages[1]["content"]
assert compacted[2]["content"].startswith("[TOOL RESULT COMPACTADO]")
assert compacted[3]["content"] == messages[3]["content"]
assert meta["tool_messages_compacted"] > 0
assert meta["assistant_messages_compacted"] == 0
assert meta["user_messages_compacted"] == 0
class TestStructuredFollowups:
def test_history_entry_extracts_outcomes_and_focus_refs(self):
entry = OrchestratorEngine._build_task_history_entry(
task_id="task-1",
message="Revísame la home y dime qué módulo ves más flojo",
content=(
"## El módulo más flojo\n"
"Si tuviera que elegir uno, diría que **Desplegables** es el más problemático.\n"
"Recomiendo revisarlo primero."
),
agent_id="acai",
facts=[],
key_data={"sections": ["u30mz"]},
tool_executions=[],
artifacts_count=0,
)
assert any("Desplegables" in outcome for outcome in entry["outcomes"])
assert any(ref["label"] == "Desplegables" for ref in entry["focus_refs"])
def test_followup_message_includes_resolved_context(self):
session = SessionState(
immutable_rules=["No romper el proyecto"],
task_history=[
{
"task_id": "prev1",
"objective": "Revísame la home y dime qué módulo ves más flojo",
"status": "completed",
"summary": "User: home → Agent: el módulo más flojo es Desplegables",
"facts": [],
"key_data": {"sections": ["u30mz"]},
"tools_used": ["get_module_config_vars"],
"outcomes": ["Si tuviera que elegir uno, diría que Desplegables es el más problemático."],
"focus_refs": [
{
"type": "module",
"label": "Desplegables",
"id": "u30mz",
"role": "primary_focus",
}
],
}
],
)
session.begin_task("Céntrate solo en ese módulo y dime qué cambiarías")
engine = ContextEngine()
messages = engine._build_messages(session)
assert "[CONTEXTO RESUELTO DEL TURNO ANTERIOR]" in messages[-1]["content"]
assert "Desplegables" in messages[-1]["content"]
class _DummyMCP:
is_running = True
def get_tool_definitions(self):
return [
{"name": "tool_a"},
{"name": "tool_b"},
]
class TestToolGating:
def test_base_agent_disables_tools_for_transform_followups(self):
agent = BaseAgent(
profile=AgentProfile(role="acai", name="Acai", allowed_tools=["tool_a", "tool_b"]),
model_adapter=None,
context_engine=None,
mcp_client=_DummyMCP(),
memory_store=None,
sse_emitter=None,
)
assert agent._get_allowed_tools(followup_mode="transform") == []
def test_base_agent_keeps_tools_for_non_transform_followups(self):
agent = BaseAgent(
profile=AgentProfile(role="acai", name="Acai", allowed_tools=["tool_a"]),
model_adapter=None,
context_engine=None,
mcp_client=_DummyMCP(),
memory_store=None,
sse_emitter=None,
)
tools = agent._get_allowed_tools(followup_mode="fetch_more")
assert tools == [{"name": "tool_a"}]