DEV Community

Taylor Lin
Taylor Lin

Posted on

Assumption Traps in Agentic Patches: A Glossary, a Decision Tree, and Four Worked Leaves

The agent was asked to add request throttling to a small Flask service. It returned a 40-line patch, two new files, and a unit test that stayed green. The test never opened a socket.

The patch imported flask_limiter from a module that was not in requirements.txt. It read REDIS_URL from the environment. That name did not appear in .env.example, Compose files, or any deploy script. The run looked cheap. The review debt was not.

This walkthrough is a constructed incident, not a production postmortem. The failure mode is stable enough to need a vocabulary and a gate. Prompts that say “do not invent APIs” do not classify the invention after it lands.

Why this is not a token problem

Free-model agent loops fail in a specific way. They fill missing facts with plausible ones. The plausible ones compile in the model’s head. They do not compile in your tree.

Context windows, backend choice, and diff routing are separate problems. This note covers only unstated premises inside a proposed patch. If a change is grounded in the repo, the tree below says apply. If it is not, the tree says stop.

Glossary

Use these terms as labels on a trace, not as slogans in a system prompt.

  1. Assumption. An unstated premise the model needs for the patch to be correct. Example: “this app already talks to Redis.”
  2. Grounding. Evidence in the working tree, lockfile, tests, or checked-in config that supports the premise.
  3. Fabrication. A file, import, symbol, flag, or protocol that does not exist in the tree and is not introduced with its own install and wiring.
  4. Env-bound claim. A premise that can only be true in a running environment: secrets, hostnames, cluster names, live ports.
  5. Repo-checkable claim. A premise that a local command can confirm or reject: import, grep, test, type check.
  6. Trace. The ordered record of tool calls, model text, and the unified diff. Without a trace, the gate has nothing to classify.
  7. Gate. A pre-apply check that maps a patch onto one leaf. It does not replace a human read.
  8. Replay. Re-running the same prompt and tools after the gate rejects a leaf, with the missing evidence pasted in.

If a word in that list cannot be pointed at a line in the diff or the repo, drop it. Vague labels hide fabrications.

Decision tree

Walk the questions in order. Stop at the first leaf. Do not score “mostly grounded.” Mixed patches inherit the worst leaf.

  1. Does every new import, file path, and public symbol already exist in the tree or lockfile, or is it added with matching install and wiring in the same diff? If no → Leaf D, Fabricated. Reject. Replay with the file tree and the lockfile excerpt. If yes → continue.
  2. Does the change require runtime values that are not represented in-repo (secrets, URLs, region names, live service bindings)? If yes → Leaf C, Env-bound. Stop and ask a human or a config oracle. Do not invent .env values. If no → continue.
  3. Can the remaining claims be confirmed with a local command (import, test, grep, type checker)? If yes → Leaf B, Repo-checkable. Run the command. Apply only on pass. If no, and every premise has a path in the tree → Leaf A, Grounded. Apply, then run the existing suite.

The tree is conservative on purpose. A false reject costs one replay. A false apply costs a broken deploy.

patch
  ├─ new symbol/path missing from tree+lockfile? --yes--> D Fabricated (reject)
  │                                              --no-->
  ├─ needs runtime/secret not in repo?           --yes--> C Env-bound (ask)
  │                                              --no-->
  └─ local command can prove the claim?          --yes--> B Repo-checkable (verify)
                                                 --no-->  A Grounded (apply)
Enter fullscreen mode Exit fullscreen mode

Leaf D — Fabricated: worked example

Input (proposed hunk, labeled example):

+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
+
+limiter = Limiter(app, key_func=get_remote_address, storage_uri=os.environ["REDIS_URL"])
Enter fullscreen mode Exit fullscreen mode

flask_limiter is absent from requirements.txt and pyproject.toml. No install step is in the diff. The gate does not debate whether the library is a good idea. It classifies the hunk as Fabricated.

Replay packet to paste back:

