ארבעת המצבים שצריך להביןThe four states you must understand
בכל קריאה ל-messages.create אתה מקבל בחזרה stop_reason. זה לא טקסט לדוח — זה מה שאומר לך מה לעשות אחר כך. ארבעה ערכים מכסים 99%+ מהמקרים:
- end_turn — המודל סיים מרצונו. ה-content הסופי הוא התשובה.
- tool_use — המודל ביקש להריץ כלי. אסור לחזור למשתמש לפני שהרצת אותו.
- max_tokens — הפלט נחתך. הטקסט שיש לך הוא חלקי.
- stop_sequence — הפלט פגע ב-stop string שהגדרת.
הטעות הראשונה שעשינו: טיפלנו בכל המצבים שאינם end_turn כשגיאה. זה גרם לכל קריאת tool להיכשל. הטעות השנייה: התעלמנו מ-max_tokens והחזרנו ל-user את הטקסט החתוך. זה הניע באג שבו ה-builder "סיים" באמצע פונקציה.
הטיפול הנכון הוא switch מפורש על כל הערכים, עם טיפול מיוחד לכל אחד.
Every messages.create response carries a stop_reason. It is not a logging string — it tells you what to do next. Four values cover 99%+ of cases:
- end_turn — the model finished on its own. The content is the answer.
- tool_use — the model requested a tool. You must not return to the user before running it.
- max_tokens — output was truncated. The text you have is partial.
- stop_sequence — output hit a stop string you registered.
Our first mistake was treating anything other than end_turn as an error. Tool calls all failed. The second was ignoring max_tokens and shipping the truncated text to the user — that produced the bug where the builder "finished" mid-function.
The correct handler is an explicit switch on every value, each with its own treatment.
end_turn — הסיום הטבעיend_turn — the natural finish
זה ה-happy path. המודל החליט שהוא סיים. ה-content מכיל את התשובה. הלולאה צריכה לצאת.
if (response.stop_reason === 'end_turn') {
const text = response.content
.filter(b => b.type === 'text')
.map(b => b.text)
.join('');
return { ok: true, text, usage: response.usage };
}שתי טעויות שכיחות גם פה:
- לא לחבר את כל ה-text blocks. Claude לפעמים מחזיר כמה blocks (במיוחד אחרי thinking או tool_use). אם תיקח רק את
content[0].text, יכול להיות שתאבד חצי תשובה. - לא לבדוק שיש text בכלל. אם המודל החזיר רק tool_use ואז end_turn (נדיר אבל קורה), ה-text הוא ריק. תחזיר משהו שמשמעותי, לא string ריק.
This is the happy path. The model decided it was done. The content holds the answer. The loop should exit.
if (response.stop_reason === 'end_turn') {
const text = response.content
.filter(b => b.type === 'text')
.map(b => b.text)
.join('');
return { ok: true, text, usage: response.usage };
}Two common mistakes even here:
- Not joining all text blocks. Claude sometimes returns several (especially after thinking or after tool_use). Reading only
content[0].textcan drop half the answer. - Not checking text exists at all. If the model returned only tool_use and then end_turn (rare but real), text is empty. Return something meaningful, not an empty string.
tool_use — בקשה, לא תשובהtool_use — a request, not an answer
זה המצב שכמעט כל סוכן יראה הכי הרבה. tool_use אומר: "אל תחזיר ל-user. הרץ את הכלים שביקשתי, החזר את התוצאות, וקרא לי שוב". טעויות פה הן הסיבה לרוב הלולאות התקועות.
if (response.stop_reason === 'tool_use') {
const toolBlocks = response.content.filter(b => b.type === 'tool_use');
const results = await Promise.all(toolBlocks.map(async block => {
try {
const out = await runTool(block.name, block.input, ctx);
return {
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(out).slice(0, 32000),
};
} catch (err) {
return {
type: 'tool_result',
tool_use_id: block.id,
content: `error: ${err.message}`,
is_error: true,
};
}
}));
messages.push({ role: 'assistant', content: response.content });
messages.push({ role: 'user', content: results });
// לולאה ממשיכה
}שלושה דברים שכל מטפל ב-tool_use חייב לקיים:
- tool_result לכל tool_use_id — לא לדלג על אף אחד. גם אם נכשל, חייב tool_result עם
is_error: true. - אסור לדבר ל-user ביניהם — מ-tool_use עד ה-tool_results, ה-user לא רואה כלום. תוצאת ביניים נשלחת רק אחרי שהמודל מקבל הזדמנות נוספת.
- id-ים בדיוק כפי שהתקבלו — לא ליצור id חדש, לא לחתוך, לא להוסיף prefix.
This is the state most agents will see most often. tool_use means: "do not return to the user. Run what I asked for, send the results back, and call me again". Mishandling this is the source of most stuck loops.
if (response.stop_reason === 'tool_use') {
const toolBlocks = response.content.filter(b => b.type === 'tool_use');
const results = await Promise.all(toolBlocks.map(async block => {
try {
const out = await runTool(block.name, block.input, ctx);
return {
type: 'tool_result',
tool_use_id: block.id,
content: JSON.stringify(out).slice(0, 32000),
};
} catch (err) {
return {
type: 'tool_result',
tool_use_id: block.id,
content: `error: ${err.message}`,
is_error: true,
};
}
}));
messages.push({ role: 'assistant', content: response.content });
messages.push({ role: 'user', content: results });
// loop continues
}Three things every tool_use handler must satisfy:
- A tool_result for every tool_use_id — never skip one. Even on failure, return a tool_result with
is_error: true. - Do not speak to the user in between — from tool_use until the tool_results, the user sees nothing. Intermediate output is shown only after the model gets another turn.
- IDs verbatim — never mint, trim, or prefix the id. Echo what the API gave you.
max_tokens — הפלט נחתךmax_tokens — the output was truncated
זה המצב הכי טריקי, כי הוא יכול לקרות באמצע tool_use וגם באמצע טקסט. שלוש אפשרויות שצריך להבחין ביניהן:
- נחתך באמצע טקסט — יש לך טקסט חלקי. אופציות: לבקש מהמודל "continue", או להעלות את
max_tokensולקרוא שוב. - נחתך באמצע tool_use — ה-input של ה-tool הוא JSON שבור. אסור להריץ אותו. תחזור למודל עם הודעת מערכת.
- נחתך אחרי tool_use אחד אבל לפני tool_use שני — ה-tool הראשון תקין, אבל המודל אולי תכנן עוד. תרוץ את הראשון, תחזור.
if (response.stop_reason === 'max_tokens') {
const last = response.content.at(-1);
if (last?.type === 'tool_use') {
const isComplete = isValidJson(last.input);
if (!isComplete) {
return continueWith({
message: 'previous response was truncated mid tool_use; reissue the call with valid JSON',
});
}
}
if (last?.type === 'text') {
return continueWith({ message: 'previous response was truncated; continue from where you left off' });
}
}This is the trickiest case because it can fire mid tool_use as well as mid text. Three sub-cases to distinguish:
- Truncated mid text — you have partial text. Options: ask the model to continue, or raise
max_tokensand re-run. - Truncated mid tool_use — the tool's input JSON is malformed. You must not execute it. Loop back to the model with a system message.
- Truncated after one tool_use but before another — the first tool's input is valid, but the model may have planned more. Run the first, then loop back.
if (response.stop_reason === 'max_tokens') {
const last = response.content.at(-1);
if (last?.type === 'tool_use') {
const isComplete = isValidJson(last.input);
if (!isComplete) {
return continueWith({
message: 'previous response was truncated mid tool_use; reissue the call with valid JSON',
});
}
}
if (last?.type === 'text') {
return continueWith({ message: 'previous response was truncated; continue from where you left off' });
}
}stop_sequence — הכלי הכי לא מנוצלstop_sequence — the most underused tool
stop_sequence מקבל array של מחרוזות. כשהמודל פולט אחת מהן, הפלט נעצר בדיוק לפניה. זה כלי שלא מקבל מספיק שימוש. שני use-cases אצלנו:
- פורמט מובטח. הוספת stop על
\n\n---END---וכוונון ה-prompt לכלול אותו אחרי ה-output. זה נותן fail-fast אם המודל המציא תוכן נוסף. - חיתוך משחקי roleplay. אם הסוכן מתחיל לדמיין את תשובת המשתמש ("User: ..."), stop על
\nUser:חוסם את זה.
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
system: SYSTEM_PROMPT,
messages,
stop_sequences: ['\nUser:', '\n---END---'],
max_tokens: 4096,
});
if (response.stop_reason === 'stop_sequence') {
// המחרוזת שעצרה — response.stop_sequence
// אצלנו: אם זה '\n---END---' אנחנו מתייחסים ל-output כסיום מלא,
// אם זה '\nUser:' אנחנו זורקים את ה-output ומבקשים מחדש.
return handleStopSequence(response);
}ה-API מחזיר response.stop_sequence שמראה איזו מחרוזת בדיוק עצרה את הפלט. השתמש בזה כדי לבחור התנהגות שונה לכל stop.
stop_sequence takes an array of strings. When the model emits any of them, output stops just before it. This is a tool that does not get enough use. Two cases we lean on:
- Format guarantees. Add a stop on
\n\n---END---and coach the prompt to emit it after the answer. Fail-fast if the model invents extra content. - Cutting roleplay drift. If the agent starts hallucinating user replies ("User: ..."), a stop on
\nUser:kills it.
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
system: SYSTEM_PROMPT,
messages,
stop_sequences: ['\nUser:', '\n---END---'],
max_tokens: 4096,
});
if (response.stop_reason === 'stop_sequence') {
// the matched string is in response.stop_sequence
// ours: '\n---END---' means a clean finish,
// '\nUser:' means discard the output and re-prompt.
return handleStopSequence(response);
}The API returns response.stop_sequence with the exact string that fired. Branch your handler on which one matched, not just on the fact that it stopped.
טבלת ההחלטה המלאהThe full decision table
זו הטבלה שאנחנו מדפיסים ומדביקים מעל המקלדת של כל מי שכותב agent runtime:
function handleStop(response, ctx) {
switch (response.stop_reason) {
case 'end_turn':
return { type: 'finish', text: extractText(response) };
case 'tool_use':
return { type: 'continue', work: 'run_tools', blocks: extractToolUses(response) };
case 'max_tokens': {
const last = response.content.at(-1);
if (last?.type === 'tool_use' && !isValidJson(last.input)) {
return { type: 'continue', work: 'reissue_tool_use' };
}
return { type: 'continue', work: 'continue_text' };
}
case 'stop_sequence': {
if (response.stop_sequence === '\n---END---') {
return { type: 'finish', text: extractText(response) };
}
return { type: 'continue', work: 'reprompt', reason: 'roleplay_drift' };
}
case 'pause_turn': // streaming, ארוך — להחזיר את ה-content ולהמשיך
return { type: 'continue', work: 'resume' };
case 'refusal': // המודל סירב — לא להריץ tools, לחזור ל-user
return { type: 'finish', text: 'I can\'t help with that request.', refusal: true };
default:
ctx.log.warn('unexpected stop_reason', { reason: response.stop_reason });
return { type: 'finish', text: extractText(response), unexpected: true };
}
}unexpected stop_reason. לפני זה, חצי מבאגי ה-builder היו מצב חדש שלא טופל.סיכום מעשי: stop_reason הוא לא detail של ה-API. הוא ה-control flow של הסוכן. תכתוב switch מלא על כל הערכים, גם אלה שלא ראית עוד, והוסף ברירת מחדל שמלוגגת ולא קורסת.
Here is the table we print and tape above the desk of anyone writing an agent runtime:
function handleStop(response, ctx) {
switch (response.stop_reason) {
case 'end_turn':
return { type: 'finish', text: extractText(response) };
case 'tool_use':
return { type: 'continue', work: 'run_tools', blocks: extractToolUses(response) };
case 'max_tokens': {
const last = response.content.at(-1);
if (last?.type === 'tool_use' && !isValidJson(last.input)) {
return { type: 'continue', work: 'reissue_tool_use' };
}
return { type: 'continue', work: 'continue_text' };
}
case 'stop_sequence': {
if (response.stop_sequence === '\n---END---') {
return { type: 'finish', text: extractText(response) };
}
return { type: 'continue', work: 'reprompt', reason: 'roleplay_drift' };
}
case 'pause_turn': // long streaming — return content, resume
return { type: 'continue', work: 'resume' };
case 'refusal': // model refused — do not run tools, return to user
return { type: 'finish', text: 'I can\'t help with that request.', refusal: true };
default:
ctx.log.warn('unexpected stop_reason', { reason: response.stop_reason });
return { type: 'finish', text: extractText(response), unexpected: true };
}
}unexpected stop_reason. Before, half of the builder bugs were new states we had not handled.Practical takeaway: stop_reason is not an API detail. It is the agent's control flow. Write the full switch, including states you have not seen yet, and add a default that logs and does not crash.