Study guides / CCDV-F / Domain 5

Tools & MCPs · Lesson 3 of 3

5.3 - Tool Choice and Structured Error Responses

Control tool selection deliberately with tool_choice, and design tool errors the model can actually recover from.

tool_choice lets you constrain how a call proceeds, and the exam expects precise recall of all four modes and when each is the right one: {"type": "auto"} for normal open-ended agent behaviour where Claude decides whether and which tool to call (the default when tool_choice is omitted), {"type": "any"} when some tool call is required but the model should pick which one, {"type": "tool", "name": "..."} to force one specific named tool, and {"type": "none"} to suppress tool calls entirely for that turn. Every mode accepts an additional disable_parallel_tool_use: true flag, which caps the response to at most one tool call regardless of how many the model might otherwise have wanted to make in parallel.

Choosing the right mode

Forcing a specific tool ({"type": "tool", "name": "ask_clarifying_question"}) is common right after an ambiguous user turn where you want the model to ask a clarifying question rather than guess and call a real tool — you're not letting the model decide whether to ask, you're guaranteeing it does. any shows up when the entire point of a turn is delegation to some tool and a plain-text answer would be a bug (a routing step in a pipeline that must hand off to exactly one of several specialist tools). none is useful for a final summarisation turn where tools were relevant earlier in the conversation but you want a clean natural-language answer with no risk of another tool call. auto is correct for the overwhelming majority of ordinary agent turns — reaching for any or a forced tool as a default, rather than for a specific narrow reason, tends to produce unnecessary or premature tool calls.

Errors as structured, actionable results

When a tool call fails, the failure should come back as a tool_result the model can reason about — a clear error category and enough detail to decide what to do next — not a generic "something went wrong" string, and not a raw exception or stack trace. Set is_error: true on the tool_result content block so the model knows this result represents a failure rather than a legitimate (if oddly-shaped) success. A structured error — distinguishing, for example, invalid_input from not_found from permission_denied from transient_failure — lets the model choose correctly between retrying, asking the user for missing or corrected information, escalating to a human, or giving up and explaining the failure honestly. Each category implies a different, specific next action; collapsing them into one undifferentiated failure signal takes that choice away from the model.

Matching error categories to recovery behaviour

The categories aren't decorative — each one should map to a distinct, appropriate follow-up. invalid_input (a malformed order ID, a date outside a valid range) should lead the model to ask the user for corrected input, not to retry the same call unchanged. not_found (a record that legitimately doesn't exist) should lead the model to tell the user the thing doesn't exist, not to imply a system fault or keep searching. permission_denied should lead the model to explain the limitation, not to retry with different parameters as if the problem were the input. transient_failure (a timeout, a rate limit, a temporary upstream outage) is the one category where retrying — once, with a brief explanation — is actually the right move, precisely because it's the one category where the same call might succeed a moment later. Building this mapping into the error shape itself, rather than leaving the model to infer intent from a free-text message, is what makes the categorisation useful rather than cosmetic.

Common exam distractor

Returning a bare exception message or stack trace as the tool_result content is a trap answer — it's not actionable for the model, and it can leak implementation detail (internal file paths, database schema, library versions) that shouldn't reach the model or, downstream, the user. A related distractor: fixing a bad error message by retrying the call automatically several times regardless of error category. A not_found or invalid_input result won't change on retry — automatic retry is only appropriate for genuinely transient categories, and applying it universally just wastes calls while delaying the honest answer the user actually needs.

Parallel tool calls and partial failure