REJECT Leaf D
- import flask_limiter is not in requirements.txt or pyproject.toml
- no install/wiring hunk in this diff
Allowed next move: add the dependency and the wiring in one patch, or implement throttling with stdlib + existing middleware only.
Enter fullscreen mode Exit fullscreen mode

A reject without the missing evidence produces the same fabrication on the next turn. The packet is the artifact, not the word “no.”

Leaf C — Env-bound: worked example

Keep the limiter idea. Drop the missing import. The model now writes:

storage_uri = os.environ["REDIS_URL"]  # assumed present in prod
Enter fullscreen mode Exit fullscreen mode

Search the tree:

rg -n "REDIS_URL" --glob '.env*' --glob '*.yml' --glob '*.yaml' --glob '*.toml'
rg -n "os.environ|getenv" -g '*.py'
Enter fullscreen mode Exit fullscreen mode

If both are empty for REDIS_URL, the claim is env-bound. The name might be correct in some other company’s stack. It is not evidence here.

Ask, do not fill:

STOP Leaf C
Need: whether rate-limit state is process-local, Redis, or skipped in this environment.
Need: the actual config key if Redis is already used (grep cache/session clients).
Do not invent REDIS_URL, passwords, or hostnames.
Enter fullscreen mode Exit fullscreen mode

Env-bound is not Fabricated. The symbol might exist at runtime. The gate still blocks apply until a human or a checked-in config answers.

Leaf B — Repo-checkable: worked example

The tree already vendors a tiny token bucket in app/limits.py. The agent inlines a call from the request hook. Imports resolve. No new env vars.

That is not yet Grounded. It is Repo-checkable. The claim “this hook runs once per request” is a test, not a feeling.

python -c "from app.limits import TokenBucket; TokenBucket(rate=5, burst=5)"
pytest tests/test_limits.py tests/test_request_hook.py -q
Enter fullscreen mode Exit fullscreen mode

Pass → apply. Fail → reject with the command output in the replay packet. Do not let the model “fix” a failing test by deleting it. That path is Fabricated behavior even when the files existed.

Leaf A — Grounded: worked example

Two handlers already copy-paste the same 12-line retry loop. The agent extracts retry_call() into app/retry.py and updates both call sites. No new dependency. No new env. Existing tests cover the handlers.

pytest tests/test_handlers.py -q
git apply /tmp/agent.patch
pytest -q
Enter fullscreen mode Exit fullscreen mode

Leaf A is the only auto-apply candidate, and only after the suite that already existed. New behavior still needs a new test; that case drops back to Leaf B.

Artifact: a local gate you can run

The script below is a proposal. It is a heuristic classifier, not a proof of correctness. Save it as assumption_gate.py at the repo root. It reads a unified diff from stdin and prints one leaf.

#!/usr/bin/env python3
"""Classify a unified diff into Fabricated / Env-bound / Repo-checkable / Grounded."""
from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

IMPORT_RE = re.compile(r"^\+\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))")
ENV_RE = re.compile(r"(?:os\.environ\[([\"'])([A-Z0-9_]+)\1\]|getenv\((['\"])([A-Z0-9_]+)\3)")
NEW_FILE_RE = re.compile(r"^\+\+\+ b/(.+)$")

STDLIB = {
    "os", "sys", "re", "json", "pathlib", "typing", "functools",
    "itertools", "collections", "dataclasses", "datetime", "logging",
}

def lockfile_modules(root: Path) -> set[str]:
    names: set[str] = set()
    req = root / "requirements.txt"
    if req.exists():
        for line in req.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            names.add(re.split(r"[<>=\\[]", line, maxsplit=1)[0].replace("-", "_").lower())
    pyproject = root / "pyproject.toml"
    if pyproject.exists():
        for m in re.finditer(r"['\"]([A-Za-z0-9_-]+)(?:[<>=]|['\"])", pyproject.read_text(encoding="utf-8")):
            names.add(m.group(1).replace("-", "_").lower())
    return names

