fix agent stream audit lifecycle
This commit is contained in:
@@ -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 لواجهة التحكم موثقة في بند التحقق المرئي أدناه.
|
||||
|
||||
@@ -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("بدأ تحليل المهمة.")
|
||||
|
||||
Reference in New Issue
Block a user