diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index e8948f355..d61eb6a25 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -1213,7 +1213,13 @@ Claude Desktop בצורת המזהה שהחוזה מגדיר. **מזהה שחוזר פעמיים באותו עמוד הוא ``ambiguous_section``**, עם רשימת -המועמדים — בדיוק כמו כל כותרת כפולה. הכלי אינו מנחש. +המועמדים — בדיוק כמו כל כותרת כפולה. הכלי אינו מנחש. **והרשימה חסומה ב-50 +מועמדים**, אותו מספר ומאותה סיבה כמו ההצעות: מלאי בסדר הופעה ולא דירוג, +גבוה מספיק שכל עמוד אמיתי ייענה במלואו. אם היו יותר, התשובה נושאת +``candidates_truncated: true`` — ורק אז, כמו ``suggestions_truncated``. לפני +התקרה זה היה השדה היחיד בתשובה בלי גבול: עמוד סינתטי של 512KB עם 13,030 +כותרות שנפתחות באותו מזהה החזיר 13,030 מועמדים ו-1.4MB על שאילתה בת שלושה +תווים (#3426). **ותקרה על מספר הסקשנים בעמוד — אותה תקרה של האאוטליין, 50,000.** עמוד שחוצה אותה מוחזר כ-``{"ok": false, "error": "too_many_sections", "max": 50000}`` diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..38d42b892 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **fix: ``codekeeper_docs_get_section`` חוסם את רשימת המועמדים של ``ambiguous_section`` ב-50 (#3426).** זה היה השדה היחיד בתשובה בלי תקרה: ``toc`` חסום ב-400 ו-``suggestions`` ב-50, ורק המועמדים חזרו כולם — נמדד 13,030 מועמדים ו-1.4MB על שאילתה בת שלושה תווים בעמוד סינתטי. הענף נדלק מכותרות שנושאות מזהה, ומאז #3428 הקורפוס שנושא אותם מוגש. אותו מספר כמו ההצעות ומאותה סיבה (מלאי בסדר הופעה, לא דירוג), והדגל ``candidates_truncated`` מופיע רק כשנחתך — כמו ``suggestions_truncated`` — ולכן תצלום אפס-הדיף של הכלי על 208 קובצי ה-RST זהה לפני ואחרי. ובאותו PR: טסט שמקבע שגם במסלול ה-Markdown ``suggestions`` הוא רשימת מחרוזות ולא ה-``NamedTuple`` (הצרכן השני מ-#3426 הוא אותו אתר קריאה, והוא כותב ``.titles``). - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py index 8f13e5b8a..600cd7c27 100644 --- a/mcp_server/docs_handlers.py +++ b/mcp_server/docs_handlers.py @@ -56,6 +56,19 @@ DEFAULT_DOCS_REPO = "CodeBot" _TOC_MAX = 400 # תקרת פריטי TOC בתשובה (הגנת גודל) +#: תקרת המועמדים בתשובת ``ambiguous_section``. **אותו מספר כמו +#: ``doc_sections.MAX_IDENTIFIER_SUGGESTIONS``, ומאותה סיבה:** רשימת המועמדים +#: היא מלאי בסדר הופעה ולא דירוג, ולכן חיתוך שלה מסתיר פריטים בלי קריטריון — +#: והתקרה גבוהה מספיק כדי שכל עמוד אמיתי ייענה במלואו (כותרת כפולה בקורפוס +#: חוזרת פעמיים או שלוש). מה שהיא עוצרת נמדד בסקירת #3425: עמוד סינתטי של +#: 512KB עם 13,030 כותרות שנפתחות ב-``K11.`` החזיר 13,030 מועמדים — 1,393,787 +#: בתים ושיא הקצאה של 12.6MB — על שאילתה בת שלושה תווים, וזה היה השדה היחיד +#: בתשובה בלי גבול (``toc`` ב-``_TOC_MAX``, ‏``suggestions`` +#: ב-``MAX_IDENTIFIER_SUGGESTIONS``). הענף נדלק רק מכותרות שנושאות מזהה, ומאז +#: #3428 הקורפוס שנושא מזהים (``amir-bug-patterns``) מוגש. השוויון בין שני +#: המספרים מקובע בטסט, כמו שני גבולות האאוטליין והפארסר. אישו #3426. +_CANDIDATES_MAX = 50 + #: הסיומת ← **המודול** שמפרסר אותה. זהו המקום היחיד שאומר איזה פורמט הכלי #: יודע לקרוא בכלל, ו-``DOCS_PATH_POLICY`` למטה אומר מי מהם מוגש בכל ריפו. #: שתי שאלות שונות, ולכן שתי טבלאות — ו-``_validate_policy_tables`` קושר @@ -286,13 +299,23 @@ def _resolve_docs_path(path: str, policy: _DocsPathPolicy) -> _ResolvedPath: return _ResolvedPath(None, None, "missing_path") -def _toc(doc: doc_sections.Document) -> tuple[list, bool]: - items = doc_sections.build_toc(doc) - if len(items) > _TOC_MAX: - return items[:_TOC_MAX], True +def _capped(items: list, limit: int) -> tuple[list, bool]: + """‏``(items, truncated)`` — הרשימה עד ``limit``, והאם באמת נחתכה. + + **פונקציה אחת לשני הגבולות של התשובה** (``toc`` ו-``candidates``), כדי + שהשאלה "מתי הדגל נכון" תיענה במקום אחד: ``True`` רק כשהיו יותר מהתקרה, + ולעולם לא על רשימה שבדיוק בגודלה — אותו גבול כמו ``Capped.append`` + ב-``_ceiling`` וכמו ``max_sections`` בפארסר. + """ + if len(items) > limit: + return items[:limit], True return items, False +def _toc(doc: doc_sections.Document) -> tuple[list, bool]: + return _capped(doc_sections.build_toc(doc), _TOC_MAX) + + def _line_of(exc: BaseException) -> dict: """``{"line": N}`` כשהחריגה נושאת מספר שורה, ו-``{}`` כשלא. @@ -496,13 +519,19 @@ def docs_get_section( # כותרת כפולה → כל המועמדים עם breadcrumb (בלי לנחש) if len(matches) > 1: + candidates, candidates_truncated = _capped(matches, _CANDIDATES_MAX) base.update({ "ok": False, "error": "ambiguous_section", "requested": section, "candidates": [{ "title": s.title, "breadcrumb": list(s.breadcrumb), "level": s.level, "line_range": [s.heading_line, s.end_line], - } for s in matches], + } for s in candidates], }) + if candidates_truncated: + # רק כשנחתך — אותה מוסכמה של ``suggestions_truncated`` ו-``remaining_chars``: + # שדה שקיים תמיד היה משנה כל תשובת ``ambiguous_section`` בתצלום + # אפס-הדיף בלי ששום התנהגות השתנתה. + base["candidates_truncated"] = True return base # התאמה יחידה → הסקשן + ניווט (שכנים ותת-סקשנים) diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..d6f438b25 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -203,6 +203,8 @@ def _build_note_color_doc() -> str: # ייבוא היא ``import-time-side-effects``, ותיאור הכלי היה משתנה בין פריסות — # כלומר שני לקוחות היו קוראים שני חוזים שונים לאותו כלי. מה שה-ENV קובע # נאמר בתיאור במילים, בלי למנות ממנו. + + def _build_docs_path_doc() -> str: parts = [] for repo, policy in docs_handlers.DOCS_PATH_POLICY.items(): @@ -261,7 +263,9 @@ def _build_docs_path_doc() -> str: "sub-numbered heading is reachable by its full name only. " "An identifier that repeats in " "the file is ambiguous_section with candidates, like any duplicate " - "heading. A query that is not shaped like an identifier never takes " + "heading — at most 50 of them, in document order, with " + "candidates_truncated: true only when more were cut. A query that is " + "not shaped like an identifier never takes " "this path. (2) BACKTICKS: headings are returned as raw source, so a " "heading written with ``literal`` markup needs those backticks in the " "query too — section=\"MissingGreenlet\" finds nothing when the " diff --git a/tests/test_mcp_docs_handlers.py b/tests/test_mcp_docs_handlers.py index fbe329d81..b80f14f78 100644 --- a/tests/test_mcp_docs_handlers.py +++ b/tests/test_mcp_docs_handlers.py @@ -170,6 +170,52 @@ def test_ambiguous_section_returns_candidates_with_breadcrumb(): assert crumbs[0] != crumbs[1] # Alpha vs Beta +def test_candidates_are_capped_and_the_flag_appears_only_when_they_were_cut(monkeypatch): + """‏``candidates`` היה השדה היחיד בתשובה בלי תקרה (#3426). + + מזהה שחוזר בעמוד החזיר את **כל** המופעים — נמדד 13,030 מועמדים ו-1.39MB + על שאילתה בת שלושה תווים. עכשיו הוא חסום כמו ``toc`` ו-``suggestions``, + והדגל קיים רק כשהוא נכון, כדי שכל תשובת ``ambiguous_section`` על עמוד + רגיל תישאר זהה בית-בית (אפס-דיף). התקרה נקראת מהמודול בזמן הקריאה + ומוקטנת כאן ל-2, במקום לבנות חמישים ואחת כותרות — אותו נימוק כמו בטסט + תקרת הסקשנים. + """ + monkeypatch.setattr(docs_handlers, "_CANDIDATES_MAX", 2) + + at_the_cap = docs_handlers.docs_get_section( + _TextBackend(_identified_rst("P3 — ראשון", "P3 — שני")), path="x", section="P3") + assert at_the_cap["error"] == "ambiguous_section" + assert [c["title"] for c in at_the_cap["candidates"]] == ["P3 — ראשון", "P3 — שני"] + assert "candidates_truncated" not in at_the_cap, "עמוד שבדיוק בתקרה נענה במלואו, בלי דגל" + + over = docs_handlers.docs_get_section( + _TextBackend(_identified_rst("P3 — ראשון", "P3 — שני", "P3 — שלישי")), + path="x", section="P3") + assert over["error"] == "ambiguous_section" + assert [c["title"] for c in over["candidates"]] == ["P3 — ראשון", "P3 — שני"], "בסדר הופעה" + assert over["candidates_truncated"] is True + + +def test_the_candidates_cap_is_the_suggestions_inventory_number(): + """שני מלאים לא-מדורגים באותה תשובה — מספר אחד, כמו שני גבולות האאוטליין והפארסר.""" + assert docs_handlers._CANDIDATES_MAX == doc_sections.MAX_IDENTIFIER_SUGGESTIONS + + +def test_suggestions_on_a_markdown_page_are_a_list_of_strings_too(both_repos): + """הצרכן שנוסף ב-#3428 הוא אותו אתר קריאה לשני הפורמטים, והוא כותב ``.titles``. + + ‏#3426 ביקש שזה ייקבע בטסט ולא רק ב-docstring של ``Suggestions``: מי שישכח + את ``.titles`` ישלח ``[["K11"], false]`` — JSON תקין בלי שום שגיאה — וגם + במסלול ה-Markdown איש לא היה תופס את זה. + """ + md = "# K11. כשל\n\nטקסט\n\n# K12. דגל\n\nטקסט\n" + out = docs_handlers.docs_get_section(_TextBackend(md), path="x.md", + repo="amir-bug-patterns", section="K99") + assert out["error"] == "section_not_found" + assert out["suggestions"] == ["K11", "K12"] + assert all(isinstance(s, str) for s in out["suggestions"]) + + def test_include_subsections_false_smaller_than_true(): full = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", section="משתני סביבה - רפרנס", include_subsections=True)