def env_keys(root: Path) -> set[str]:
    keys: set[str] = set()
    for path in root.glob(".env*"):
        for line in path.read_text(encoding="utf-8").splitlines():
            if line.strip() and not line.startswith("#") and "=" in line:
                keys.add(line.split("=", 1)[0].strip())
    return keys

def top_module(dotted: str) -> str:
    return dotted.split(".", 1)[0].replace("-", "_").lower()

def classify(diff: str, root: Path) -> str:
    locked = lockfile_modules(root)
    known_env = env_keys(root)
    fabricated: list[str] = []
    env_bound: list[str] = []
    new_paths: list[str] = []

    for line in diff.splitlines():
        m = NEW_FILE_RE.match(line)
        if m and m.group(1) != "/dev/null":
            rel = m.group(1)
            if not (root / rel).exists() and "requirements" not in rel and "pyproject" not in rel:
                # new file is fine if the diff also adds it; track for humans
                new_paths.append(rel)
        im = IMPORT_RE.match(line)
        if im:
            mod = top_module(im.group(1) or im.group(2))
            if mod not in STDLIB and mod not in locked and not (root / mod).exists():
                fabricated.append(mod)
        for em in ENV_RE.finditer(line):
            key = em.group(2) or em.group(4)
            if key not in known_env:
                env_bound.append(key)

    if fabricated:
        return "D Fabricated imports=" + ",".join(sorted(set(fabricated)))
    if env_bound:
        return "C Env-bound keys=" + ",".join(sorted(set(env_bound)))
    if new_paths:
        return "B Repo-checkable new_files=" + ",".join(new_paths)
    return "A Grounded"

def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--root", default=".")
    args = p.parse_args()
    diff = sys.stdin.read()
    print(classify(diff, Path(args.root).resolve()))
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Labeled test plan (unexecuted here; run it on your tree):

chmod +x assumption_gate.py

# Leaf D fixture: missing import
printf '%s\n' '--- a/app.py' '+++ b/app.py' '@@ -1 +1,2 @@' ' from flask import Flask' '+from flask_limiter import Limiter' \
  | python assumption_gate.py --root .

# Leaf C fixture: unknown env key
printf '%s\n' '--- a/app.py' '+++ b/app.py' '@@ -1 +1,2 @@' ' import os' '+uri = os.environ["REDIS_URL"]' \
  | python assumption_gate.py --root .

# Empty hunk should print A Grounded or B if you only add files
Enter fullscreen mode Exit fullscreen mode

Extend the script with language-specific lockfiles before you trust it on a non-Python repo. The leaf names stay the same. The extractors do not.

Where a free model loop fits

Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free-model access and a free server option matter only as a cheap place to iterate the gate: produce a patch, classify the leaf, reject fabrications, replay with evidence. The tree does not depend on that product. Strip the name and the workflow is unchanged.

Do not put secrets on a shared free server. Env-bound answers belong in a local config oracle or a human, not in a prompt that will be logged.

Limitations, and who should skip this

The gate does not catch semantic lies. A grounded retry helper can still retry the wrong exception. An existing test file can still assert the wrong behavior. Heuristics on imports and env keys miss dynamic imports, vendored names that do not match PyPI, and configuration loaded from a database.

Skip this approach if any of the following is true:

  1. You apply model patches without reading them. The gate is a filter, not a reviewer.
  2. You need a vendor SLA or a guaranteed model quality bar. Free-model loops are for classification practice, not for unattended production edits.
  3. Your diffs are not Python, and you have not replaced the extractors.
  4. The repo has no lockfile, no tests, and no .env.example. Every leaf collapses to Env-bound or Fabricated, and the tree becomes noise.
  5. You would rather spend the replay budget on generating more code than on proving the last hunk.

Cheap generation shifts cost onto classification. The glossary is there so a reject is specific. The tree is there so a mixed patch cannot hide a fabricated import behind a grounded rename. The leaves are there so a replay has a packet, not a vibe.

If you run the gate, post one Fabricated or Env-bound leaf you caught—missing import, invented path, or unknown env key. The misses are the useful sample.

Top comments (0)