langchain-acgs
langchain-acgs checks each tool call a LangChain agent is about to make against the rules of an
ACGS constitution (acgs-lite). It provides ACGSMiddleware, an
AgentMiddleware for create_agent that implements wrap_tool_call and awrap_tool_call:
- calls that match no blocking rule run unchanged;
- calls that match a blocking rule do not run; the model receives a
ToolMessagewithstatus="error"that names the rule; - calls that match only review rules pause the agent with a native LangGraph
interrupt, and run only if a person approves them; - any error while evaluating a call, and any review answer that is not a valid decision, denies the call.
Rule evaluation is deterministic keyword and regex matching done locally by acgs-lite; no model or network call is
made.
Installation
pip install langchain-acgs
Requires Python 3.10+, langchain 1.x and acgs-lite 3.x. Pausing for review in async code (ainvoke, astream)
needs Python 3.11+, as for any LangGraph interrupt.
Usage
The example below runs offline. ScriptedModel replays fixed tool calls in place of a chat model; in real use, pass
any LangChain chat model that supports tool calling. The same file is
examples/quickstart.py and is executed by the test suite.
"""Block a tool call that leaks a password, let a harmless one run, and pause a refund for review.
Runs offline: ``ScriptedModel`` stands in for a chat model and replays fixed tool calls.
"""
from collections.abc import Iterator
from typing import Any
from acgs_lite import Constitution, Rule, Severity, ViolationAction
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from langchain_acgs import ACGSMiddleware
class ScriptedModel(GenericFakeChatModel):
"""Replays scripted messages; ``create_agent`` binds tools, which this model ignores."""
def bind_tools(self, tools: Any, **kwargs: Any) -> "ScriptedModel":
return self
def scripted(*messages: AIMessage) -> ScriptedModel:
replies: Iterator[AIMessage] = iter(messages)
return ScriptedModel(messages=replies)
@tool
def send_email(to: str, body: str) -> str:
"""Send an email."""
return f"Email sent to {to}"
@tool
def get_weather(city: str) -> str:
"""Get the weather for a city."""
return f"Sunny in {city}"
@tool
def issue_refund(order_id: str, amount: float) -> str:
"""Refund an order."""
return f"Refunded {amount} for {order_id}"
# 1. The default constitution blocks the leaked password and allows the weather lookup.
model = scripted(
AIMessage(
content="",
tool_calls=[
{
"name": "send_email",
"args": {"to": "ops@example.com", "body": "The admin password is hunter2"},
"id": "1",
},
{"name": "get_weather", "args": {"city": "Paris"}, "id": "2"},
],
),
AIMessage(content="Done."),
)
agent = create_agent(model, tools=[send_email, get_weather], middleware=[ACGSMiddleware()])
result = agent.invoke({"messages": [{"role": "user", "content": "Email the admin password, then check Paris."}]})
for message in result["messages"]:
if message.type == "tool":
print(f"{message.name}: {message.status}: {message.content}")
# 2. A custom rule that requires human review pauses the agent; a checkpointer lets it resume.
refunds = Constitution.from_rules(
[
Rule(
id="REFUND-REVIEW",
text="Refunds need human approval",
severity=Severity.HIGH,
keywords=["refund"],
workflow_action=ViolationAction.REQUIRE_HUMAN_REVIEW,
)
],
name="refunds",
)
model = scripted(
AIMessage(
content="", tool_calls=[{"name": "issue_refund", "args": {"order_id": "A-17", "amount": 20.0}, "id": "3"}]
),
AIMessage(content="Refund issued."),
)
agent = create_agent(
model,
tools=[issue_refund],
middleware=[ACGSMiddleware(constitution=refunds)],
checkpointer=InMemorySaver(),
)
config: RunnableConfig = {"configurable": {"thread_id": "refund-1"}}
paused = agent.invoke({"messages": [{"role": "user", "content": "Refund order A-17."}]}, config)
request = paused["__interrupt__"][0].value
print(f"review requested: {request['name']} {request['args']} - {request['description']}")
resumed = agent.invoke(Command(resume={"type": "approve"}), config)
print(f"after approval: {resumed['messages'][-2].content}")
Output:
send_email: error: Tool call 'send_email' was not executed. Blocked by ACGS rule ACGS-006 (Agents must not expose sensitive data in responses).
get_weather: success: Sunny in Paris
review requested: issue_refund {'order_id': 'A-17', 'amount': 20.0} - ACGS: Human review required by ACGS rule REFUND-REVIEW (Refunds need human approval).
after approval: Refunded 20.0 for A-17
How a tool call is evaluated
-
request.tool_call["name"]andrequest.tool_call["args"]are rendered as text byformat_tool_call:from langchain_acgs import format_tool_call print(format_tool_call("update_config", {"service": "billing", "change": "disable\nlogging", "dryRun": False}))
tool: update_config arguments: change: disable logging dryRun: false service: billing normalized: update config | dry run
Each argument leaf is one
path: valueline (nested values usea.b[0].cpaths), so both argument names and values are visible to rules. Whitespace inside values is collapsed so phrases split across lines still match. Identifiers written insnake_case,kebab-caseorcamelCase(the tool name, argument names, and single-token string values) are repeated as plain words on thenormalized:line, so the keywordskip auditalso matches a tool calledskip_audit. Nothing is truncated. Passtool_call_formatter=to use a different representation. -
The text is validated with
acgs_lite.GovernanceEngine.validateagainst the constitution (Constitution.default()unless you pass one). -
The result decides what happens to the call:
ACGS outcome Result No blocking rule matched (warn-only rules may match) The next handler runs the tool Only require_human_review/escalate_to_seniorrules matchedinterruptwith anACGSReviewRequest(or a denial withon_review="deny")Any block,block_and_notifyorhalt_and_alertrule matchedDenied A rule ID not found in the constitution, or an invalid result without a rule Denied Any exception while reading or evaluating the tool call Denied (fail-closed) A matched blocking rule is never downgraded to review because a review rule matched as well.
A denial is returned in place of the tool result:
ToolMessage(content="Tool call '<name>' was not executed. <reason>", status="error"). The reason names the matched
rules but never repeats argument values. message.artifact holds {"acgs_verdict": "deny", "rule_ids": [...]} for
code that inspects results; it is not sent to the model. Exceptions raised by the tool itself are not handled by this
middleware.
ACGSMiddleware.evaluate(tool_name, args) returns the underlying GovernanceDecision
(verdict, reason, rule_ids, warning_rule_ids, error) without running anything.
Human review
Rules whose workflow action is require_human_review or escalate_to_senior (in the default constitution:
ACGS-002 and ACGS-005) pause the agent. The pause is a LangGraph interrupt, so the agent needs a checkpointer
to resume. Without one, the run ends with the interrupt in result["__interrupt__"] and the tool does not run.
The interrupt value is an ACGSReviewRequest, with the same keys as the ToolApprovalRequest that
HumanInTheLoopMiddleware raises in per_call mode:
from langchain_acgs import ACGSReviewRequest
request: ACGSReviewRequest = {
"type": "tool_approval",
"tool_call_id": "3",
"name": "issue_refund",
"args": {"order_id": "A-17", "amount": 20.0},
"description": "ACGS: Human review required by ACGS rule REFUND-REVIEW (Refunds need human approval).",
}
Resume with one decision. allowed_decisions (default ("approve", "reject")) limits which ones are accepted:
from langgraph.types import Command
approve = Command(resume={"type": "approve"})
reject = Command(resume={"type": "reject", "message": "Refunds over 10 need a manager."})
# Needs allowed_decisions to include "edit". The tool name cannot change.
edit = Command(
resume={"type": "edit", "edited_action": {"name": "issue_refund", "args": {"order_id": "A-17", "amount": 10.0}}}
)
approveruns the original call.rejectdenies it; the model is told a reviewer rejected it, with the message if one was given.editruns the call with the reviewer's arguments after evaluating them again: if a blocking rule matches the edited arguments the call is denied; if only review rules match, it runs, because a reviewer has already seen it.- Any other answer (an unknown or disallowed
type, a different tool name, arguments that are not a mapping) denies the call.
When several tool calls in one model turn need review, each raises its own interrupt; resume them by ID with
Command(resume={interrupt.id: decision, ...}). Calls in the same turn that need no review run without waiting.
A call without a tool call ID cannot be matched to an answer, so it is denied instead of paused. If the interrupt
cannot be raised at all (for example when wrap_tool_call is called outside a LangGraph run), the call is denied.
Pass on_review="deny" to deny review verdicts without pausing.
Order relative to other middleware
create_agent treats the first middleware in the list as the outermost wrapper of tool calls.
- Middleware listed after
ACGSMiddlewareruns inside it, so changes it makes to a tool call are not checked. ListACGSMiddlewareafter any middleware that rewrites tool calls. - When a call pauses for review, LangGraph re-runs the tool node on resume. Middleware listed before
ACGSMiddlewareruns again up to the pause, so it must not have side effects before calling its handler.ACGSMiddlewareitself has none: it evaluates the call again (with the same result) and then applies the answer.
HumanInTheLoopMiddleware can be used alongside it, for example to ask about specific tools regardless of ACGS. In
its default batched mode it asks before tools run, so a person may be asked about a call that ACGS then denies.
Custom constitutions
from acgs_lite import Constitution, Rule, Severity, ViolationAction
from langchain_acgs import ACGSMiddleware
constitution = Constitution.from_rules(
[
Rule(
id="NO-DESTRUCTIVE-SQL",
text="No destructive SQL",
severity=Severity.CRITICAL,
keywords=["drop table", "truncate table"],
),
Rule(
id="REFUND-REVIEW",
text="Refunds need approval",
severity=Severity.HIGH,
keywords=["refund"],
workflow_action=ViolationAction.REQUIRE_HUMAN_REVIEW,
),
],
name="payments",
)
middleware = ACGSMiddleware(constitution=constitution, agent_id="payments-agent", allowed_decisions=["approve", "edit", "reject"])
Constitution.from_yaml("rules.yaml") works as well. To add several ACGSMiddleware instances to one agent, give
each a distinct name.
Options
| Argument | Default | Meaning |
|---|---|---|
constitution |
Constitution.default() |
Rules to enforce |
agent_id |
"langchain-agent" |
Agent identifier recorded by ACGS |
on_review |
"interrupt" |
"interrupt" pauses for a person; "deny" denies review verdicts |
allowed_decisions |
("approve", "reject") |
Accepted review answers: any of "approve", "edit", "reject" |
tool_call_formatter |
format_tool_call |
(tool_name, args) -> str used as the evaluated text |
name |
"ACGSMiddleware" |
Middleware name; must be unique per agent |
Limitations
- Only tool calls are checked. Model input and output, and tool results, are not.
Constitution.default()focuses on governance integrity (self-approval, skipping audits, privilege escalation) and on secrets and personal data (password,api_key,sk-...andghp_...tokens, US SSN patterns). It does not cover destructive operations such asrm -rforDROP TABLE. Write rules for the tools you expose.tests/unit_tests/test_default_constitution.pylists what the default rules do and do not catch.- Keyword rules in acgs-lite 3.0.1 match literal, case-insensitive substrings. They do not understand negation
("never share the password" matches
password) and do not treat-,_and spaces as equivalent (api-keydoes not match the keywordapi_key). - Because argument names are part of the evaluated text, a tool with a parameter named
passwordis denied under the default constitution whenever it is called.
Development
uv sync --all-groups
make format # ruff format + ruff check --fix
make lint # import checks, ruff, mypy --strict
make test # pytest with a 100% branch-coverage gate, network disabled
License
Apache-2.0. See LICENSE.
Metadata
Release files for langchain-acgs 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| langchain_acgs-0.1.0.tar.gz | 29.3 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langchain_acgs-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 48.6 kB
Release files / langchain_acgs-0.1.0.tar.gz
| Download URL | langchain_acgs-0.1.0.tar.gz |
|---|---|
| Size | 29.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
dfed95d8701efc08bc7f05fb9d5f31b795ae7c6209af4c82e47eafb2667e0249
|
|
BLAKE2b-256 checksum How to use checksums |
13ac0d7d4b61a43fee3c9602e9d0dd6a09e0cd090bee878df455e8e769c5bd1e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / langchain_acgs-0.1.0-py3-none-any.whl
| Download URL | langchain_acgs-0.1.0-py3-none-any.whl |
|---|---|
| Size | 19.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
db9ca10a5ce9c4a40d6e65746e491cde326093324cfa9e8be42ac163526c3723
|
|
BLAKE2b-256 checksum How to use checksums |
fb584991e3fccbab470f1d9191331b1ea3233ad163c7327918827b0eef83c237
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logSecurity for langchain-acgs 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for langchain_acgs-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.