Guys, I have a problem. I’m building an orchestration layer, and the agent—for example—asks the user for a keyword, extracts the keyword, but it also calls the Excel tool and writes the data to Excel. The user didn’t ask for this in Excel, so what should I do with this layer?
hi @akin
could you share your graph source code? Especially your tools definition and docstrings.
I can’t share the source code because there are too many files and code blocks.
The structure we are proposing is the MarketingMix-adapted version of this:
user_message
-> turn_contract / intent_router
-> context_builder
-> agent
-> action_policy
-> tool_approval / tool_execution
-> done_checker
-> END or agent
This is the correct flow aligned with LangGraph’s logic.
LangChain/LangGraph does not provide a mandatory “use this exact JSON contract” format out of the box. However, when you combine the concepts explained in the documentation — state, conditional edges, persistence, memory management, tool-call review, human-in-the-loop, and agent step management — the correct production pattern that emerges is this.
The mistake we are currently making is:
model.bindTools(allTools)
and then expecting the model to stop at the right point by itself.
That means we are not really using the control power that LangGraph provides.
For our system, the correct approach should be:
turn_contract = classify(user_message, state)
visible_tools = selectTools(turn_contract)
model.bindTools(visible_tools)
tool_calls = actionPolicy(tool_calls, turn_contract)
done = doneChecker(state, result)
Would this be the right approach for our architecture?
Yeah, this is the right direction. The bindTools(allTools) and hoping the model stops on its own is exactly the thing to move away from, and what you sketched is basicaly the idiomatic LangGraph way of doing it.
Quick mapping of your stages to actual primitves so you dont reinvent them:
- turn_contract / intent_router → a structured output classifer (withStructuredOutput / with_structured_output). One thing: this already gives you the “mandatory JSON contract” you said is missing. The schema is validated, and on native providers you can go strict (method: “jsonSchema”). What LangGraph deliberatly doesnt ship is a prescribed pipeline template, and thats by design - its the low level layer, so defining your own contract is the intended way, not a workaround.
- selectTools(contract) + bindTools(visible_tools) → dynamic tool selection via wrap_model_call. This is the real fix for the Excel problem, a tool thats not bound simply cant be called.
- action_policy → wrap_tool_call.
- tool_approval → HumanInTheLoopMiddleware (needs a checkpointer).
- done_checker → a conditional edge, or a before_model hook returning jumpTo “end”.
Three things that will bite you in prod that arent in the pseudocode yet:
-
action_policy has to keep the message history valid. If you let the model emit a tool call and then drop it, you end up with an AIMessage that has a tool call and no matching ToolMessage, and the next model call errors out. So do the real filtering in selectTools (so it never gets emited in the first place), and if you realy must veto after the fact, return a synthetic ToolMessage explaining the denial insted of deleting it - same thing HITL reject does.
-
done_checker needs a hard stop or it loops untill recursionLimit throws. Add a max turns guard, the documented way is a before_model hook that returns jumpTo “end” once a budget is hit. Dont leave it purely to the model deciding its done.
-
tool_approval interrupts need a checkpointer to pause/resume. And if the intent itself is low confidence, just interrupt() at the router and ask the user instead of guesing (call interrupt once per node, loop back with a conditional edge for reprompts).
For structure I’d go hybrid: an outer StateGraph for turn_contract → context_builder → agent → done_checker, and make the agent node a create_agent with the middleware (visible_tools + approval). That way you get deterministic routing where you want it, and the batteries included tool loop where you dont.
So yeah, ship it - just make the contract a validated schema and not free text, keep visible_tools as the primary control with action_policy only as a backstop, and put a turn cap on the loop.
The problem is not Excel, Word, or image generation.
The problem is that the agent has unrestricted access to the entire tool surface on every turn and decides by itself what to do. This creates the same issue across every tool category:
-
“List the accounts” → it lists them, then unnecessarily performs targeting discovery.
-
“Suggest 5 headlines” → it suggests them, then unnecessarily performs a web search.
-
“Create a campaign” → it creates the campaign, then unnecessarily checks the account again.
The root cause of this behavior is single and clear:
There is no deterministic control layer.
The model alone decides which of the 70 tools to call, when to call them, and how many times to call them.
Is Pawel’s Suggestion Generally Correct?
Yes.
The 5 primitives he suggested are the foundation of agent orchestration, independent of file generation:
| Primitive |
|---|
| Intent router with structured output |
| Dynamic tool binding |
| Action policy |
| Tool approval / HITL |
| Done checker |
These are general agent discipline primitives.
They apply to files, search, ads, and every other tool category in the same way.
What Is Missing in Your System — General Overview
1. The control architecture is inverted
Current flow:
model decides → system executes
Correct flow:
system classifies the intent → system defines the allowed action space → model decides within that limited space
2. The tool surface is not narrowed based on intent
In builder.js:2753:
this.model.bindTools(this.tools)
This means all tools are available on every turn.
As Pawel said:
A tool that is not bound simply cannot be called.
This is the strictest and cleanest form of control.
3. The stopping condition is left to the model
shouldApproveTool only checks whether there is a tool_call or not:
builder.js:1708
If the model does not decide that it is done, the loop can continue up to 200 supersteps.
There is no proper:
-
turn cap
-
tool-call counter
-
return_direct pattern
-
deterministic done checker
4. The policy layer is post-hoc and fragmented
Currently, there are only regex guards around some image tools.
There is no general policy layer that answers:
Is this tool allowed for this intent?
Pawel’s warning is important here:
Policy should be handled inside selectTools, so the model never emits invalid tool calls in the first place.
If you veto tool calls after the model already emitted them, you can end up with orphan AIMessage(tool_calls), which may cause provider-level errors.
5. BuilderV3 is disabled
Your builderV3.js is actually an ads-specific version of the architecture Pawel described:
| BuilderV3 Part | Purpose |
|---|---|
ads_router |
Intent routing |
selectVisibleTools |
Dynamic tool binding |
reviewToolCalls |
Action policy |
adsGuard |
Approval guard |
routeAfterResult |
Done checker |
But:
BUILDER_V3_ENABLED=false
And it is currently only designed for ads.
You have not moved this pattern into the general agent layer.
6. The prompt tells the model everything all the time
The full 350-line prompt is active on every turn.
This causes the model to infer things like:
File generation is part of my job.
Even when the user did not ask for it.
There is no progressive disclosure.
Skills and instructions are not revealed on demand.
Instead, the model sees too much context and too many capabilities on every turn.
New Graph Topology
START → memory_compaction → intent_router → agent
→ (shouldApproveTool) → tool_policy → tool_approval → tools → agent → …
↘ END
Changed / New Files
3 new modules:
-
toolCategories.js— centralized metadata that maps all tools to categories, including MCP prefixes and built-in tools. -
turnIntentRouter.js— language-agnostic intent classifier. LLM structured output is the primary path, while universal technical signals are handled through deterministic fast-path logic. -
toolPolicy.js— 3 guards:-
selectToolsForTurn— dynamic binding -
reviewToolCalls— post-hoc veto backstop -
shouldStop— done checker / turn cap
-
Changed files:
-
state.js— addedturn_intent,tool_policy, andturn_countersfields. -
builder.js— addedintent_routerandtool_policynodes, added done-checker logic toshouldApproveTool, and added dynamic tool binding insidecallModel. -
index.js— changedtemperature: 1totemperature: 0.2. -
server.js— changedrecursionLimit: 200to16in 5 places. -
prompt.js— added scope-discipline rules and made ALWAYS / NEVER loop triggers conditional. -
docker-compose.yml— added theTURN_ORCHESTRATION_ENABLED=trueflag.
Mapping to Pawel’s 3 Production Warnings
1. Message history remains valid
During tool_policy veto, the system adds a synthetic ToolMessage and AIMessage.
AIMessage(tool_calls) is never deleted.
2. Done checker has a hard stop
shouldStop() is called inside shouldApproveTool to enforce the turn cap.
recursionLimit is reduced to 16.
3. Checkpointer
PostgresSaver already exists, so HITL interrupts continue to work.
Kill Switch
TURN_ORCHESTRATION_ENABLED=false
When disabled, all new nodes become no-op and the system falls back to the V1 ReAct behavior.
@akin it seems like you’ve solved the problem yourself
Do you need any further help or assistance?