DEV Community

Cover image for The variables my subagents referenced and nobody declared
Akash Hadagali Persetti
Akash Hadagali Persetti

Posted on

The variables my subagents referenced and nobody declared

terraform validate failed on nearly every multi-file run with the same error class:

Error: Reference to undeclared input variable

  on iam.tf line 14, in resource "aws_iam_policy" "app_access":
  14:     Environment = var.environment

An input variable with the name "environment" has not been declared.
This variable can be declared with a variable "environment" {} block.
Enter fullscreen mode Exit fullscreen mode

Same error on var.project_name. Same error on var.region. Same error on var.account_id. Four names, referenced across storage.tf and iam.tf and database.tf, declared nowhere.

I had not written any of those references. An LLM had, because I told it to.

The pipeline, briefly

TerraformAgent is a 6-node LangGraph pipeline that turns a natural-language infrastructure request into validated Terraform: orchestrator → researcher → domain_subagents → aggregator → reviewer → evaluator, with a conditional edge from evaluator back to domain_subagents for targeted retries. The orchestrator picks domains deterministically, keyword-matching the task and mapping services through a static table, with security always included. Each domain subagent generates its own files in parallel, the aggregator merges them into one directory, and the evaluator runs real terraform fmt and terraform validate against a provider cache baked into the container image.

The 6-node pipeline. The dashed edge is the evaluator's targeted retry back into domain_subagents.

The line that did it

Every domain subagent prompt carried this requirements block:

    Requirements:
    - Use proper HCL2 syntax
    - Add descriptive comments for each resource
    - Follow AWS best practices (tags, encryption, least privilege)
    - No hardcoded secrets  use variables or data sources
    - Reference other domains' resources by the conventional names given in the Research Context
Enter fullscreen mode Exit fullscreen mode

Line three is the problem. "Follow AWS best practices (tags, encryption, least privilege)."

The subagents complied. Tagging a resource properly means an Environment tag and a Project tag, and any competent Terraform author reaches for var.environment and var.project_name to fill them. IAM policies need an account ID and a region in the ARN, so var.account_id and var.region showed up too. The model produced exactly the code a human would have written.

A human would also have written the variable blocks. The model could not, because of where it sits in the pipeline.

Why the architecture hid it

A single-file generator never hits this. It declares the variable it uses, in the file it is writing, in the same pass.

Generation here is split across parallel subagents that each own one domain's files:

DOMAIN_FILES = {
    "storage": ["storage.tf"],
    "database": ["database.tf"],
    "security": ["iam.tf"],
    ...
}
Enter fullscreen mode Exit fullscreen mode

The storage subagent owns storage.tf. The security subagent owns iam.tf. A variable "environment" block belongs to neither. It belongs to the merged directory, and no subagent knows the merged directory exists.

So every subagent's output was individually valid HCL. The aggregated directory was not. Splitting generation across agents created a shared namespace that no agent owned, and the failure lived entirely in that gap.

Shared files come from the orchestrator. Domain subagents only produce their own files.

Fix, part one: something above the subagents declares it

There was already a precedent I had not recognized as a category. versions.tf had this exact shape: a file no domain owns, generated once by the orchestrator, merged in by the aggregator.

def render_versions_tf() -> str:
    """Render the shared versions.tf content from the known provider list.

    Generated once by the Orchestrator and merged in by the Aggregator, so
    no domain subagent needs to emit its own required_providers block.
    """
Enter fullscreen mode Exit fullscreen mode

Variables belong in the same bucket. So render_variables_tf(), following that pattern:

def render_variables_tf() -> str:
    """Render the shared variables.tf content every domain subagent is told
    to use for tagging (var.environment, var.project_name) plus the
    aws_caller_identity/aws_region data sources for account ID and region.

    Domain subagent prompts are instructed to follow AWS tagging best
    practices, which reliably produces references to these exact names —
    but no domain subagent declares them. Generated once by the
    Orchestrator and merged in by the Aggregator (same pattern as
    versions.tf), so `terraform validate` always finds them declared.
    """
    return """variable "environment" {
  description = "Deployment environment name (e.g. dev, staging, prod)"
  type        = string
  default     = "dev"
}

variable "project_name" {
  description = "Project name used for resource naming and tagging"
  type        = string
  default     = "terraform-agent"
}

data "aws_caller_identity" "current" {}

data "aws_region" "current" {}
"""
Enter fullscreen mode Exit fullscreen mode

Both variables carry defaults. That is not cosmetic. The evaluator runs terraform validate with no tfvars file, so a variable without a default would trade one failure for another.

The orchestrator generates it alongside versions.tf:

def orchestrator_node(state: dict) -> dict:
    task = state.get("task", "")
    services = detect_aws_services(task)
    active_domains = sorted({service_to_domain(s) for s in services} | {"security"})
    return {
        "active_domains": active_domains,
        "shared_versions_file": render_versions_tf(),
        "shared_variables_file": render_variables_tf(),
        "status": AgentStatus.RESEARCHING,
        "current_node": "researcher",
    }
