#872·monty

REPL type-check context desyncs from the session namespace: a raising snippet loses its bindings, and imports never carry

Author: pawel-kotowskiCreated Sep 16, 2026Updated Sep 16, 2026

On a type_check=True REPL session, the accumulated type-check context drifts out of sync with the interpreter's namespace, so feed_start refuses code that would have run correctly. Two independent effects, both traceable to TypeCheckState.committed_stubs (crates/monty-python/src/repl.rs, added in #319). Verified on 0.0.23 (current PyPI release).

Documented contract, pydantic_monty/_monty.pyi (checkout):

type_check: Type-check each fed snippet before executing it; each successfully executed snippet is appended to the accumulated context used for type-checking subsequent snippets.

A. A snippet that raises loses names the interpreter keeps

A snippet ending in an uncaught exception is never committed, so nothing it bound joins the accumulated context — including names bound before the raising line, which the interpreter has already committed and still holds.

A1 'a = 1; raise ValueError("boom")'  -> RUNTIME ERROR: ValueError: boom
A2 'print(a)'                         -> TYPE CHECK REFUSED: Name `a` used when not defined
A3 'print(a)', skip_type_check=True   -> prints 1          ← the value is right there
A4 'c = 1' + a CAUGHT exception       -> OK (commits)
A5 'print(c)'                         -> OK

The two ledgers commit at different granularities: the interpreter per statement, the checker per snippet. After any uncaught error the session is effectively unusable for an agent that assumed its variables persisted — it must re-derive everything, while the worker still holds the values.

B. Import-bound names never carry (the accumulated context is a stub file)

Every binding kind carries — variables, def, class, lambdas, instances — except names bound by an import, even from a fully successful snippet:

import json                        -> next call: Name `json` used when not defined
import json as json                -> next call: OK
import json as jj                  -> next call: Name `jj` used when not defined
from typing import Any             -> next call: Name `Any` used when not defined
from typing import Any as Any      -> next call: OK
from json import dumps             -> next call: Name `dumps` used when not defined
from json import dumps as dumps    -> next call: OK

That split is exactly PEP 484's no-implicit-re-export rule for stub files: an imported name is exported from a stub only when written with a redundant alias. Two more observations confirm the accumulated snippets are being checked as a .pyi rather than as a module:

  • binding the identical value by assignment carries fine — alias = json then alias.dumps({}) type-checks and runs, so it is not a limitation of representing module or function values;
  • a def whose body uses json survives the carry-over and can be called in a later snippet, because stub-file function bodies are not checked.

So a REPL user writing ordinary Python gets stub-file export semantics applied to their own session, which is surprising in a way the docstring doesn't hint at.

Repro

python
import asyncio
from importlib.metadata import version

import pydantic_monty as m


async def feed(s, code, skip=False):
    try:
        st = await s.feed_start(
            code,
            print_callback=lambda k, t: print("      out:", t.rstrip()),
            skip_type_check=skip,
        )
        return f"OK ({type(st).__name__})"
    except m.MontyTypingError as e:
        return "TYPE CHECK REFUSED: " + str(e).split("\n")[0]
    except m.MontyRuntimeError as e:
        return "RUNTIME ERROR: " + str(e).split("\n")[0]


async def main():
    print(f"pydantic-monty {version('pydantic-monty')}\n")
    async with m.AsyncMonty() as pool:

        def checkout():
            return pool.checkout(
                script_name="agent.py", type_check=True, type_check_stubs=""
            )

        print("A. a snippet that raises loses names the interpreter still holds")
        async with checkout() as s:
            print("   A1 ->", await feed(s, "a = 1\nraise ValueError('boom')"))
            print("   A2 ->", await feed(s, "print(a)"))
            print("   A3 ->", await feed(s, "print(a)", skip=True))
        async with checkout() as s:
            print("   A4 ->", await feed(s, "c = 1\ntry:\n    raise ValueError()\nexcept ValueError:\n    pass"))
            print("   A5 ->", await feed(s, "print(c)"))

        print("\nB. import-bound names never carry; the redundant-alias form does")
        cases = [
            ("import json",                     "print(json.dumps({}))"),
            ("import json as json",             "print(json.dumps({}))"),
            ("import json as jj",               "print(jj.dumps({}))"),
            ("from typing import Any",          "v: Any = 1"),
            ("from typing import Any as Any",   "v: Any = 1"),
            ("from json import dumps",          "print(dumps({}))"),
            ("from json import dumps as dumps", "print(dumps({}))"),
        ]
        for setup, use in cases:
            async with checkout() as s:
                await feed(s, setup)
                print(f"   {setup:33} -> {await feed(s, use)}")

        print("\n   assignment carries the identical value:")
        async with checkout() as s:
            print("   ->", await feed(s, "import json\nalias = json\nfn = json.dumps"))
            print("   ->", await feed(s, "print(alias.dumps({}))"))
            print("   ->", await feed(s, "print(fn({}))"))

        print("\n   stub semantics: function bodies are not re-checked")
        async with checkout() as s:
            print("   ->", await feed(s, "import json\ndef f() -> str:\n    return json.dumps({})"))
            print("   ->", await feed(s, "print(f())"))


asyncio.run(main())

Expected

  • A: a snippet's committed bindings should reach the checker even when the snippet later raised — the interpreter has already kept them. Committing the executed prefix, or reconciling the context against the post-feed namespace, would both do it.
  • B: the accumulated context should be checked with module semantics, or import-bound names should be re-exported into it, so import json in one snippet leaves json usable in the next.

The two are separable; A is the one that bites hardest, since after any error the whole session's state becomes unreachable.

Why it matters

We run a multi-call agent loop on a held session, and the docstring's persistence guarantee is what the model is told. One production run hit A: an external call returned a shape the model mis-handled, the snippet raised after several successful assignments, and every later call was refused with unresolved-reference on names the worker still held. The model read those as its own mistakes and re-probed the state — including re-issuing calls that write. skip_type_check=True is not an acceptable workaround for us, since the check is what keeps a malformed write from reaching a save function.