Skip to content
echogeco
The harness, in code · Build it

JSON tool-call parser

Implement the parser that turns a prose reply into calls to dispatch and errors to send back, and never executes a brace from the prose.

codeeasyExercise · 4 min · 68 of 77
On this page

A model with no native tool-call field writes its calls as text, and the harness has to find them. Every reply passes through the parser first, so a bug in it is either a crashed run or a call nobody asked for.

The situation

A team runs its support agent on a self-hosted model with no structured tool-call field, so the system prompt asks for each call as a fenced json block holding name and args. The first parser was a regular expression for the outermost pair of braces, handed to json.loads. Over a week of 14,000 replies it failed three ways. 120 replies (0.9%) carried a trailing comma or a single-quoted key; json.loads raised, and the run died with no trace. 40 replies were cut off at the output cap in the middle of a block. One reply described a call it had decided against, quoted {"name": "delete_draft"} in a sentence, and the harness executed it.

The harness wants one function that turns a reply into the calls to dispatch and the errors to send back as results, so that a bad block costs one round trip instead of a run, as the tool-call loop requires of every failure.

The question

Implement the parser so that every malformed block becomes an error entry naming its block, and nothing outside a json fence is ever parsed.

def solve(text):
    """text: one model reply. A tool call is a fenced block whose opening line
    is three backticks and json, in any case, holding one object
    {"name": str, "args": {...}}; "args" may be omitted. Fences with any
    other tag are prose.
    Returns one entry per json block, in order:
      ("call", name, args)     a well-formed call
      ("error", n, reason)     the n-th json block (from 1) was malformed;
                               reason is "invalid json", "not an object",
                               "missing name", "args not an object" or
                               "unclosed block"
    Prose, and braces in prose, are never parsed. Never raises."""
FENCE = "`" * 3
def block(body, tag="json"):
    return f"{FENCE}{tag}\n{body}\n{FENCE}"

solve("Let me check the queue.\n" + block('{"name": "search_tickets", "args": {"q": "refund delay"}}'))
    -> [("call", "search_tickets", {"q": "refund delay"})]
solve(block('{"name": "get_order", "args": {"id": "A-17"}}') + "\nthen\n"
      + block('{"name": "get_shipment", "args": {"id": "A-17"},}'))
    -> [("call", "get_order", {"id": "A-17"}), ("error", 2, "invalid json")]
solve("Drafting the reply.\n" + FENCE + 'json\n{"name": "send_reply", "args": {"body": "Hi Priya')
    -> [("error", 1, "unclosed block")]
solve(block("print({'a': 1})", "python") + "\nNow the tests.\n" + block('{"name": "run_tests"}'))
    -> [("call", "run_tests", {})]

The block fixture builds each fence rather than typing three backticks, so the examples survive being quoted inside a page.

A strong answer · Rubric · Where answers go wrong · Solution

Write your answer first, then open