Octofs 0.9.0: บรรทัดที่ 42 คือคำโกหก

เอเจนต์ขอแทนที่บรรทัดที่ 40 ถึง 44 และก็ได้บรรทัดที่ 40 ถึง 44 จริง ๆ แต่มันไม่ใช่บรรทัดที่มันเคยอ่าน

ไม่มีอะไรพัง ไม่มี error โผล่มา ที่ไหนสักแห่งระหว่าง view ที่สร้างแผน กับ batch_edit ที่ลงมือทำ มี formatter ทำงานแทรกและเลื่อนไฟล์ลงไปสามบรรทัด การแก้ไขจึงลงเรียบร้อยสวยงามบนโค้ดห้าบรรทัดที่ไม่รู้อีโหน่อีเหน่ โมเดลเห็นผลลัพธ์ว่าสำเร็จ รายงานว่ารีแฟกเตอร์เสร็จแล้ว แล้วเดินหน้าต่อ เราเจอมันตอนรีวิวยี่สิบนาทีให้หลัง จากการอ่าน diff ที่ไม่สมเหตุสมผลเอาเสียเลย

รีลีสนี้มีอยู่เพื่อฆ่ารูปแบบความล้มเหลวนี้

0.9.0 ทำให้ที่อยู่ของทุกบรรทัดตรวจสอบได้จากเนื้อหา: หนึ่งบรรทัดคือ N:hh — ตำแหน่งของมัน บวกกับแฮชของสิ่งที่อยู่บนบรรทัดนั้น — และเครื่องมือแก้ไขทุกตัวจะตรวจแฮชนั้นกับไฟล์ก่อนเขียนแม้แต่ไบต์เดียว เป้าหมายที่ล้าสมัยจะล้มเหลวอย่างชัดเจน โดยมีเนื้อหาปัจจุบันอยู่ในข้อความผิดพลาด มันลงผิดบรรทัดไม่ได้อีกแล้ว เพราะ "บรรทัดที่ผิด" จะไม่ตรงกันตั้งแต่แรก

มีอีกสองการเปลี่ยนแปลงออกมาพร้อมกัน และสุดท้ายกลายเป็นว่ามันคือแนวคิดเดียวกันที่ใส่เสื้อผ้าคนละชุด เดี๋ยวเล่าตอนท้าย


เลขบรรทัดคือ primitive ที่ผิด

เรื่องของเลขบรรทัดคือ มันเป็นจริงเฉพาะวินาทีที่คุณอ่านมันเท่านั้น

ลูปการแก้ไขของเอเจนต์คือลำดับของ MCP call ที่แยกจากกัน โดยมีช่องว่างคั่นระหว่างกัน และในช่องว่างนั้นไฟล์ไม่ได้ถูกแช่แข็งไว้ tool call อื่นแก้มัน formatter ทำงานตอนบันทึก เอเจนต์อีกตัวที่รันคู่ขนานไปแตะไฟล์เดียวกัน คนที่นั่งดูเซสชันอยู่แก้คำผิดหนึ่งจุด พอถึงเวลาที่การแก้ไขเดินทางมาถึง "บรรทัดที่ 42" ก็ชี้ไปยังอะไรก็ตามที่บังเอิญมานั่งอยู่ในช่องที่ 42 — และเซิร์ฟเวอร์ไฟล์ที่รับแค่จำนวนเต็มเปล่า ๆ ไม่มีทางแยกออกเลยว่าบรรทัดที่โมเดลตั้งใจจะแก้ กับบรรทัดที่มันกำลังจะทำลาย ต่างกันตรงไหน

