fix agent stream audit lifecycle

This commit is contained in:
Hamza Ayed
2026-10-04 19:05:17 +03:00
parent ec9d74173f
commit 8266de07c5
3 changed files with 115 additions and 6 deletions
+2
View File
@@ -4,6 +4,8 @@
## الحالة الحالية — 2026-10-04
- 2026-10-04 — متابعة فقد اتصال الوكيل بعد 4–5 دقائق: أُعيد اختبار `/v1/agent/run/stream` بطلب بحث فعلي متعدد الأهداف. على الخدمة الموجودة في 8000 اكتمل طلب خلال 163.6 ثانية مع 12 heartbeat وحدث `done`، ولم ينقطع النقل؛ لكن Gemma أعادت ملخصًا ناقصًا، وهذا قصور جودة منفصل. كشف سجل التدقيق القديم أن مدد البث تُحفظ عند وصول الترويسة قبل انتهاء جسم SSE، فلا تمثل عمر الطلب ولا تكشف قطع العميل. عُدّل middleware لقياس المدة حتى اكتمال البث، وتسجيل `499` داخليًا عند إلغاء/إغلاق العميل و`500` عند استثناء أثناء الإرسال، من دون تسجيل محتوى السؤال أو الإجابة. اختبارات بث الوكيل والإلغاء والمهارات نجحت 31/31. شُغلت نسخة من الشفرة الحالية على 18813 بقاعدة اختبار معزولة؛ طلب Gemma حي استمر 219.6 ثانية، تلقى 12 heartbeat وانتهى بـ`done`، وسجل التدقيق 219,465ms وHTTP 200. بعدها أُعيد تشغيل API على 8000 (PID 41184) مع الاحتفاظ بقاعدة SQLite الحالية، وأعاد `/health` الحالة `ok`. اختبار الوكيل النهائي على 8000 انتهى بـ`done` بعد 23.98 ثانية مع heartbeat؛ سجل التدقيق الجديد طابق المدة 23,975ms وحالة 200. لم يتكرر فقد الاتصال في الاختبارات الحية؛ ما زال اختبار Flutter التفاعلي المطوّل مفتوحًا، وجواب Gemma على المهمة متعددة الأهداف كان ناقصًا وهو خلل جودة منفصل.
- 2026-10-04 — إصلاح إجراء المشروع غير المستجيب على Flutter Web: مراجعة `file_selector_web 0.9.4+2` المثبت محليًا أكدت أن `getDirectoryPath()` يعيد `null` على الويب؛ لذلك لم يكن زر تسجيل مجلد كامل قادرًا على فتح المنتقي. حُوّل الإجراء الرئيسي في الويب إلى اختيار ملفات كود/PDF للتحليل، وأُخفي إجراء تسجيل المجلد من قائمة «مشاريعي» عندما لا يوجد مشروع مسجل، مع رسالة تشرح خيار الملفات والمشاريع المسجلة. أزرار تسجيل المجلد بقيت لسطح المكتب، وقائمة المشاريع المسجلة سابقًا تظل قابلة للاختيار. اختبار Flutter الكامل 25/25، `flutter analyze --no-pub` بلا ملاحظات، وبناء Web نجح خلال 171.3 ثانية. لم يتم بعد اختبار إرسال ملف فعلي من نافذة المتصفح ولا التحقق المرئي من Windows؛ لا تُعد هذه الخطوة تسجيل مشروع كاملًا في الويب.
- 2026-10-04 — اختبار عقد بث الوكيل في عميل Flutter: أُضيف خادم HTTP محلي للاختبار يرسل أحداث تقدم و`heartbeat` ثم `done`، ويتحقق من عرض رسالة النبضة والجواب النهائي وتمرير Bearer ومساحة المشروع والملفات المختارة والمهارة في الطلب. اختبار الملف المستهدف نجح 3/3، ومجموعة Flutter الكاملة نجحت 24/24، و`flutter analyze --no-pub` بلا ملاحظات. هذا يثبت فك أحداث SSE في عميل Dart ولا يغني عن تجربة النافذة يدويًا أو عن طلب حي طويل؛ مشكلة عدم ظهور نافذة نسخة LocalAppData لواجهة التحكم موثقة في بند التحقق المرئي أدناه.
+39 -6
View File
@@ -186,12 +186,8 @@ async def audit_agent_routes(request: Request, call_next: Any):
event_id = str(uuid4())
started = perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Agent-Audit-ID"] = event_id
return response
finally:
def record() -> None:
try:
database.record_agent_audit_event(
event_id=event_id,
@@ -204,6 +200,43 @@ async def audit_agent_routes(request: Request, call_next: Any):
except Exception:
logger.exception("Unable to write agent audit metadata")
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Agent-Audit-ID"] = event_id
body_iterator = getattr(response, "body_iterator", None)
if body_iterator is None:
record()
return response
async def audited_body():
nonlocal status_code
try:
async for chunk in body_iterator:
yield chunk
except (asyncio.CancelledError, GeneratorExit):
# 499 is a diagnostic marker for a client-closed stream; it is
# not an HTTP status sent to the client after headers were sent.
status_code = 499
raise
except Exception:
status_code = 500
raise
finally:
record()
response.body_iterator = audited_body()
return response
except asyncio.CancelledError:
status_code = 499
record()
raise
except Exception:
status_code = 500
record()
raise
class Message(BaseModel):
role: str = Field(description="system أو user أو assistant")
@@ -4,6 +4,9 @@ import tempfile
import unittest
from unittest.mock import patch
from fastapi import Request
from fastapi.responses import StreamingResponse
_TEST_DATA_DIR = None
if "SOVEREIGNAI_DATA_DIR" not in os.environ:
_TEST_DATA_DIR = tempfile.TemporaryDirectory(prefix="sovereignai-stream-tests-")
@@ -13,6 +16,77 @@ from app import main
class AgentStreamTests(unittest.IsolatedAsyncioTestCase):
def _agent_request(self) -> Request:
return Request(
{
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/agent/run/stream",
"raw_path": b"/v1/agent/run/stream",
"query_string": b"",
"headers": [],
"client": ("127.0.0.1", 12345),
"server": ("127.0.0.1", 8000),
"root_path": "",
}
)
async def test_audit_duration_includes_complete_stream_lifetime(self) -> None:
recorded = []
async def body():
yield b"event: progress\n\n"
await asyncio.sleep(0.04)
yield b"event: done\n\n"
async def call_next(_request):
return StreamingResponse(body(), media_type="text/event-stream")
with patch.object(
main.database,
"record_agent_audit_event",
side_effect=lambda **event: recorded.append(event),
):
response = await main.audit_agent_routes(self._agent_request(), call_next)
chunks = [chunk async for chunk in response.body_iterator]
self.assertEqual(chunks, [b"event: progress\n\n", b"event: done\n\n"])
self.assertEqual(len(recorded), 1)
self.assertEqual(recorded[0]["status_code"], 200)
self.assertGreaterEqual(recorded[0]["duration_ms"], 35)
async def test_audit_marks_client_closed_stream_as_499(self) -> None:
recorded = []
started = asyncio.Event()
async def body():
yield b"event: progress\n\n"
started.set()
await asyncio.Future()
async def call_next(_request):
return StreamingResponse(body(), media_type="text/event-stream")
with patch.object(
main.database,
"record_agent_audit_event",
side_effect=lambda **event: recorded.append(event),
):
response = await main.audit_agent_routes(self._agent_request(), call_next)
stream = response.body_iterator
self.assertEqual(await anext(stream), b"event: progress\n\n")
waiting_read = asyncio.create_task(anext(stream))
await asyncio.wait_for(started.wait(), timeout=1)
waiting_read.cancel()
with self.assertRaises(asyncio.CancelledError):
await waiting_read
self.assertEqual(len(recorded), 1)
self.assertEqual(recorded[0]["status_code"], 499)
async def test_idle_model_wait_sends_heartbeat_then_final_result(self) -> None:
async def slow_agent(request, report_progress=None, *, user_id):
await report_progress("بدأ تحليل المهمة.")