Enter fullscreen mode Exit fullscreen mode

And the aggregator picks it up. Before:

merged = {
    "versions.tf": state.get("shared_versions_file", ""),
}
for domain_files in state.get("domain_outputs", {}).values():
    merged.update(domain_files)
Enter fullscreen mode Exit fullscreen mode

After:

merged = {
    "versions.tf": state.get("shared_versions_file", ""),
    "variables.tf": state.get("shared_variables_file", ""),
}
for domain_files in state.get("domain_outputs", {}).values():
    merged.update(domain_files)
Enter fullscreen mode Exit fullscreen mode

Fix, part two: two of the four symbols stopped existing

My instinct was to declare all four names. That instinct was half wrong.

Account ID and region are not deployment inputs. They are facts about the credentials and provider config the run already has. Terraform will tell you both without being asked:

data "aws_caller_identity" "current" {}

data "aws_region" "current" {}
Enter fullscreen mode Exit fullscreen mode

So var.account_id and var.region did not get declarations. They stopped being referenced. Two of the four undeclared symbols went away by removing the need for the symbol rather than satisfying it.

This is the part I keep coming back to. When something references a name that does not exist, declaring the name is the obvious move and often the worse one. Half this bug was solved by shrinking the namespace instead of filling it.

Fix, part three: the prompt reads the declarations instead of describing them

The first version of this fix hand-wrote the scope into the prompt string:

    A shared variables.tf is already provided (do not redeclare it). It gives you:
    - var.environment (string, default "dev")
    - var.project_name (string, default "terraform-agent")
    - data.aws_caller_identity.current.account_id  use this, NOT var.account_id
    - data.aws_region.current.name  use this, NOT var.region
    Do not reference any other var.* name  anything else will fail terraform validate
    because it will not be declared anywhere.
Enter fullscreen mode Exit fullscreen mode

That worked and it was wrong. Two places now knew the list of shared symbols: render_variables_tf(), which declares them, and this string, which describes them. Nothing kept the two in agreement. Add a variable to the renderer and forget the prompt, and subagents never use it. Remove one from the renderer and leave it in the prompt, and you are back to undeclared references and the identical validate failure.

So the prompt stopped describing the scope and started parsing it. parse_shared_scope() pulls the declared names out of the rendered variables.tf:

_VARIABLE_BLOCK_RE = re.compile(r'variable\s+"([^"]+)"\s*\{([^}]*)\}', re.DOTALL)
_DATA_BLOCK_RE = re.compile(r'data\s+"([^"]+)"\s+"([^"]+)"\s*\{')
_DEFAULT_ATTR_RE = re.compile(r'default\s*=\s*"?([^"\n]*)"?')


def parse_shared_scope(variables_tf: str) -> List[Dict[str, str]]:
    if not variables_tf:
        return []

    entries: List[Dict[str, str]] = []

    for name, body in _VARIABLE_BLOCK_RE.findall(variables_tf):
        default_match = _DEFAULT_ATTR_RE.search(body)
        default_value = default_match.group(1).strip() if default_match else ""
        entries.append({"kind": "variable", "name": name, "default": default_value})

    for data_type, data_name in _DATA_BLOCK_RE.findall(variables_tf):
        entries.append({"kind": "data", "type": data_type, "name": data_name})

    return entries
Enter fullscreen mode Exit fullscreen mode

And render_domain_scope_note() builds the prompt text from that parse:

def render_domain_scope_note(variables_tf: str) -> str:
    scope = parse_shared_scope(variables_tf)
    if not scope:
        return ""

    lines = [
        "A shared variables.tf is already provided (do not redeclare it). "
        "It gives you:"
    ]
    for entry in scope:
        if entry["kind"] == "variable":
            default_note = f' (default "{entry["default"]}")' if entry["default"] else ""
            lines.append(f'    - var.{entry["name"]}{default_note}')
        else:
            attr = _DATA_SOURCE_PRIMARY_ATTRIBUTE.get(entry["type"])
            ref = f'data.{entry["type"]}.{entry["name"]}' + (f".{attr}" if attr else "")
            lines.append(f"    - {ref}")

    declared_var_names = ", ".join(
        f'var.{e["name"]}' for e in scope if e["kind"] == "variable"
    )
    lines.append(
        f"    Do not reference any var.* name other than {declared_var_names or '(none declared)'} "
        "— anything else will fail terraform validate because it will not be declared anywhere."
    )
    return "\n".join(lines)
Enter fullscreen mode Exit fullscreen mode

The subagent prompt is now a single interpolation:

