REPL type-check context desyncs from the session namespace: a raising snippet loses its bindings, and imports never carry
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)' -> OKThe 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: OKThat 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 = jsonthenalias.dumps({})type-checks and runs, so it is not a limitation of representing module or function values; - a
defwhose body usesjsonsurvives 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
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 jsonin one snippet leavesjsonusable 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.
Source: pydantic/monty