Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/mcp-server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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}``
Expand Down
1 change: 1 addition & 0 deletions docs/whats-new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
39 changes: 34 additions & 5 deletions mcp_server/docs_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`` קושר
Expand Down Expand Up @@ -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}`` כשהחריגה נושאת מספר שורה, ו-``{}`` כשלא.

Expand Down Expand Up @@ -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

# התאמה יחידה → הסקשן + ניווט (שכנים ותת-סקשנים)
Expand Down
6 changes: 5 additions & 1 deletion mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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 "
Expand Down
Loading
Loading