วิธีบรรเทาที่ใช้กันทั่วไปคือ whole-file staleness gate: ประทับตราไฟล์ตอนที่ถูกอ่าน แล้วปฏิเสธการแก้ไขถ้า mtime หรือแฮชเปลี่ยน เราเคยมีแบบนั้น มันทื่อทั้งสองทาง มันปฏิเสธการแก้บรรทัดที่ 900 เพราะมีคนไปแตะบรรทัดที่ 3 และการจะกู้คืนก็ต้องอ่านไฟล์ใหม่ทั้งไฟล์ — แพง แถมสิ่งที่อ่านใหม่ก็ล้าสมัยทันทีอีก ที่แย่กว่านั้นคือมันไม่ได้บอกอะไรที่เป็นประโยชน์เลย "ไฟล์เปลี่ยนไปแล้ว" ทิ้งให้โมเดลเหลือทางเดินเดียว: อ่านไฟล์ทั้งไฟล์อีกครั้ง แล้วภาวนาว่าคราวนี้จะชนะการแข่งขัน

primitive มันผิดตั้งแต่ต้น การอ้างอิงถึงบรรทัดควรพกข้อมูลมากพอที่จะตรวจสอบตัวเองได้


N:hh — ตำแหน่งบวกหลักฐาน

ใน 0.9.0 view แสดงทุกบรรทัดเป็น N:hh|content:

1:a3|fn main() {
2:f1|    println!("Hello");
3:0e|}

N คือตำแหน่งที่นับเริ่มจาก 1 ส่วน hh คืออักขระฐานสิบหกสองตัว — แฮช FNV-1a ของเนื้อหาบรรทัดนั้น พับจาก 32 บิตลงมาเหลือ 8 บิต เครื่องมือแก้ไขรับ composite ID เหล่านี้กลับไปเป็นเป้าหมาย และ verify_line_id จะตรวจแฮชกับไฟล์ ณ ตอนที่ลงมือแก้ ถ้าตรง การแก้ไขก็เดินหน้า ถ้าไม่ตรง ก็ไม่มีอะไรถูกเขียนลงไปเลย

แฮชครอบคลุม เฉพาะเนื้อหา ไม่เคยรวมตำแหน่ง ตอนเขียนมันดูเหมือนรายละเอียดเล็ก ๆ แต่กลายเป็นหัวใจของการออกแบบทั้งหมด เพราะบรรทัดยังเก็บแฮชเดิมไว้แม้จะย้ายที่ การตรวจสอบที่ล้มเหลวจึงออกตามหาได้ว่าเนื้อหาไปอยู่ไหน — สแกนไฟล์หาบรรทัดที่มีแฮชตามที่คาด แล้วคุณก็รู้ว่าเป้าหมายไม่ได้หายไป มันแค่เลื่อนลงไปสามบรรทัด

ซึ่งนั่นคือสิ่งที่ข้อความผิดพลาดบอกเป๊ะ ๆ:

Stale line id "42:c7" — the file changed since you viewed it. Current content around line 42:
40:1b|    let config = load_config()?;
41:9f|    let client = Client::new(&config);
42:2e|    tracing::info!("client ready");
43:0a|
44:5d|    run(client).await
Content matching hash c7 is now at: 45:c7 (your target may have moved).
Retry with the fresh ids above, or run `view` with start: 40, end: 44 (or a wider range) to confirm before editing.

ในข้อความนั้นมีสามสิ่ง และแต่ละสิ่งตั้งใจใส่ไว้ หนึ่งคือเนื้อหาปัจจุบันรอบ ๆ เป้าหมายพร้อม ID ชุดใหม่ เพื่อให้โมเดลเล็งเป้าใหม่ได้ทันที สองคือตอนนี้เนื้อหาที่ตรงกับแฮชที่คาดไว้ไปอยู่ที่ไหน โดยเรียงตัวเลือกที่ใกล้ที่สุดก่อน เพื่อให้บรรทัดที่แค่ย้ายที่แต่ไม่ถูกแก้ไขกลับมาถูกต้องได้ในก้าวเดียว และสามคือช่วง view ที่เจาะจง เผื่อว่ามันอยากยืนยันมากกว่าจะเดา

โมเดลกู้สถานการณ์ได้จากข้อความผิดพลาดเพียงอย่างเดียว ไม่ต้องอ่านไฟล์ 2,000 บรรทัดใหม่ ไม่มีการแข่งขันรอบสอง ไม่มีคอนเท็กซ์ที่ถูกเผาทิ้ง ข้อความผิดพลาดคือคำสั่งวิธีกู้คืนในตัวมันเอง

