ארבעת המצבים שצריך להבין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 ריק.
Noteאצלנו end_turn שמגיע בלי שום verification של ה-deliverable מסומן כחשוד. ה-runtime בודק אם הסוכן עבר את ה-checklist (todo_write, fetch_image, set_colors, write_file ×3+, deploy) לפני שמכבדים את ה-end_turn. בלי זה, סוכנים מצהירים על סיום מוקדם מדי.

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].text can 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.
NoteIn our system, an end_turn that arrives without verifying the deliverable is flagged as suspect. The runtime checks the tool checklist (todo_write, fetch_image, set_colors, write_file ×3+, deploy) before honoring end_turn. Without that gate, agents declare completion too early.

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 חייב לקיים:

  1. tool_result לכל tool_use_id — לא לדלג על אף אחד. גם אם נכשל, חייב tool_result עם is_error: true.
  2. אסור לדבר ל-user ביניהם — מ-tool_use עד ה-tool_results, ה-user לא רואה כלום. תוצאת ביניים נשלחת רק אחרי שהמודל מקבל הזדמנות נוספת.
  3. 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:

  1. A tool_result for every tool_use_id — never skip one. Even on failure, return a tool_result with is_error: true.
  2. 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.
  3. 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' });
  }
}
Warningאל תציג ל-user את הטקסט החתוך כתשובה סופית. גם אם הוא נראה שלם, סוף משפט יכול להיות אמצע פסקה. תמיד תחזור עם continuation או תעלה את ה-cap ותריץ שוב.

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_tokens and 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' });
  }
}
WarningNever show the user truncated text as a final answer. Even when it ends with a period, that period may be mid-paragraph. Always continue or raise the cap and re-run.

stop_sequence — הכלי הכי לא מנוצלstop_sequence — the most underused tool

stop_sequence מקבל array של מחרוזות. כשהמודל פולט אחת מהן, הפלט נעצר בדיוק לפניה. זה כלי שלא מקבל מספיק שימוש. שני use-cases אצלנו:

  1. פורמט מובטח. הוספת stop על \n\n---END--- וכוונון ה-prompt לכלול אותו אחרי ה-output. זה נותן fail-fast אם המודל המציא תוכן נוסף.
  2. חיתוך משחקי 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:

  1. 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.
  2. 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 };
  }
}
Winאצלנו, מאז שעברנו ל-switch מפורש כזה, יש פחות מ-0.3% jobs שעוצרים על 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 };
  }
}
WinSince switching to an exhaustive switch, fewer than 0.3% of our jobs stop on 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.