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 ดิบ ๆ ไปเลย เรื่องนั้นอยู่ในบทความแยกต่างหาก ส่วนบทความนี้ว่าด้วยการทำให้แน่ใจว่าเครื่องมือจะแก้บรรทัดที่มันถูกเล็งไปจริง ๆ