และเพราะผลลัพธ์ของการแก้ไขกลับมาเป็น diff ที่มี ID คำนวณใหม่สด ๆ การแก้ไขจึงต่อกันเป็นสายได้ เรียก batch_edit สามครั้งติดกัน แล้วเป้าหมายของครั้งที่สองก็มาจากผลลัพธ์ของครั้งแรก — ไม่ต้อง view ไฟล์ซ้ำระหว่างนั้นเลย

มีข้อแลกเปลี่ยนหนึ่งข้อที่เราพูดตรง ๆ และมันเขียนไว้เป็นคอมเมนต์ในซอร์สโค้ด ไม่ได้ซุกซ่อนไว้: แปดบิตหมายความว่าบรรทัดที่เปลี่ยนไปแล้วยังมีโอกาส 1/256 ที่จะได้แฮชเดิม เรายอมรับข้อนี้ ID ยังสั้นพอที่จะถูกในแง่คอนเท็กซ์และอ่านออกใน transcript การตรวจตำแหน่งจับการเลื่อนแบบหยาบ ๆ ได้ทุกกรณี ส่วนทางเลือกอีกทาง — ใช้แฮชยาวขึ้นบนทุกบรรทัดของทุกครั้งที่อ่านไฟล์ — จะกินโทเคนในทุก ๆ การอ่านจริง ๆ เพียงเพื่อกันกรณี 0.4% ที่ลูป "diff พร้อม ID ชุดใหม่" มักเปิดโปงออกมาอยู่แล้ว

เรายังลบสวิตช์เลือกโหมดทิ้งด้วย เวอร์ชันก่อนหน้ามีแฟล็ก --line-mode ให้เลือกระหว่างการอ้างอิงด้วยเลขบรรทัดกับด้วยแฮช แฟล็กนั้นหายไปแล้ว และรูปแบบ N:hh เป็นข้อบังคับ — นี่คือ breaking change ของ 0.9.0 การมีสองโหมดแปลว่าคำอธิบายของทุกเครื่องมือต้องอธิบายทั้งคู่ โมเดลทุกตัวต้องเดาว่าตัวเองกำลังคุยกับโหมดไหน และโหมดที่ปลอดภัยกลับเป็นแบบต้องเปิดเอง ความปลอดภัยที่ส่งมาหลังแฟล็ก คือความปลอดภัยที่คนส่วนใหญ่ไม่เคยเปิดใช้

จำนวนเต็มธรรมดายังใช้ได้ในที่ที่ตำแหน่งอย่างเดียวปลอดภัยจริง ๆ และโดยธรรมชาติแล้วไม่มีอะไรให้ตรวจสอบ: ช่วงของ view (ค่าติดลบนับจากท้ายไฟล์) และจุดยึดสำหรับแทรก คือ 0 สำหรับต้นไฟล์ และ -1 สำหรับต่อท้าย ส่วนทุกอย่างที่เล็งไปยังเนื้อหา ที่มีอยู่แล้ว ต้องใช้ ID


การแก้ไขที่สำเร็จแต่ไม่ได้ทำอะไรเลย

ในเมื่อลงไปดูข้างในแล้ว เราก็เจอบั๊กเดียวกันในเวอร์ชันที่เงียบกว่า

str_replace จับคู่แบบไล่ระดับ: เริ่มจากตรงเป๊ะก่อน แล้วค่อยตามด้วยรอบ fuzzy ที่ปรับช่องว่างให้เป็นมาตรฐาน เผื่อกรณีที่การย่อหน้าของโมเดลเคลื่อนไป ในไฟล์ที่ใช้ตัวจบบรรทัดแบบ CRLF รอบ fuzzy จะจับคู่ได้บนข้อความที่ปรับมาตรฐานแล้ว จากนั้นพยายามยัดข้อความแทนที่กลับเข้าไปในเนื้อหาดิบ ซึ่งทุกบรรทัดยังจบด้วย \r\n อยู่ จึงไม่มีที่ให้ยัดลงไปได้เลย มันเขียนไฟล์กลับลงไปเหมือนเดิมทุกไบต์ แล้วรายงานว่าสำเร็จพร้อม diff การแก้ไขของนักพัฒนาบน Windows เดินครบทุกขั้นตอนโดยที่ไม่มีอะไรเปลี่ยนเลย

