LangChain — research agent¶
A richer ReAct variant of the basic LangChain example, governing a web-search-and-calculator research agent under a single balanced policy.
What this example demonstrates¶
This example initializes Agent Assembly with init_assembly() in sdk-only mode and runs a ReAct-style research trajectory over two tools — web_search and calculator — under a balanced policy that bundles four controls at once:
- Network allowlist — outbound egress is only allowed to
*.openai.com. - Daily budget — tool calls are metered against a
$1.00 / daycap. - Tool-call logging — every governed call is recorded as an audit event.
- Credential-leak block — any tool input carrying a secret is denied.
It also includes a credential-leak demo that uses a SAFE, FAKE key (sk-FAKE...) — never a real secret — to show the leak rule firing. Finally, --mock mode runs the whole demo offline with no API keys, so CI can run it.
The framework / library¶
This example is built on LangChain. The tools are defined with LangChain's @tool decorator (langchain_core.tools.tool), and the governance layer hooks into LangChain via Agent Assembly's AssemblyCallbackHandler callback handler.
Version pins from pyproject.toml:
| Dependency | Version |
|---|---|
agent-assembly |
>=0.0.1a2 |
langchain-core |
>=0.2.0 |
| Python | >=3.12 |
Dev extras pin pytest>=8.0.0 and pytest-mock>=3.14.0.
How it works¶
main.py opens an Agent Assembly context with init_assembly() in sdk-only mode:
with init_assembly(
gateway_url=gateway_url,
api_key=api_key,
agent_id="langchain-research-agent",
mode="sdk-only",
) as ctx:
...
Inside the context it constructs a BalancedPolicyEngine and wires it into LangChain through AssemblyCallbackHandler(interceptor=policy).
The agent then replays a scripted ReAct trajectory — the steps a real LLM-driven agent would emit while researching its question — over the two tools web_search and calculator. Each step goes through _run_governed_call, which fires the handler's on_tool_start callback before invoking the tool. The BalancedPolicyEngine.check_tool_start method evaluates the rules in priority order:
- Credential-leak block (highest priority, fail-closed) — if the input matches any credential pattern (e.g.
sk-...,AKIA...,api_key=...,secret/password/token=...), the call is denied. - Network allowlist — any
http(s)://URL in the input has its host extracted and checked againstNETWORK_ALLOWLIST(*.openai.com,openai.com); a non-allowlisted host is denied. - Daily budget — the per-call cost (
web_search=$0.02,calculator=$0.00) is checked against the remaining$1.00 / daycap; an exhausted budget denies the call. - Tool-call logging — on an allow, the budget is charged and the decision is recorded.
Every call, allowed or denied, is appended to audit_log. A denied call surfaces as a ToolExecutionBlockedError raised by the callback handler, which _run_governed_call catches and prints as ❌ BLOCKED. At the end of the run the demo replays the full audit trail and the final budget total.
Prerequisites & running it¶
See Preparing the runtime environment for the shared prerequisites.
Then, from the examples repository:
--mock replays a scripted ReAct trajectory offline with no API keys. The example also auto-falls back to mock mode whenever OPENAI_API_KEY is unset.
Code walkthrough¶
The balanced policy configuration (policy.py):
#: Outbound network egress is only permitted to hosts matching this allowlist.
NETWORK_ALLOWLIST: tuple[str, ...] = ("*.openai.com", "openai.com")
#: Per-day spend ceiling, in USD. Tool calls are metered against this cap.
DAILY_BUDGET_USD: float = 1.00
#: Per-call cost model (USD) used to meter spend in offline mode.
TOOL_COSTS: dict[str, float] = {
"web_search": 0.02,
"calculator": 0.00,
}
The rule evaluation order in check_tool_start — credential-leak first, then allowlist, then budget (policy.py):
# 4. Credential-leak block — highest priority, fail closed.
if _contains_credential(input_str):
reason = (
f"Tool '{tool_name}' input contains a credential and is blocked "
"by policy rule 'block_credential_leak'."
)
self._record(tool_name, "deny", reason)
return {"status": "deny", "reason": reason}
# 1. Network allowlist — deny egress to non-allowlisted hosts.
match = re.search(r"https?://[^\s\"']+", input_str)
if match:
host = _extract_host(match.group(0))
if not _host_allowed(host):
reason = (
f"Tool '{tool_name}' attempted egress to '{host}', which is "
"not on the network allowlist (*.openai.com)."
)
self._record(tool_name, "deny", reason)
return {"status": "deny", "reason": reason}
The scripted ReAct trajectory replayed in --mock mode (main.py):
_MOCK_REACT_STEPS: list[tuple[str, str]] = [
("web_search", '{"query": "speed of light"}'),
("web_search", '{"query": "population of France"}'),
("calculator", '{"expression": "299792458 / 68000000"}'),
# An egress attempt to a host NOT on the allowlist — blocked by policy.
("web_search", '{"query": "fetch https://evil-exfil.example.com/leak"}'),
]
The governed call: it fires the callback before invoking the tool and catches a block (main.py):
def _run_governed_call(handler, tool_name, input_str):
print(f" → {tool_name}({input_str})")
try:
handler.on_tool_start(
serialized={"name": tool_name, "type": "tool"},
input_str=input_str,
run_id=uuid4(),
)
result = _TOOLS[tool_name].invoke(json.loads(input_str))
print(f" ✅ ALLOWED — {result}")
except ToolExecutionBlockedError as exc:
print(f" ❌ BLOCKED — {exc}")
print()
Notes & caveats¶
The credential-leak demo uses a SAFE FAKE key
The credential-leak step submits sk-FAKE0000DEMO0000NOTAREALKEY0000 — a deliberately fake, non-functional key. It is used only to show the credential-leak policy rule firing, and is never a real secret.
--mock runs offline
--mock replays the scripted ReAct trajectory offline with no API keys and no real network access, which is what CI runs. The example also auto-falls back to mock mode whenever OPENAI_API_KEY is unset.
Expected behavior¶
================================================================
Agent Assembly — LangChain ReAct Research Agent
================================================================
Initializing Agent Assembly (gateway: http://localhost:8080, sdk-only mode)...
Agent: langchain-research-agent
Gateway: http://localhost:8080
Mode: sdk-only (mock (offline))
Balanced policy (local simulation of gateway policy):
ALLOWLIST — outbound egress to *.openai.com, openai.com
BUDGET — $1.00 / day, metered per tool call
LOG — every tool call recorded as an audit event
BLOCK — any tool input that leaks a credential
Running ReAct research trajectory:
----------------------------------------------
→ web_search({"query": "speed of light"})
✅ ALLOWED — The speed of light in vacuum is 299792458 metres per second.
→ web_search({"query": "population of France"})
✅ ALLOWED — France has a population of approximately 68000000 people.
→ calculator({"expression": "299792458 / 68000000"})
✅ ALLOWED — 299792458 / 68000000 = 4.40871
→ web_search({"query": "fetch https://evil-exfil.example.com/leak"})
❌ BLOCKED — Tool 'web_search' attempted egress to 'evil-exfil.example.com', which is not on the network allowlist (*.openai.com).
Credential-leak demo (SAFE FAKE key):
----------------------------------------------
→ web_search({"query": "summarize using api_key=sk-FAKE0000DEMO0000NOTAREALKEY0000"})
❌ BLOCKED — Tool 'web_search' input contains a credential and is blocked by policy rule 'block_credential_leak'.
Governance events recorded this run:
----------------------------------------------
✅ web_search allow — allowed (charged $0.02; spent=$0.02 / limit=$1.00 (2%))
✅ web_search allow — allowed (charged $0.02; spent=$0.04 / limit=$1.00 (4%))
✅ calculator allow — allowed (charged $0.00; spent=$0.04 / limit=$1.00 (4%))
❌ web_search deny — Tool 'web_search' attempted egress to 'evil-exfil.example.com', which is not on the network allowlist (*.openai.com).
❌ web_search deny — Tool 'web_search' input contains a credential and is blocked by policy rule 'block_credential_leak'.
Final budget: spent=$0.04 / limit=$1.00 (4%)
Assembly context shut down.
How to read the governance events¶
| Event | Governance control | Outcome |
|---|---|---|
web_search("speed of light") |
tool-call capture + budget | ALLOWED, charged $0.02 |
web_search("population of France") |
tool-call capture + budget | ALLOWED, charged $0.02 |
calculator(...) |
tool-call capture + budget | ALLOWED, $0.00 (local compute) |
web_search(... evil-exfil.example.com ...) |
network allowlist | BLOCKED — host not on *.openai.com |
web_search(... api_key=sk-FAKE... ) |
credential-leak block | BLOCKED — secret detected in input |
The final block replays the full audit trail and the running budget total — the governance evidence a real gateway would persist server-side.