I built an agentic assistant for battery engineering. A physics-based digital twin runs the actual electrochemical simulation, an LLM orchestrates and explains. Partway through, I ran into a failure that had nothing to do with the physics or the prompt wording. It was about how many tool calls I was asking a 7B model to plan in one go, and it changed how I designed every tool after that.
What the system does
The principle behind the whole project: physics does the maths, the model does the language.
A PyBaMM digital twin simulates an LG M50 21700 cell using the open Chen2020 parameter set — a real electrochemical model, not a language model's best guess at a discharge curve. That twin is wrapped as six tools and exposed through FastMCP over stdio, so it's not hard-wired into one agent; any MCP client can drive it. On the other side, a LangGraph ReAct agent, backed by a local Ollama model, reads a plain-English question, decides which tool to call, and turns the physics result into an explanation.
The six tools:
| Tool | What it simulates |
|---|---|
cell_info\ |
Static description of the modelled cell |
simulate_discharge\ |
Constant-current discharge (runtime, delivered capacity/energy) |
simulate_cccv_charge\ |
CC-CV charge (charge time, energy in) |
compare_charging_strategies\ |
Runs and ranks multiple charge rates in one call |
compare_discharge_rates\ |
Runs and compares multiple discharge rates in one call |
simulate_degradation\ |
Capacity fade over N cycles via SEI growth |
The model never touches a number. It picks a tool, reads the structured result, and narrates it. That split only holds up, though, if the agent reliably calls the right tool — and that's where a smaller local model started to show its limits.
Where it broke
Single-tool questions worked from early on. Ask "how long does it run at 1C?" and the agent calls simulate_discharge(c_rate=1.0)\, reads the result, answers. Reliable every time.
The break came with comparisons. Ask "2C versus 0.5C" and the natural ReAct pattern is to call simulate_discharge\ twice, once per rate, then reason over both results. That's standard multi-step tool use, and it's the kind of thing larger models handle without issue. Running on qwen2.5:7b\, though, multi-rate discharge comparisons dropped a rate: the model would not reliably chain two simulate_discharge\ calls, so the answer came back based on only one of the two rates, stated as if it covered the comparison the user actually asked for.
That's a quiet failure. It doesn't throw an error. It produces a confident, well-formatted answer that's simply incomplete, the exact failure mode the physics-grounding was supposed to prevent, just relocated one layer up, into tool orchestration instead of number generation.
It wasn't the only rough edge. Two others, for context: llama3.1:8b\ at one point printed tool calls as literal JSON text instead of actually invoking them, so no simulation ran at all, which is what pushed the model choice to qwen2.5:7b\. And separately, the agent would sometimes speculate about why a number looked a certain way, labelling a delivered capacity above the 5.0 Ah nominal rating as "inefficiency" or "over-discharge," when a capacity above nominal at gentle rates is just normal cell behaviour, not something the simulation had reported as a problem.
Why the multi-call problem happens
It's tempting to read "the model dropped a tool call" as "get a bigger model" and stop there. That's true as far as it goes, but it skips the more useful question: what am I actually asking a 7B model to plan?
A two-call comparison isn't one decision, it's several in sequence — call tool A, hold its result in context, decide to call tool B with different arguments, hold that result too, then reconcile both before answering. Every one of those is a place a small model can drop a step, and on a 7B model run locally, without the depth of training data that gives larger models a stronger prior on "plan, then execute, then synthesize," that margin is thin.
The fix: collapse the plan into the tool, not the model's head
Instead of asking the agent to orchestrate two simulate_discharge\ calls and reconcile them itself, I added compare_discharge_rates\, mirroring the compare_charging_strategies\ tool that already existed for the charging side. It takes a list of rates, runs every simulation internally in Python, and returns runtime and capacity for all of them in one structured result.
The agent's job shrinks to one decision: recognize this is a comparison question, call the one tool built for it, narrate the result. No multi-step plan to hold in working memory, no partial result to silently drop. The system prompt makes the preference explicit too — it tells the model to prefer compare_discharge_rates\ and compare_charging_strategies\ over calling the single-simulation tools repeatedly, so the model isn't left to rediscover the right pattern on its own each time.
This is a small code change with a bigger implication for how I think about tool design for agents. The instinct, especially coming from examples built around large frontier models, is to expose small, composable primitives and let the agent chain them however a question requires. That's the right call when the model doing the chaining is strong. When it isn't, composability becomes a liability, because every extra hop is another place a plan can quietly fall apart. The tool boundary isn't just an API decision — it's a decision about how much sequential reasoning you're willing to hand to the model versus how much you do deterministically in code before the model ever sees it.
Where the line still sits
This didn't make the problem disappear everywhere, and I'd rather say that directly than imply otherwise. Chaining different tools in sequence — a charge comparison followed by a degradation run, with the agent reasoning across both — can still drop a step on a 7B model. The system prompt and the single-call comparison tools solve the specific case that came up most (comparing the same kind of simulation across several rates), not general multi-tool planning. For that, the honest options are a bigger model like qwen2.5:14b\, or an explicit planning step ahead of execution, and I've left that as a known limitation rather than pretending the fix generalizes further than it does.
The broader takeaway
None of this is specific to batteries or to qwen2.5:7b\. Any project running a smaller model locally — for cost, latency, privacy, or air-gap reasons — is going to hit some version of this. The generalizable move is: test the multi-step cases you expect people to actually ask for, watch for the quiet failures rather than just the crashes, and design tool boundaries around what the model actually does reliably, not around what looks cleanest in an architecture diagram. It's a less glamorous kind of engineering than swapping in a bigger model and moving on. It's also the kind that matters when the actual constraint is a system that runs entirely on hardware someone already owns, with no API bill and no dependency on a provider staying online.
If you're running a small model locally, where's the line you've found between letting the agent chain tool calls itself versus just collapsing the sequence into one call in code? Curious whether others have hit this with 7B-class models specifically, or whether it's more about the stdio/MCP setup than the model size.
The full project — the PyBaMM twin, the MCP server, the LangGraph agent, and the rest of the issues hit along the way — is open source: github.com/BinuShefieldShifani/Batterytwin-mcp. Portfolio and other projects at binushefieldshifani.github.io.``

Top comments (1)
The "call simulate_discharge twice and reason over both" failure is such a clean example of something people blame on the model when it's really a tool-shape problem. A 7B model planning two independent calls and then holding both results in working memory to compare is asking it to be an orchestrator; giving it
compare_discharge_ratesas a single tool moves that orchestration into deterministic code where it belongs. We ran into the same thing and the rule that stuck was: if two tool calls are almost always followed by a comparison, that comparison is a tool, not a reasoning step.The quiet part you named — a confident, well-formatted, incomplete answer — is the one that actually scares me, because it survives every smoke test. Did you end up adding anything on the read side to catch it, like having the tool return the set of rates it actually simulated so the agent (or a cheap validator) can notice one is missing before it narrates? Curious whether you found the fix was mostly tool granularity or also constraining the ReAct loop itself.