ตอนนี้การจับคู่ทั้งหมดเกิดขึ้นในพื้นที่แบบ LF และ restore_endings จะใส่ \r\n กลับคืนตอนเขียน batch_edit ก็เช่นเดียวกัน ไฟล์ยังคงตัวจบบรรทัดของตัวเองไว้ ส่วนตัวจับคู่ก็เลิกสนใจมันไปเลย

บันไดการจับคู่เต็มรูปแบบใน 0.9.0 คือ ตรงเป๊ะกู้คืนอักขระ escape ที่เป็นตัวอักษรจริงfuzzy แบบปรับช่องว่างพร้อมปรับย่อหน้าข้อมูลวินิจฉัย ขั้นที่สองเป็นของใหม่ และเป็นวิศวกรรมที่ทำขึ้นจากรูปแบบความผิดพลาดของโมเดลล้วน ๆ: เมื่อโมเดล escape JSON ของตัวเองสองชั้น แล้วส่ง backslash กับ n ที่เป็นตัวอักษรจริงมาแทนการขึ้นบรรทัดใหม่ เราจะตีความ escape เหล่านั้น และถ้า แบบนั้น จับคู่ได้เพียงจุดเดียว เราก็ลงมือแก้ให้เลยพร้อมแนบหมายเหตุว่าเราทำอะไรไป มันเป็นความผิดพลาดที่โมเดลทำอยู่ตลอด เป็นกรณีที่ชัดเจนไม่กำกวมเมื่อเกิดขึ้น และการโยน error กลับไปเพราะเรื่องนี้ก็เท่ากับเสียการสื่อสารไปหนึ่งรอบเพื่อแก้สิ่งที่ไม่ได้เสียจริง

replace_all ก็เป็นของใหม่เช่นกัน — การแก้ไขสไตล์เปลี่ยนชื่อ ที่เมื่อก่อนต้องอาศัยบริบทรอบข้างมากพอให้ทุกจุดที่พบไม่ซ้ำกัน หรือไม่ก็ต้องใช้ batch_edit โดยมีหนึ่งคำสั่งต่อหนึ่งจุด และเมื่อการจับคู่แบบตรงเป๊ะเจอหลายจุดโดยไม่ได้ใส่ replace_all ข้อความผิดพลาดตอนนี้จะไล่ตำแหน่งที่พบทั้งหมด ในรูปของ line ID:

Found 3 matches for replacement text at:
  1. 12:a3
  2. 88:a3
  3. 140:a3
Add more surrounding context to make a unique match, pass `replace_all: true` to replace all 3 occurrences, or use `batch_edit` with the specific line ids.

ทางออกที่ระบุชื่อไว้สามทาง และทุกทางลงมือได้ทันทีโดยไม่ต้อง view อีกรอบ รูปแบบเดิมอีกครั้ง


คำแนะนำก็คือคำแนะนำ และโมเดลเลือกฟังคำแนะนำ

การเปลี่ยนแปลงข้อที่สามเป็นข้อที่จะทำให้บางคนหงุดหงิด ขออธิบายเหตุผลไว้ตรงนี้

Octofs ตรวจจับการใช้ shell ผิดวิธี — คือเวลาที่โมเดลเอื้อมไปหา cat, grep, find, ls, sed หรือ awk ทั้งที่มีเครื่องมือ MCP เฉพาะทางที่ทำงานนั้นได้ดีกว่า ก่อน 0.9.0 การตรวจจับนี้ปรับได้ผ่าน --hint-mode: จะเตือนแบบนุ่มนวล หรือจะปฏิเสธไปเลย และตั้งแต่ 0.8.1 ค่าเริ่มต้นคือแบบนุ่มนวล

