DEV Community

Finley Zhou
Finley Zhou

Posted on

A Free Model Endpoint Proposed a Build Cleanup. My C++ Executor Required a Dry Run Before Touching State.

A model endpoint can return well-formed JSON, a whitelisted tool name, and a command that is still wrong for the current state of a repository. The safer design starts from a two-phase executor: the model proposes, but a small C++ gate only mutates state after a dry run and invariant checks pass.

Background

This case study walks through a small maintenance agent for a C++ repository. The agent receives a natural-language request such as "refresh the compile database" or "clean the generated build directory", then asks a free model endpoint to map that request to exactly one tool call. I used MonkeyCode's free model access for the proposal step and its free server option to host the C++ gate while I iterated on the checks. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The first prototype validated only JSON shape. That was the failure this design targets: the model returned a valid build_clean proposal while another job was still writing into the build directory. The JSON was parseable, the tool was allowed, and the confidence field looked reassuring. The problem was not the model's grammar; it was that the executor had no way to test the proposal against the current state before committing to a side effect.

Project goal

The goal was not to run arbitrary shell from a language model. It was to keep a narrow set of repository maintenance actions under a C++ controller. The agent had exactly four tools:

Tool Effect
compdb_refresh Regenerates compile_commands.json
format_dry_run Runs clang-format without writing files
diff_check Checks working tree noise without mutating
build_clean Removes generated build files

Only build_clean had a destructive effect, so it became the test case for the gate.

Implementation walkthrough

1. Parse the proposal into a C++ struct

The endpoint response is JSON. The C++ side converts it into a strongly typed value so later checks cannot accidentally use an unvalidated field:

struct Proposal {
  std::string tool;
  std::vector<std::string> arguments;
  double confidence = 0.0;
  std::string expected_effect;
};
Enter fullscreen mode Exit fullscreen mode

A nlohmann/json parser fills the struct and immediately fails on missing or ambiguous fields:

auto body = nlohmann::json::parse(response);
Proposal p{
  body.value("tool", ""),
  body.value("arguments", std::vector<std::string>{}),
  body.value("confidence", 0.0),
  body.value("expected_effect", "")
};
Enter fullscreen mode Exit fullscreen mode

2. Reject anything outside the whitelist

The gate uses a hardcoded tool set. The model can suggest ideas, but it cannot expand the command surface at runtime:

std::set<std::string> allowed_tools = {
  "compdb_refresh", "format_dry_run", "diff_check", "build_clean"
};

bool allowed(const Proposal& p) {
  return allowed_tools.contains(p.tool)
      && p.expected_effect == effect_for(p.tool)
      && !p.arguments.empty();
}
Enter fullscreen mode Exit fullscreen mode

The confidence field is logged but never used for authorization in this design. A high confidence value does not change whether the proposal is safe to execute.

3. Create a tool-specific dry run

Each tool needs a non-destructive probe. The probe should exercise the same target selection without mutating state. For this repository the mapping was:

Tool Dry-run command
build_clean cmake --build build --target help
compdb_refresh cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -N
format_dry_run already read-only
diff_check already read-only

The code converts a proposal to its dry-run argument vector:

std::vector<std::string> dry_run_args(const Proposal& p) {
  if (p.tool == "build_clean") {
    return {"--build", "build", "--target", "help"};
  }
  if (p.tool == "compdb_refresh") {
    return {"-S", ".", "-B", "build",
            "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", "-N"};
  }
  return p.arguments; // read-only tools keep their args
}
Enter fullscreen mode Exit fullscreen mode

4. Check exit code and state before the real call

A zero exit code from the dry run is necessary but not enough. The gate also checks whether the proposed side effect is valid for the current state. For build_clean, that means the build directory must not have been modified in the last 30 seconds:

bool dry_run_passed(const Proposal& p) {
  auto args = dry_run_args(p);
  int code = run_tool(p.tool, args, /*capture=*/true);
  if (code != 0) return false;

  if (p.tool == "build_clean") {
    auto last = std::filesystem::last_write_time("build");
    auto now = std::filesystem::file_time_type::clock::now();
    if (last > now - std::chrono::seconds(30)) {
      return false; // another job is likely active
    }
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

5. Execute only after every check passes

The real call runs only when the proposal is allowed and the dry run passes. For a mutating tool, the postcondition verifies that the build directory timestamp changed:

bool execute_plan(const Proposal& p) {
  if (!allowed(p) || !dry_run_passed(p)) {
    log_rejection(p);
    return false;
  }

  auto before = std::filesystem::last_write_time("build");
  int code = run_tool(p.tool, p.arguments, /*capture=*/false);
  auto after = std::filesystem::last_write_time("build");

  return code == 0 && after != before;
}
Enter fullscreen mode Exit fullscreen mode

Read-only tools use the same structure, but their postcondition is exit code only plus a bounded output size; they do not require a timestamp change.

Fixture results

The repository includes a small fixture that exercises the decision path without requiring a real build system. It sends these proposals through the gate:

  1. A valid diff_check proposal: accepted.
  2. An unknown tool name: rejected.
  3. A build_clean proposal against a freshly modified build directory: rejected.
  4. A build_clean proposal with the wrong argument count: rejected.
  5. A build_clean proposal against an older build directory: accepted only after the dry-run command exits zero.

These fixture results are not a production benchmark. They show where the gate blocks an invalid plan, not how often a particular model endpoint returns one.

Limitations

  • The dry-run probe is not a sandbox. Some tools do not have a perfect non-destructive equivalent.
  • There is a time-of-check to time-of-use gap between the dry run and the real call. A process can modify the build directory in that window.
  • The free model endpoint is a proposal source, not an enforcement point. Availability, latency, or a bad response can stop the agent without changing the gate's safety properties.
  • Scaling the whitelist too far makes the gate hard to review. Each new tool should have its own dry-run mapping and postcondition.

Who should not use this pattern

Do not use a proposed-tool gate for arbitrary shell execution, privileged operations, or anything that cannot be rolled back. If the tool surface is open-ended, a static whitelist is not enough. The same applies to multi-tenant CI systems where one tenant's proposal could affect another tenant's state.

Lessons learned

The smallest change that mattered was not prompt-tuning. It was splitting the model's proposal from the executor's decision. A valid JSON object is only a request; it becomes a command after the gate validates shape, whitelist, dry run, exit code, and state checks.

If you are already using MonkeyCode's free model access for proposal generation, spend the first integration day on rejected plans. That is where the gate earns its keep, and the C++ side stays small enough to review line by line.

Top comments (0)