def _domain_prompt(domain: str, state: dict, feedback: str) -> str:
    files = ", ".join(DOMAIN_FILES[domain])
    feedback_str = f"\n[RETRY] Previous Reviewer/Evaluator feedback to fix: {feedback}" if feedback else ""
    scope_note = render_domain_scope_note(state.get("shared_variables_file", ""))
    return f"""You are an expert DevOps Coder responsible for the '{domain}' domain of this infrastructure request.
    ...
    {scope_note}
Enter fullscreen mode Exit fullscreen mode

One thing does not survive the parse. Which attribute you actually want off a data source is provider schema knowledge, not something the HCL declaration states, so that stays a lookup:

_DATA_SOURCE_PRIMARY_ATTRIBUTE: Dict[str, str] = {
    "aws_caller_identity": "account_id",
    "aws_region": "name",
}
Enter fullscreen mode Exit fullscreen mode

A data source with no entry here still gets listed in the scope note, just without the attribute hint. That is the one piece of the contract still maintained by hand, and it degrades quietly rather than producing invalid code.

Stating the consequence matters too. "Do not reference other variables" gets partial compliance. "It will not be declared anywhere and validate will fail" gets better compliance, because the model can reason about the outcome.

The second bug, same evaluator run

The reviewer flagged scope creep in the same pass. Subagents were emitting IAM roles, custom policies, and KMS keys nobody had asked for. A request for a DynamoDB table came back with a customer-managed KMS key and a key policy attached to it.

Same source line. "Least privilege" and "encryption" read as instructions to build IAM resources and encryption resources, not as constraints on the resources actually requested. The subagent has no notion of scope beyond what the prompt gives it, and the prompt gave it a mandate.

The requirements block now carries a boundary:

    - Build ONLY the resources this request needs. Do not add IAM roles, policies,
      KMS keys, or other supporting resources unless the request requires them or
      the specific resource you're creating cannot function without them (e.g. an
      EC2 instance needing a security group). When AWS provides a sensible default
      (e.g. DynamoDB's AWS-owned encryption key), use the default instead of
      creating a custom resource for it.
Enter fullscreen mode Exit fullscreen mode

One vague instruction, two unrelated failure modes. Undeclared references and unrequested resources have nothing in common except the line that caused them.

Verification

I reproduced the error before fixing it. A DynamoDB table plus an IAM policy referencing all four names, written to a real directory, run through real terraform init -backend=false and terraform validate. Fails without variables.tf. Passes with it.

Tests at three levels. Renderer content in test_tools.py:

class TestRenderVariablesTf:
    def test_declares_environment_and_project_name(self):
        content = render_variables_tf()
        assert 'variable "environment"' in content
        assert 'variable "project_name"' in content

    def test_variables_have_defaults(self):
        content = render_variables_tf()
        # Every declared variable must have a default so subagent-generated
        # code that references it validates with no tfvars supplied.
        assert content.count('variable "') == content.count("default")
Enter fullscreen mode Exit fullscreen mode

The drift regression test, which is the one that made part three worth building. Append a variable to the rendered output and assert the prompt mentions it, with no hand-written change anywhere:

    def test_scope_note_derived_from_same_parse_as_variables_tf(self):
        """If a variable is added to render_variables_tf(), the scope note
        must mention it automatically with no hand-written change needed —
        this is the actual regression test for the prompt/renderer drift bug."""
        synthetic_variables_tf = render_variables_tf() + '\nvariable "extra_flag" {\n  default = "x"\n}\n'
        note = render_domain_scope_note(synthetic_variables_tf)
        assert "var.extra_flag" in note
Enter fullscreen mode Exit fullscreen mode

And the wiring, in test_nodes.py, asserting the prompt reflects state and does not carry a stale scope block when state is empty:

class TestDomainPrompt:
    def test_scope_note_reflects_state_variables_file(self):
        state = {"task": "x", "shared_variables_file": render_variables_tf()}
        prompt = _domain_prompt("storage", state, feedback="")
        assert "var.environment" in prompt
        assert "data.aws_caller_identity.current.account_id" in prompt

    def test_missing_variables_file_produces_no_stale_scope_note(self):
        state = {"task": "x"}
        prompt = _domain_prompt("storage", state, feedback="")
        assert "var.environment" not in prompt
Enter fullscreen mode Exit fullscreen mode

That second one matters more than it looks. If shared_variables_file is missing from state, the scope note is empty rather than advertising symbols that were never merged in.

The evaluator caught this, not me

Worth stating plainly. Nobody read the generated code and noticed a missing declaration. The evaluator ran terraform validate and the run failed with a file, a line number, and a symbol name.

A generator without a real validation gate ships confidently wrong output. The HCL was well-formed, commented, and idiomatic. It looked correct in review. An LLM judge asked "is this good Terraform?" would have said yes, because it is good Terraform, apart from being invalid. The reason I can describe this failure to the exact line is that something mechanical ran the actual tool.

When you split code generation across parallel agents, any symbol that crosses an agent boundary has to be produced by something that sits above all of them, and described to them by reading what it produced.

Top comments (0)