คำเตือนแบบนุ่มนวลไม่ได้ผล คำเตือนที่ต่อท้ายผลลัพธ์ที่สำเร็จแล้วก็คือข้อเสนอแนะที่ต้องแข่งกับผลลัพธ์ที่โมเดลถืออยู่ในมือแล้ว และผลลัพธ์ชนะเสมอ เราเห็นเซสชันที่คำเตือนเดิมขึ้นถึงหกครั้ง แล้วโมเดลก็ยังรัน grep ต่อไป เพราะ grep คืน output ให้ และการเมินคำเตือนก็ไม่มีต้นทุนอะไรเลย

ใน 0.9.0 การใช้ shell ผิดวิธีเป็น hard error เสมอ และสวิตช์เลือกโหมดก็หายไปแล้ว การเรียกจะล้มเหลว ไม่มีอะไรถูกรัน และข้อความผิดพลาดจะระบุชื่อเครื่องมือที่ควรใช้พร้อมตัวอย่างที่ใช้ได้จริง:

Searching file text with this command is forbidden — use `view` with content= instead
(gitignore-aware, context lines, line numbers, works on remote hosts).

  Example:
    view path="src/main.rs" content="fulfill_input_requests"
    view path="src/" content="TODO" regex=true
    view path="ssh://user@host/dir" content="TODO"  # remote search — no `ssh grep` needed

นี่ไม่ใช่เรื่องความเป็นระเบียบ view ที่ใช้ content= คืน line ID ที่เครื่องมือแก้ไขรับได้ เคารพ .gitignore และทำงานกับพาธ ssh:// ได้อย่างโปร่งใส ส่วน output ดิบของ grep ให้เลขบรรทัดกับโมเดล — ซึ่งตามทุกอย่างที่เล่ามาข้างต้น ก็คือคำโกหกที่รอเวลาปะทุ — แล้วยังกลบมันจมหายไปใน node_modules อย่างเงียบ ๆ เครื่องมือเฉพาะทางมีประโยชน์กว่าในทุกด้าน คำถามเดียวที่เหลือจึงมีแค่ว่าจะปล่อยให้การเลือกใช้มันเป็นทางเลือกหรือไม่ ตอนนี้ไม่ใช่แล้ว

