feat(mcp): כל כלי קריאה מצהיר על תקרת התשובה שלו ועומד בה — מדידה אחת, ורשת אחרונה (#3460, #3474, #3481)

Merged
AM
22d ago
+3416-662

תבנית Pull Request

✨ תיאור קצר

  • Claude Code שומר לקובץ כל תשובת כלי שעוברת את הסף שלו (25,000 טוקנים כברירת מחדל, MAX_MCP_OUTPUT_TOKENS), חוץ מכלי שמצהיר ב-tools/list על _meta["anthropic/maxResultSizeChars"]: לכלי כזה הערך המוצהר, בתווים, מחליף את סף הטוקנים לתוכן טקסט (code.claude.com/docs/en/mcp.md, "MCP output limits and warnings", נקרא שוב ב-2026-09-29). עד היום רק codekeeper_read_batch הצהיר, ולכן קובץ עברי של 52,158 תווים (13 שורות) הגיע לסוכן כקובץ ולא כטקסט.
  • עכשיו כל כלי קריאה מצהיר על התקרה שלו, ועומד בה בעצמו: המדידה היא של מה שבאמת נשלח. מאחורי הכלים יושבת רשת אחרונה שמסרבת לתשובה שעוברת את מה שהוצהר.
  • בדרך נסגרו שני באגים באותו שורש: המדידה של query (#3474), וחיתוך באמצע עמוד ב-list_repo_tree (#3481).
  • עדכון — תיקוני הריוויו של Han (קומיט ef6a27d): מטא-דאטה של קובץ שמור כבר לא דוחקת את התוכן, "טווח או קריאה מלאה" נקבע לפי הבקשה, וכל ההתאמה יושבת במודול אחד. הפירוט בסעיף 7.

Closes #3460
Closes #3474
Closes #3481

📦 שינויים עיקריים

  • קוד (Backend)
  • בוט טלגרם
  • מסד נתונים/מיגרציות
  • תיעוד (docs/)
  • DevOps/CI/CD

פירוט נקודות:

1. מדידה אחת, במודול אחד — mcp_server/answer_size.py (חדש).

  • מודול בלי תלויות פנימיות. הוא מחזיק את התקציב (OUTPUT_BYTE_BUDGET), את המדידה כפי שה-SDK שולח (wire_json, list_item_cost), את אוצר המילים (ANSWER_TOO_LARGE, BYTE_BUDGET_REASON) ואת ההצהרה ללקוח (declared_size_meta).
  • בזכותו נשבר המעגל handlers ↔ repo_handlers, ש-QUERY_OUTPUT_BYTE_BUDGET היה עותק שלו בגללו. העותק נמחק.

2. כל כלי עומד בתקרה בעצמו.

כלי ומצבעל main (נמדד דרך call_tool)עכשיו
get_file, קריאה מלאה של 300K תווים עבריים547,927 בתיםanswer_too_large (548 בתים), עם הפניה ל-lines/query (ב-Markdown: toc/section)
get_file, lines=[1, ∞]548,037נגמר על גבול שורה (255,900), range.truncation_reason: "byte_budget", וממשיכים מ-end + 1
get_file, query לפי המתכון של #3474259,168249,041, truncation_reason: "byte_budget"
get_file, toc עם תיאור של 200K תוויםהסירוב עצמו 400,523אחרי הריוויו: מפת הכותרות מגיעה בהצלחה, התיאור ירד שלם, ובמקומו file.description_bytes ורמז ל-codekeeper_update_file_description
get_repo_file, docs/mcp-server.rst מלא299,038answer_too_large (771), עם lines/outline ו-read_with
get_repo_file, webapp/app.py עם lines=[1, ∞]932,955255,991, נגמר על גבול שורה
outline, CSS עם per_page=500נמדדו 247,684 (חלון דחוס), נשלחו 261,992page_too_large עם bytes=261,992
search_repo, שורות מלאות מירכאותנמדדו 248,234 (str()), נשלחו 374,931250,304
list_repo_tree, עמוד שלא נכנסחיתוך באמצע העמוד, והמשכו אבד (#3481)page_too_large על העמוד כולו, ואחרי הריוויו גם עם hint
list_board_notes, 200 פתקים × 20,000 תווים7,173,206answer_too_large (380) עם הפניה ל-include_content=false. הקריאה מהמסד נעצרת מוקדם

3. ההצהרה היא חלק מהרישום.

  • שמונה כלים מצהירים: get_file, get_repo_file, search_repo, list_repo_tree, list_notes, list_board_notes, docs_get_section ו-read_batch.
  • כולם מצהירים meta=answer_size.declared_size_meta(), על OUTPUT_BYTE_BUDGET. בתים הם חסם עליון על התווים שהלקוח סופר.
  • AdminAwareFastMCP.add_tool מסרב לערך שהלקוח לא יכבד: לא int, bool, אפס או פחות, או מעל 500,000.
  • list_repo_notes אינו מצהיר, בכוונה. החשבון: 20 × 20,000 = 400,000 תווים, כלומר לפחות 400,000 בתים, ואין לו מצב רזה.

4. רשת אחרונה ב-AdminAwareFastMCP.call_tool.

  • רצה בשני הענפים, וקוראת את התקרה מ-Tool.meta.
  • מודדת כל בלוק וגם structuredContent. צורה שאי אפשר למדוד מסורבת (fail-closed).
  • כתוב גם בקוד, ליד הרשת: תפיסה שלה היא באג בכלי, לא "המערכת עבדה".
  • כל תפיסה נרשמת כ-WARNING עם הסמן answer_size_net, שם הכלי והמספרים בלבד.

5. תיקונים קטנים באותו שורש.

  • סירוב על קובץ שמור מוותר על תיאור ענק שלם, ובמקומו file.description_bytes. זה הצד של הקריאה ב-#3489. השורש בכותבים נשאר פתוח שם.
  • ב-search_repo, חיתוך של התקציב גובר על max_results שהמנוע דיווח.

6. תיעוד.

  • docs/mcp-server.rst: סעיף חדש, mcp-answer-size. עודכנו גם טבלת הקבועים, הטווחים, query, המפה, העץ, הבאץ', הפתקים ופתרון התקלות.
  • docs/whats-new.rst, mcp_server/README.md.
  • תיאורי הכלים: משפט אחד משותף ל-get_file/get_repo_file, והשורה על רשימות הפתקים.

7. תיקוני הריוויו של Han (קומיט ef6a27d).

  • WARN-001 — מטא-דאטה לא דוחקת את התוכן. קודם, קובץ שמור עם תיאור ענק קיבל סירוב או טווח מקוצץ, גם כשהתוכן עצמו נכנס בנוחות. עכשיו ההכרעה ב-backend._fit_with_file_meta, לפי שלושה כללים:
    • (א) תשובה שנכנסת — זהה בית-בית לקודם.
    • (ב) תשובה שהייתה מסורבת או נחתכת, ובלי המטא-דאטה נכנסת שלמה — מוותרת קודם על התגיות ואחר כך על התיאור, עם סימן (tags_truncated, description_bytes) ורמז ל-codekeeper_update_file_description.
    • (ג) כשהתוכן עצמו גדול — המטא-דאטה נשארת, אלא אם זה סירוב, או שהמטא-דאטה גדולה מכל שאר התשובה (_metadata_outweighs). כך טווח של כמה שורות לא נחנק תחת תיאור של 200KB.
  • WARN-002 — "טווח או קריאה מלאה" לפי הבקשה. file_read_answer ו-fit_file_answer מקבלים ranged=lines is not None, ולא מסיקים את זה משדה range שמור במסמך. במראה זה לא היה באג (למסמך של מראה אין שדות משתמש), אבל הקוד עובר באותה דרך.
  • SUGG-001/002 — מודול mcp_server/answer_fit.py (חדש).
    • בונה סירוב אחד (too_large), סימון חיתוך אחד (cut(returned=, of=)), fit_read לשני כלי הקריאה, fit_refusal ו-fit_lists.
    • הכינויים וייצואי-המשנה (ANSWER_TOO_LARGE ב-docs_handlers, UNREAD_BYTE_BUDGET/_wire ב-read_batch, repo_handlers.OUTPUT_BYTE_BUDGET ב-server ועוד) הוסרו. כל מודול מייבא ישירות, וטסט AST שומר על זה — כולל כינויים וייצוא-משנה עקיף.
  • SUGG-003 — סירוב page_too_large של העץ נושא hint, והתיאור של list_repo_tree מזכיר אותו.
  • SUGG-004 — לוג INFO answer_size_fit על כל answer_too_large וחיתוך byte_budget: שם הכלי, שמות הארגומנטים בלבד (בלי ערכים), והמספרים. הרשומות נאספות ב-ContextVar שנפתח ב-call_tool ועובר לחוט העובד.
  • SUGG-005 — שלושה טסטים: תגיות ענקיות יחד עם תיאור ענק; טווח מהאמצע עם שני עותקי טקסט, על הגבול; רמז שמפנה לענף (ref) הנכון.
  • שינוי התנהגות מכוון (JD-004), מתועד ב-whats-new: לקוח שאינו Claude Code, שהיה מקבל עד היום 400KB, מקבל עכשיו סירוב עם רמז.

🧪 בדיקות

  • Unit
  • Integration: session אמיתי של הפרוטוקול, עם PostHog דלוק ומראות git אמיתיות ב-tmp_path.
  • הרצה חיה (סשן MCP אמיתי בזיכרון, מעל ProductionBackend ו-RepoBackend אמיתיים ומראות git אמיתיות), מפורטת למטה.
  • Manual: בדיקה אחרי דיפלוי — בוצעה אבל לא בדקה את ה-PR (רשימת כלים ישנה בצד הלקוח), מפורטת למטה. צריך לחזור עליה מסשן חדש.

קובץ טסטים חדש, tests/test_mcp_answer_size.py:

  • הרשת: נבדקת עם תקרה קטנה שמוזרקת לכלי, כך שעלות הטסט לא קשורה לקבוע.
  • שני הענפים: הרשת נבדקת גם בענף הרגיל וגם בענף של הבאץ'.
  • כלי שנרשם בעקיפת add_tool: גם הוא מוחזק לתקרה שלו.
  • כל בלוק נספר: וצורה שאי אפשר למדוד מסורבת.
  • בדיקת הערך ברישום.
  • שומר: דרך session אמיתי עם PostHog, ה-_meta שהלקוח רואה שווה ל-Tool.meta, והכלים שמצהירים הם בדיוק השמונה.
  • הקלט הגרוע של כל אחד משמונת הכלים: כולל docs_get_section וסבב מלא של טווח שנחתך, קריאת המשך וחיבור. התוצאה זהה לקובץ המקורי, בלי שורה כפולה ובלי חסרה, גם בקובץ גדול שבו התוכן יושב ב-code וגם ב-content.
  • כל טסט של כלי מוודא שהרשת לא נדלקה, כלומר שהכלי התאים את התשובה בעצמו.
  • המספרים בתיעוד: טסט מצמיד את המספרים בסעיף החדש לקבועים עצמם.

מול הקוד הישן: 33 מתוך 38 הטסטים החדשים נופלים על main, והנפילות הן על הגדלים עצמם (למשל 545437 <= 256000, 259187 <= 256000, 370076 <= 256000). חמישה עוברים שם, וזה צפוי:

  • טסטים של אפס-דיף: תשובה שנכנסת לא משתנה.
  • docs_get_section, שכבר התאים את עצמו.
  • טסט מבני על העלה.
  • הקבלה של ערך התקרה עצמו.

מוטציות (17), ב-worktree נפרד. כל אחת הפילה לפחות טסט אחד:

  • הרשת: רשת רק בענף אחד; רשת שקוראת רישום שמתמלא ב-add_tool; list_tools שמזריק _meta; מדידה של הבלוק הראשון בלבד, או של טקסט בלבד; דילוג על בדיקת הערך.
  • טווחים: end שמדווח את מה שביקשו.
  • query: מדידה דחוסה, או בלי שמורה למעטפת.
  • באץ': פריט קובץ בלי התאמה, או תקציב על הקריאה המשותפת.
  • פתקים: קריאה של כולם לפני המדידה, או חיתוך במקום סירוב.
  • שאר הכלים: תיאור שנשאר בסירוב; מדידת חלון האאוטליין; עץ שלא נדחה; הצהרה שנמחקה מכלי; שורות חיפוש שנמדדות ב-str(); max_results שגובר על התקציב.

תיקוני הריוויו — טסטים:

  • כל הטסטים החדשים נכתבו לפני התיקון ונפלו על הקוד שלפניו מהסיבה הנכונה.
  • טסט הלוג רץ דרך call_tool בתת-תהליך נקי, כשגוף הכלי רץ בחוט עובד — לא דרך caplog (הלקח של #3390/#3393).
  • טסט שקיבע את הבאג (סירוב של toc בגלל תיאור) עודכן להתנהגות החדשה. החשבון של הסירוב עצמו נבדק עכשיו ישירות.
  • 11 מוטציות נוספות, כולן נהרגו: הקשר שלא עובר לחוט העובד; בלי שורת לוג; _metadata_outweighs תמיד אמת / תמיד שקר; מסלול הצלחה ששומר מטא-דאטה; רמז חסר בהצלחה ובסירוב; הסתעפות לפי range שמור; רמז חסר בעץ; 256_000 מוקלד מחדש; חיתוך שלא נרשם.

הרצה חיה (אחרי תיקוני הריוויו): סשן MCP אמיתי, פעם על הענף לפני התיקונים ופעם אחריהם, והשוואת הבתים שהלקוח קיבל.

  • הקובץ מ-#3460 גדל: docs/source-projects/codebot-patterns.md ב-amir-bug-patterns הוא היום 133,844 בתים ב-1,045 שורות, ולא כ-52,000 תווים ב-13 שורות. לכן נבנה תחליף באותם ממדים (52,158 תווים עבריים, 13 שורות), והקובץ האמיתי רץ לצידו.
  • אפס-דיף: 316 מתוך 320 תשובות זהות בית-בית, כולל כל 300 תשובות הסריקה (כל קובץ .md ב-amir-bug-patterns — כקובץ שמור, כ-toc, וכקובץ במראה).
  • ארבע התשובות ששונות הן בדיוק מקרי WARN-001: קובץ עם תיאור של כ-200KB. קריאה מלאה של התחליף: קודם סירוב, עכשיו התוכן המלא (92,854 בתים) בלי התיאור ועם רמז; אותו דבר בקובץ האמיתי (135,886 בתים); toc ו-section_not_found ירדו מ-244KB ל-177KB.
  • הלוג בפועל: answer_size_fit: codekeeper_get_repo_file args=[lines, path, repo] byte_budget returned=1172 of=2500 — שמות בלבד, בלי ערכים.

חבילת הטסטים המלאה: ירוקה, חוץ מכשלים שקיימים גם על main ואינם קשורים:

  • tests/test_infrastructure.py: isort ו-autopep8 לא מותקנים בסביבה.
  • טסטי דפדפן: נופלים רק בהרצה מקבילית, ועוברים כשמריצים אותם אחד-אחד (55 מתוך 55).

בדיקה אחרי דיפלוי (ידנית) — בוצעה, ולא בדקה את ה-PR:

  • מה נבדק: קריאה מלאה דרך codekeeper_get_repo_file של docs/source-projects/codebot-patterns.md ב-amir-bug-patterns, מסשן Claude Code בענן, דרך ה-connector של claude.ai, מול השרת בפרודקשן.
  • השרת: שלח את התשובה שלמה ותקינה — "ok": true, כל התוכן, 135,499 בתים, בתוך OUTPUT_BYTE_BUDGET. לא נחתך ולא סורב.
  • הלקוח: שמר את התשובה לקובץ, עם ההודעה result (89,554 characters across 13 lines) exceeds maximum allowed tokens — כלומר הפעיל את סף הטוקנים (25,000 כברירת מחדל), ולא תקרה מוצהרת.
  • למה זה לא בודק את ה-PR: תיאור הכלים שהסשן החזיק ברגע הבדיקה הוא של 7be7f88, ה-main שלפני ה-PR: תיאור toc שלו מכיל את המשפט "a successful reply never cuts the file's metadata", שקיים ב-7be7f88 ולא ב-383a05e, וב-7be7f88 אף כלי קריאה לא מצהיר על anthropic/maxResultSizeChars. רשימת הכלים של הסשן נטענה לפני הדיפלוי ולא התרעננה, ולכן מבחינת הלקוח הכלי לא הצהיר על כלום.
  • לפי התיעוד (code.claude.com/docs/en/mcp.md, "MCP output limits and warnings"), כלי שמצהיר מקבל את התקרה המוצהרת לתוכן טקסט "regardless of what MAX_MCP_OUTPUT_TOKENS is set to". הציפייה שהייתה כתובה כאן — שהקובץ יגיע להקשר — עדיין לא נבדקה, ולא הופרכה.
  • מה שהמדידה כן מראה: כלי שלא מצהיר נחסם בסף טוקנים, ו-89,554 תווים מול 135,499 בתים הם היחס של עברית — קובץ עברי ייחסם בסף הזה הרבה לפני קובץ אנגלי באותו גודל. זה בדיוק המצב ש-#3460 פתר עבור שמונת הכלים שמצהירים.
  • הצעד הבא: לחזור על הבדיקה מסשן Claude Code חדש, שנפתח אחרי הדיפלוי.

🧪 בדיקות נדרשות ב‑PR

  • 🔍 Code Quality & Security
  • Unit Tests (3.11)
  • Unit Tests (3.12)

📝 סוג שינוי

  • feat: פיצ'ר חדש (ההצהרה והרשת)
  • fix: תיקון באג (#3474, #3481, סירוב שעבר את התקציב, מטא-דאטה שדחקה תוכן)
  • docs: שינוי תיעוד בלבד
  • refactor: answer_fit.py — מקום אחד להתאמה
  • perf: שיפור ביצועים
  • chore/ci: תשתית/CI
  • breaking change: שינוי צורת תשובה במקרים שעוברים את התקציב, בכוונה.
    • קריאה מלאה גדולה מהתקציב: עכשיו answer_too_large במקום התוכן.
    • טווח גדול מהתקציב: נגמר מוקדם, על גבול שורה.
    • רשימת פתקים מלאה גדולה מהתקציב: עכשיו סירוב.
    • list_repo_tree מעל התקציב: page_too_large.
    • קובץ שמור שהמטא-דאטה שלו לא נכנסת לצד התוכן: התיאור/התגיות יורדים עם סימן ורמז.
    • תשובה שנכנסת בתקציב לא משתנה בבית אחד.

✅ צ'קליסט

  • הקוד עוקב אחרי הסגנון (flake8 נקי על הקבצים שנגעתי בהם, מלבד E302 שקיים על main ב-backend.py)
  • בדיקות רצות ועוברות
  • תיעוד עודכן (README/Docs)
  • אם נוספו ג'ובים חדשים — לא רלוונטי
  • אם נוספו/שונו משתני סביבה — לא נוספו
  • אם נוספו/השתנו טוקנים — לא רלוונטי
  • אין סודות/מפתחות בקוד. הלוגים (answer_size_net, answer_size_fit) נושאים שם כלי, שמות ארגומנטים ומספרים בלבד.
  • אין מחיקות מסוכנות/פעולות על root
  • הודעת הקומיט תואמת Conventional Commits
  • CHANGELOG עודכן (docs/whats-new.rst)
  • כל ה‑Required Checks לעיל ירוקים
  • צילום/וידאו UI — לא רלוונטי
  • עיינתי במסמכי אתר התיעוד:
    • AI-MAP.md
    • docs/mcp-server.rst (הכלים, הקבועים, מודל הריצה, קריאת טווח, query, מצב הסעיפים, מפת הסימבולים, העץ, read_batch, get_note, פתרון תקלות)
    • docs/doc-authoring.rst
    • docs/versioning-stable-anchors.rst
    • docs/style-glossary.rst
    • docs/testing.rst
    • docs/error_codes.rst: מכסה קודי E_* של Sentry ולא סירובי MCP.
    • events_catalog: שורת WARNING/INFO של stdlib אינה אירוע.
    • המשפט: "התיעוד הוא התמצאות, לא סמכות".

🧩 השפעות/סיכונים

  • שינוי צורה רק מעל התקציב: תשובה שנכנסת זהה בית-בית (יש לזה טסטים והרצה חיה של 316 תשובות). שינוי הצורה שלמעלה חל רק על תשובות שעד היום נשמרו לקובץ אצל Claude Code, כלומר ממילא לא הגיעו לסוכן כטקסט.
  • לקוח שאינו Claude Code: מקבל סירוב עם רמז במקום תשובה של מאות KB (מתועד ב-whats-new).
  • ביצועים:
    • קריאה מלאה מסדרת את התשובה פעם אחת כדי למדוד אותה. זה פרופורציונלי: המסמך כבר נטען ומחושב לו hash.
    • טווח ורשימות נמדדים בהצטברות, בלי לבנות את הטקסט המלא.
    • רשימת פתקים נעצרת במסד ברגע שהתקציב עבר.
    • ניסיון ללא מטא-דאטה רץ רק כשהתשובה הראשונה סורבה או נחתכה.
  • PostHog: האירוע $mcp_tool_call נלכד מתחת לרשת, ולכן תפיסה של הרשת נראית רק בלוג (answer_size_net).

🔗 קישורים

  • Issues קשורים: #3460, #3474, #3481, #3457 (סעיף 1 נסגר כאן; סעיפים 2–4 מוקפאים), #3489 (צד הקריאה בלבד)
  • Docs Preview: ה-check של Read the Docs על ה-PR
  • מסמכים/מפרטים רלוונטיים: docs/mcp-server.rst → "תקרת התשובה — מה כלי מצהיר, ומי אוכף" (mcp-answer-size)
  • Branch Protection & PR Rules

🧯 סיכון / החזרה לאחור (Rollback)

  • revert של הקומיטים. אין מיגרציה ואין שינוי במסד.
  • אחרי revert, הכלים חוזרים לשלוח תשובות גדולות, ו-Claude Code חוזר לשמור אותן לקובץ.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FqzfvA6KiivEag5KC2mCub