حلقه درس قبل یک عیب آزاردهنده داشت: چون مدل در Print Mode اجرا میشود، تا پایان هر دور هیچچیز نمیبینی. مدل دارد کار میکند و تو به یک ترمینال ساکت زل زدهای. در این درس Observability حلقه را درست میکنیم — بدون اینکه معماری Ralph را عوض کنیم.
چرا پلاگین رسمی، Ralph واقعی نیست؟
Anthropic یک پلاگین رسمی دارد که Skill حلقه Ralph را به CLI تعاملی اضافه میکند؛ از Marketplace نصب میشود و مثلاً میگویی «این پرامپت را ۱۰ بار اجرا کن». چون داخل شل تعاملی هستی، کار مدل را زنده میبینی و حتی درخواستهای Permission را دستی تأیید یا رد میکنی. ظاهرش عالی است — اما یک تفاوت بنیادی دارد:
راهحل: خروجی جریانی JSON
Print Mode یک Flag دارد که پیامها را همان لحظه که تولید میشوند بیرون میدهد:
claude -p "hello world" --output-format stream-json --verboseنکته: `stream-json` بدون `--verbose` اجرا نمیشود و خطا میگیرد. حالا پیامها لحظهای میآیند، ولی خامِ JSONاند و برای چشم انسان قابل خواندن نیستند. پس باید خودمان Parse کنیم.
اول داده تست بساز، بعد پرامپت بده
قبل از اینکه از ایجنت بخواهیم Parser بنویسد، چند پیام نمونه واقعی تولید میکنیم تا در پرامپت به آن ارجاع بدهیم:
claude -p "read a file, do a web fetch, and run a command" \
--output-format stream-json --verbose > example.txtحالا `example.txt` نمونه واقعی همه نوع پیام را دارد: شروع Session، متن، Tool Call و نتیجه آن. سپس با یک پرامپت ساده، تبدیل را به خود ایجنت میسپاریم:
Look at ralph.sh. Convert it to a Node.js script (ralph.js) that runs
claude with --output-format stream-json --verbose and parses the JSON
messages as they stream in. See example.txt for sample messages.ralph.js — همان حلقه، با چشم باز
اسکریپت جدید پرحرفتر است چون کلی JSON Parsing دارد، ولی قلبش همان For Loop قبلی است. الگوی واقعی از روی `example.txt` ساخته شد:
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const iterations = Number(process.argv[2] ?? 10);
const prompt = fs.readFileSync("prompt.md", "utf8");
function runClaude(args, onMsg) {
return new Promise((resolve) => {
const child = spawn("claude", args, { stdio: ["ignore", "pipe", "inherit"] });
let buf = "";
child.stdout.on("data", (chunk) => {
buf += chunk;
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
try { onMsg(JSON.parse(line)); } catch { /* keep buffering */ }
}
});
child.on("close", () => resolve());
});
}
(async () => {
for (let i = 1; i <= iterations; i++) {
console.log(`=== Iteration ${i} of ${iterations} ===`);
let done = false;
await runClaude(
["-p", prompt, "--output-format", "stream-json", "--verbose"],
(msg) => {
if (msg.type === "assistant") process.stdout.write(msg.message ?? "");
if (msg.type === "tool_use") console.log(`→ ${msg.name}`, msg.input);
if (msg.type === "result" && String(msg.result ?? "").includes("ALL TASKS COMPLETE")) {
done = true;
}
},
);
if (done) {
console.log(`All tasks completed after ${i} iterations.`);
break;
}
}
})();معماری Ralph تغییری نکرد: هنوز Print Mode، هنوز Context تازه در هر دور، هنوز Magic String. فقط حالا با `node ralph.js` میبینی مدل همین الان دارد چه فایلی میخواند و چه دستوری میزند — و حلقه «زنده» به نظر میرسد.
به زبان ساده
Print Mode تا پایان دور ساکت است. با stream-json پیامها زنده میآیند؛ یک اسکریپت Node همان حلقه را نگه میدارد و JSON را برای چشم انسان ترجمه میکند.
مثال واقعی
اول `example.txt` را با یک اجرای واقعی ساختند (خواندن فایل، Web Fetch، یک دستور)، بعد همان فایل را به مدل دادند تا ralph.js را بنویسد — حدس زدن فرمت JSON لازم نشد.