สิ่งที่ยังใช้ได้: ไปป์ไลน์ cargo build 2>&1 | grep error คือการแปลงสตรีม ไม่ใช่การอ่านไฟล์ และตัวตรวจจับจงใจไม่ตัดคำสั่งที่ | มันตัดที่ ;, &&, ||, การขึ้นบรรทัดใหม่, $( และ backtick เท่านั้น และตัดเฉพาะนอกเครื่องหมายคำพูด ssh host 'cd /path && ls' จึงไม่เกิด false positive กับคำสั่งฝั่งรีโมตที่ตัวตรวจจับไม่มีธุระด้วย คำนำหน้าที่เป็นตัวแปรสภาพแวดล้อมจะถูกข้ามไปเพื่อหาโปรแกรมตัวจริง และ /bin/grep จะถูกลดรูปเหลือ grep เพื่อไม่ให้การเติมพาธกลายเป็นช่องหลบเลี่ยง


สามอย่างนี้มีอะไรเหมือนกัน

มองทั้งสามพร้อมกันแล้วจะเห็นว่ามันคือการเปลี่ยนแปลงเดียว ที่ทำซ้ำสามครั้ง

อินเทอร์เฟซที่แท้จริงของ MCP server ไม่ใช่ schema ของเครื่องมือ แต่คือทุกสตริงที่มันส่งกลับไปให้โมเดล และสตริงเหล่านั้นส่วนใหญ่คือข้อความผิดพลาด สำหรับมนุษย์ ข้อความผิดพลาดคือการแจ้งเตือน คุณอ่านแล้วก็ไปแก้ด้วยมือ แต่สำหรับเอเจนต์ ข้อความผิดพลาดคือ prompt มันคืออินพุตทั้งหมดของการตัดสินใจครั้งถัดไป มาถึงโดยไม่มีบริบทอื่นใดติดมาด้วย และคุณภาพของสิ่งที่จะเกิดขึ้นต่อจากนั้นก็ถูกจำกัดด้วยสิ่งที่อยู่ในสตริงนั้น

ดังนั้น อย่าบอกว่า "ไฟล์เปลี่ยนไปแล้ว" แต่บอกว่ามันเปลี่ยนเป็นอะไรและเนื้อหาย้ายไปอยู่ไหน อย่าบอกว่า "พบ 3 รายการที่ตรงกัน" แต่ไล่รายการเหล่านั้นออกมาเป็นเป้าหมายที่การเรียกครั้งถัดไปหยิบไปใช้ได้เลย อย่าใบ้ว่าไม่ควรใช้ grep แต่จงล้มเหลวไปเลย แล้วยื่นคำสั่ง view ที่ใช้แทนกันได้แบบเป๊ะ ๆ ให้ ทุกข้อคือหมากเดียวกัน: ยอมจ่ายอักขระสักไม่กี่ร้อยตัวในข้อความผิดพลาด เพื่อประหยัดการสื่อสารไปกลับทั้งรอบและคอนเท็กซ์ที่ต้องเผาไปกับมัน

นี่คือสิ่งที่เราหมายถึงเวลาบอกว่าเรากำลังปรับเซิร์ฟเวอร์ให้เข้ากับโมเดล แทนที่จะเข้ากับระบบไฟล์ มันไม่ใช่ prompt engineering แต่คือการออกแบบอินเทอร์เฟซให้ผู้เรียกที่กู้สถานการณ์ด้วยการอ่าน — และอ่านเฉพาะสิ่งที่คุณยื่นให้มันเท่านั้น


อัปเกรด

# Homebrew
brew upgrade muvon/tap/octofs

# Cargo
cargo install octofs --version 0.9.0

ไบนารีสำเร็จรูปสำหรับ Linux, macOS และ Windows (x86_64 และ ARM64) อยู่ที่หน้า releases และตอนนี้ไปป์ไลน์ของรีลีสเผยแพร่ไปยัง npm ควบคู่กับ crates.io และ MCP registry แล้ว

มี breaking change หนึ่งข้อที่ควรรู้: ถ้าคุณตั้ง --line-mode หรือ --hint-mode ไว้ในคอนฟิกของ MCP client ให้ลบออก — แฟล็กทั้งสองหายไปแล้ว และไบนารีจะปฏิเสธมัน ไม่มีอะไรมาแทนที่ เพราะพฤติกรรมที่ปลอดภัยกลายเป็นพฤติกรรมเดียวที่มีอยู่ นอกจากนี้ไม่ต้องแก้คอนฟิกอะไรอีก

หลังอัปเกรด ความต่างจะปรากฏตั้งแต่การแก้ไขครั้งแรกที่ไปชนกับอย่างอื่น แทนที่จะได้ผลสำเร็จเงียบ ๆ บนบรรทัดที่ผิด คุณจะได้ข้อความผิดพลาดที่เอเจนต์ของคุณลงมือทำต่อได้ทันทีโดยไม่ต้องอ่านไฟล์ใหม่


Octofs เป็นโอเพนซอร์ส (Apache 2.0) ที่ github.com/Muvon/octofs ถ้าคุณอยากได้มุมมองด้านความปลอดภัย ว่าทำไมเอเจนต์ถึงควรได้เครื่องมือจัดการไฟล์ที่จำกัดขอบเขต แทนที่จะได้ shell ดิบ ๆ ไปเลย เรื่องนั้นอยู่ในบทความแยกต่างหาก ส่วนบทความนี้ว่าด้วยการทำให้แน่ใจว่าเครื่องมือจะแก้บรรทัดที่มันถูกเล็งไปจริง ๆ