When Claude requests multiple tools in parallel and one fails while others succeed, each tool_result is reported independently — the failing call gets is_error: true with its own structured error, the succeeding calls return their normal results, and all of them go back in the same single user message alongside each other. Don't drop the failed one, and don't split the results across multiple messages — Claude needs to see the full batch outcome (which calls worked, which didn't, and why) in one turn to reason about a sensible combined next step, such as proceeding with the two successful lookups while asking the user to correct the input for the one that failed.

Exam traps

Practice question

A lookup tool fails because the requested record doesn't exist, and the application returns the raw database exception text as the tool_result. In the next turn, Claude tells the user "an internal error occurred," which is misleading - the record simply doesn't exist. What's the fix?

  • A Return a structured error like {"error": "not_found", "message": "No record with that ID"} instead of the raw exception. Correct

    A categorised, structured error gives the model an accurate signal to reason from - it can now correctly tell the user the record doesn't exist rather than implying a system fault.

  • B Retry the tool call automatically up to five times.

    A not_found result won't change on retry - this wastes calls without addressing the actual miscommunication to the user.

  • C Suppress the tool_result entirely and let the model respond without it.

    Omitting the result removes the model's only signal about what happened, which is worse than a poorly-worded one.

  • D Force tool_choice to none for all future calls to this tool.

    This disables the tool going forward rather than fixing how its errors are communicated - an overcorrection unrelated to the actual issue.

Build exercise: Design categorised error responses and control tool selection deliberately

Intermediate · 40 minutes

You'll practice:

  1. Take a tool from an earlier exercise (or define a new lookup tool) and add at least three distinct structured error outcomes - e.g. not_found, invalid_input, transient_failure - each returned as a JSON-shaped tool_result with is_error: true, then trigger each deliberately and observe how Claude's next turn differs.

    Seeing the model react differently to each category is the confirmation that the structure is actually doing useful work, not just adding ceremony.

    You should see: Different, appropriate follow-up behaviour per error category - e.g. asking for corrected input on invalid_input, telling the user the record doesn't exist on not_found, and suggesting a retry or apologising for a temporary issue on transient_failure.

    Hints
    1. What two fields does a tool_result content block need to signal a failure the model can act on?
    2. Return the tool_result content as a JSON string with an error category field and a human-readable message, and set is_error: True on the content block itself.
    3. def lookup_order(order_id):
          if order_id == "missing":
              return {"content": json.dumps({"error": "not_found", "message": "No order with that ID."}), "is_error": True}
          if order_id == "bad-format":
              return {"content": json.dumps({"error": "invalid_input", "message": "Order ID must be numeric."}), "is_error": True}
          if order_id == "timeout":
              return {"content": json.dumps({"error": "transient_failure", "message": "Order service timed out, try again shortly."}), "is_error": True}
          return {"content": json.dumps({"status": "shipped"}), "is_error": False}
  2. Run the same prompt through four requests that differ only in tool_choice: auto, any, a forced specific tool, and none. Compare whether a tool gets called, which one, and - for none - confirm no tool call is possible even if the prompt strongly implies one is needed.

    The exam tests precise recall of what each of the four tool_choice values guarantees - running them side by side on identical input is the fastest way to internalize the difference between 'the model decides' and 'you decide.'

    You should see: auto calls the tool only when the model judges it relevant; any always calls some tool; the forced tool mode always calls that exact tool even if it's a poor fit; none never produces a tool_use block, only text.

    Hints
    1. Which field of the response tells you definitively whether a tool was called, and which was?
    2. Loop over the four tool_choice dict shapes, send the identical prompt and tools list each time, and print response.stop_reason plus any tool_use block names for each.
    3. choices = [
          {"type": "auto"},
          {"type": "any"},
          {"type": "tool", "name": "lookup_order"},
          {"type": "none"},
      ]
      for tc in choices:
          resp = client.messages.create(
              model="claude-opus-5", max_tokens=1024, tools=tools, tool_choice=tc,
              messages=[{"role": "user", "content": "Thanks, that's all I needed."}],
          )
          print(tc, resp.stop_reason, [b.name for b in resp.content if b.type == "tool_use"])
  3. Send a prompt that causes Claude to request two tool calls in parallel, make one succeed and one fail (is_error: true), and return both tool_result blocks together in a single user message. Confirm Claude's next turn correctly incorporates the successful result while addressing the failed one.

    Partial failure in a parallel batch is a distinct exam scenario from a single tool's error handling - dropping the failed result or splitting the two into separate messages is the specific mistake this step is designed to catch.

    You should see: A single user message containing two tool_result blocks (one is_error: true, one not), and a following assistant turn that references both outcomes appropriately.

    Hints
    1. When two tool_use blocks appear in one response, how many messages should carry their results back?
    2. Collect both tool_result dicts into one list and send them as the content of a single {"role": "user", ...} message - never split them across two separate API calls.
    3. tool_results = [
          {"type": "tool_result", "tool_use_id": call_a.id, "content": "shipped", "is_error": False},
          {"type": "tool_result", "tool_use_id": call_b.id, "content": json.dumps({"error": "not_found"}), "is_error": True},
      ]
      messages.append({"role": "assistant", "content": response.content})
      messages.append({"role": "user", "content": tool_results})
  4. Add disable_parallel_tool_use: true to a tool_choice of any on a prompt that would otherwise plausibly trigger two simultaneous tool calls, and confirm Claude now returns at most one tool_use block per response.

    disable_parallel_tool_use is an easy-to-forget modifier on every tool_choice mode, and the exam tests whether you know it exists and what it constrains - it's not a separate tool_choice type of its own.

    You should see: Exactly one tool_use block in the response even when the prompt implies two independent lookups could both be relevant.

    Hints
    1. Is disable_parallel_tool_use its own tool_choice type, or a modifier on the existing types?
    2. Add "disable_parallel_tool_use": True as a sibling key inside the same tool_choice dict you already have, alongside "type".
    3. tool_choice = {"type": "any", "disable_parallel_tool_use": True}
      resp = client.messages.create(
          model="claude-opus-5", max_tokens=1024, tools=tools, tool_choice=tool_choice,
          messages=[{"role": "user", "content": "Look up order 123 and also order 456."}],
      )
      print(len([b for b in resp.content if b.type == "tool_use"]))  # expect 1
  5. Write a small retry wrapper around your tool executor that only retries automatically when the structured error category is transient_failure (with a short backoff and a cap), and passes every other category straight through to Claude unmodified on the first attempt.

    This operationalises the category-to-recovery mapping from the lesson content - the exam distinguishes a developer who categorises errors correctly from one who then still retries everything indiscriminately regardless of category.

    You should see: not_found and invalid_input results reach Claude immediately with no retry; transient_failure results retry up to a small cap before being surfaced, with the retry count visible in your logs.

    Hints
    1. Which single field of your structured error result should gate whether a retry happens at all?
    2. Wrap the tool call in a loop that inspects the parsed error's category field; only loop again (with a short sleep) when category == 'transient_failure' and a retry cap hasn't been hit, otherwise return immediately.
    3. def call_with_selective_retry(fn, *args, max_retries=2):
          for attempt in range(max_retries + 1):
              result = fn(*args)
              if not result.get("is_error"):
                  return result
              error = json.loads(result["content"]).get("error")
              if error != "transient_failure" or attempt == max_retries:
                  return result
              time.sleep(1.5 ** attempt)
          return result

Sources