2026-05-18 02:52:13 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
|
|
|
|
|
|
from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus, AgentToolType
|
|
|
|
|
from app.db.base import Base
|
|
|
|
|
from app.services.agent_runs import AgentRunService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_session() -> Session:
|
|
|
|
|
engine = create_engine(
|
|
|
|
|
"sqlite+pysqlite:///:memory:",
|
|
|
|
|
connect_args={"check_same_thread": False},
|
|
|
|
|
poolclass=StaticPool,
|
|
|
|
|
)
|
|
|
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
|
|
|
return session_factory()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_agent_run_service_marks_stale_knowledge_sync_run_failed_on_read() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
service = AgentRunService(db)
|
|
|
|
|
created = service.create_run(
|
|
|
|
|
agent=AgentName.HERMES.value,
|
|
|
|
|
source=AgentRunSource.USER_MESSAGE.value,
|
|
|
|
|
status=AgentRunStatus.RUNNING.value,
|
|
|
|
|
route_json={
|
|
|
|
|
"job_type": "knowledge_index_sync",
|
|
|
|
|
"heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(),
|
|
|
|
|
"requested_document_ids": [],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fetched = service.get_run(created.run_id)
|
|
|
|
|
running_runs = service.list_runs(
|
|
|
|
|
agent=AgentName.HERMES.value,
|
|
|
|
|
status=AgentRunStatus.RUNNING.value,
|
|
|
|
|
limit=100,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert fetched is not None
|
|
|
|
|
assert fetched.status == AgentRunStatus.FAILED.value
|
|
|
|
|
assert fetched.error_message == "Knowledge index heartbeat timed out."
|
|
|
|
|
assert all(item.run_id != created.run_id for item in running_runs)
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
def test_agent_run_service_marks_stale_llm_wiki_run_failed_on_read() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
service = AgentRunService(db)
|
|
|
|
|
created = service.create_run(
|
|
|
|
|
agent=AgentName.HERMES.value,
|
|
|
|
|
source=AgentRunSource.SCHEDULE.value,
|
|
|
|
|
status=AgentRunStatus.RUNNING.value,
|
|
|
|
|
route_json={
|
|
|
|
|
"job_type": "llm_wiki_sync",
|
|
|
|
|
"heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(),
|
|
|
|
|
"requested_document_ids": [],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fetched = service.get_run(created.run_id)
|
|
|
|
|
|
|
|
|
|
assert fetched is not None
|
|
|
|
|
assert fetched.status == AgentRunStatus.FAILED.value
|
|
|
|
|
assert fetched.error_message == "Knowledge index heartbeat timed out."
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 02:52:13 +00:00
|
|
|
def test_agent_run_service_updates_existing_tool_call() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
service = AgentRunService(db)
|
|
|
|
|
run = service.create_run(
|
|
|
|
|
agent=AgentName.HERMES.value,
|
|
|
|
|
source=AgentRunSource.USER_MESSAGE.value,
|
|
|
|
|
status=AgentRunStatus.RUNNING.value,
|
|
|
|
|
route_json={"job_type": "knowledge_index_sync"},
|
|
|
|
|
)
|
|
|
|
|
tool_call = service.record_tool_call(
|
|
|
|
|
run_id=run.run_id,
|
|
|
|
|
tool_type=AgentToolType.LLM.value,
|
|
|
|
|
tool_name="lightrag.index_documents",
|
|
|
|
|
request_json={"document_ids": ["doc-1"]},
|
|
|
|
|
response_json={"phase": "indexing"},
|
|
|
|
|
status="running",
|
|
|
|
|
duration_ms=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
updated = service.update_tool_call(
|
|
|
|
|
tool_call.id,
|
|
|
|
|
response_json={"track_id": "insert_123"},
|
|
|
|
|
status="succeeded",
|
|
|
|
|
duration_ms=1250,
|
|
|
|
|
error_message=None,
|
|
|
|
|
)
|
|
|
|
|
fetched = service.get_run(run.run_id)
|
|
|
|
|
|
|
|
|
|
assert updated.status == "succeeded"
|
|
|
|
|
assert updated.duration_ms == 1250
|
|
|
|
|
assert fetched is not None
|
|
|
|
|
assert len(fetched.tool_calls) == 1
|
|
|
|
|
assert fetched.tool_calls[0].status == "succeeded"
|
|
|
|
|
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
|
2026-05-30 15:46:51 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 17:19:07 +08:00
|
|
|
def test_agent_run_list_uses_lightweight_preview_and_detail_keeps_full_payload() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
service = AgentRunService(db)
|
|
|
|
|
run = service.create_run(
|
|
|
|
|
agent=AgentName.HERMES.value,
|
|
|
|
|
source=AgentRunSource.SCHEDULE.value,
|
|
|
|
|
status=AgentRunStatus.SUCCEEDED.value,
|
|
|
|
|
ontology_json={
|
|
|
|
|
"scenario": "knowledge",
|
|
|
|
|
"intent": "sync",
|
|
|
|
|
"parse_strategy": "rule_fallback",
|
|
|
|
|
"model_invocation_summary": {"tokens": 999},
|
|
|
|
|
},
|
|
|
|
|
route_json={
|
|
|
|
|
"job_type": "knowledge_index_sync",
|
|
|
|
|
"phase": "indexing",
|
|
|
|
|
"progress": {
|
|
|
|
|
"percent": 50,
|
|
|
|
|
"total_documents": 2,
|
|
|
|
|
"completed_documents": 1,
|
|
|
|
|
"documents": [{"id": "doc-1", "text": "x" * 2000}],
|
|
|
|
|
},
|
|
|
|
|
"knowledge_ingest": {"documents": [{"id": "doc-1", "text": "x" * 2000}]},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
service.record_tool_call(
|
|
|
|
|
run_id=run.run_id,
|
|
|
|
|
tool_type=AgentToolType.LLM.value,
|
|
|
|
|
tool_name="lightrag.index_documents",
|
|
|
|
|
request_json={"prompt": "x" * 2000},
|
|
|
|
|
response_json={"documents": [{"id": "doc-1", "text": "x" * 2000}]},
|
|
|
|
|
status="succeeded",
|
|
|
|
|
duration_ms=123,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
listed = next(item for item in service.list_runs(limit=20) if item.run_id == run.run_id)
|
|
|
|
|
detail = service.get_run(run.run_id)
|
|
|
|
|
|
|
|
|
|
assert listed.ontology_json == {
|
|
|
|
|
"scenario": "knowledge",
|
|
|
|
|
"intent": "sync",
|
|
|
|
|
"parse_strategy": "rule_fallback",
|
|
|
|
|
}
|
|
|
|
|
assert listed.route_json["job_type"] == "knowledge_index_sync"
|
|
|
|
|
assert listed.route_json["phase"] == "indexing"
|
|
|
|
|
assert listed.route_json["progress"] == {
|
|
|
|
|
"percent": 50,
|
|
|
|
|
"total_documents": 2,
|
|
|
|
|
"completed_documents": 1,
|
|
|
|
|
}
|
|
|
|
|
assert "knowledge_ingest" not in listed.route_json
|
|
|
|
|
assert len(listed.tool_calls) == 1
|
|
|
|
|
assert listed.tool_calls[0].tool_name == "lightrag.index_documents"
|
|
|
|
|
assert listed.tool_calls[0].request_json == {}
|
|
|
|
|
assert listed.tool_calls[0].response_json == {}
|
|
|
|
|
|
|
|
|
|
assert detail is not None
|
|
|
|
|
assert "knowledge_ingest" in detail.route_json
|
|
|
|
|
assert detail.tool_calls[0].request_json["prompt"]
|
|
|
|
|
assert detail.tool_calls[0].response_json["documents"]
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
def test_agent_run_service_summarizes_model_and_tool_failures() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
service = AgentRunService(db)
|
|
|
|
|
run = service.create_run(
|
|
|
|
|
agent=AgentName.ORCHESTRATOR.value,
|
|
|
|
|
source=AgentRunSource.USER_MESSAGE.value,
|
|
|
|
|
status=AgentRunStatus.SUCCEEDED.value,
|
|
|
|
|
ontology_json={
|
|
|
|
|
"parse_strategy": "rule_fallback",
|
|
|
|
|
"model_invocation_summary": {
|
|
|
|
|
"model_guardrail_reason": "model_conflicts_with_application_stage_signal"
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
service.record_tool_call(
|
|
|
|
|
run_id=run.run_id,
|
|
|
|
|
tool_type=AgentToolType.LLM.value,
|
|
|
|
|
tool_name="semantic_ontology.main",
|
|
|
|
|
request_json={"stage": "semantic_parse"},
|
|
|
|
|
response_json={"model_guardrail_reason": "model_conflicts_with_application_stage_signal"},
|
|
|
|
|
status="failed",
|
|
|
|
|
duration_ms=18,
|
|
|
|
|
error_message="model_conflicts_with_application_stage_signal",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
stats = service.summarize_runs(agent=AgentName.ORCHESTRATOR.value, limit=20)
|
|
|
|
|
|
|
|
|
|
assert stats.total_runs >= 1
|
|
|
|
|
assert stats.tool_call_count >= 1
|
|
|
|
|
assert stats.failed_tool_call_count >= 1
|
|
|
|
|
assert stats.llm_call_count >= 1
|
|
|
|
|
assert stats.failed_llm_call_count >= 1
|
|
|
|
|
assert stats.model_fallback_count >= 1
|
|
|
|
|
assert stats.model_guardrail_count >= 1
|
|
|
|
|
assert any(
|
|
|
|
|
item.get("tool_name") == "semantic_ontology.main"
|
|
|
|
|
for item in stats.recent_errors
|
|
|
|
|
)
|