Evaluation inside agent execution

September 20, 2026 ยท View on GitHub

RuntimeGuard runs Jev at checkpoints chosen by your application. A checkpoint maps to a GuardPolicy, which accepts either an Evaluator or a fitted EvaluationPipeline. It uses the same custom metrics, evidence validation, thresholds, backend configuration, and optional calibration as offline evaluation. There are no agent-framework dependencies and no implicit global hooks.

For one tool, start with Guard a Python tool or the LangChain adapter. For custom metric panels, calibrated scores, or several checkpoints, use the explicit APIs further below.

Guard a Python tool

from typed_evals import guard_tool


@guard_tool(
    policy="Only read tickets owned by Alice.",
    input="Read my ticket T-42.",
    contexts=["Authenticated customer: Alice. Alice owns T-42."],
)
def read_ticket(ticket_id: str) -> str:
    """Read a support ticket's complete text."""
    return ticket_store.read_authorized("Alice", ticket_id)


text = read_ticket("T-42")

ticket_store represents your application's service. Register the decorated function with your framework. input and contexts can also be synchronous callbacks receiving a mapping of the tool's bound arguments, including defaults. These callbacks should use the application's authenticated request context to provide current facts; model-supplied claims of authorization are not evidence.

The helper builds a ToolSafety(policy=..., threshold=0.9) evaluator and a before_tool checkpoint. It captures the function name, signature/docstring, JSON arguments, and a unique invocation ID. contexts must contain at least one nonblank application-owned evidence string. A missing answer is represented internally; users do not construct an empty response or a proposal object.

Sync and async functions preserve their signature and native output. Arguments are copied before evidence mapping and judgment; execution receives the reviewed JSON values. Invalid arguments, evidence mapper errors, and non-JSON tool inputs raise before execution. Variadic functions require the advanced guarded_by API. Injected framework objects need an adapter, such as the one below.

on_fail="block" and on_error="block" raise GuardrailViolation before execution. Optional threshold, backend, timeout, on_fail, and on_error configure the same policies as RuntimeGuard. Tool exceptions propagate without retries. Successful calls return native output; an enclosing guarded_agent or guarded_by wrapper collects decisions, including unique sample IDs, in its metadata. For standalone decision objects, use RuntimeGuard.call_tool.

LangChain tools

Install pip install '.[langchain]'. Apply this adapter under @tool:

from langchain.tools import ToolRuntime, tool
from typed_evals.adapters.langchain import guard_tool


@tool
@guard_tool(
    policy="Only read tickets owned by the authenticated customer.",
    contexts=lambda runtime: runtime.context["authorization_evidence"],
)
def read_ticket(ticket_id: str, runtime: ToolRuntime) -> str:
    """Read the complete text of a support ticket."""
    return ticket_store.read_authorized(runtime.context["customer_id"], ticket_id)

The runtime: ToolRuntime parameter uses LangChain's runtime injection. The adapter preserves the annotation so LangChain excludes it from the model's tool schema. Jev receives the latest human message's text, the tool's public arguments and description, and your explicitly supplied evidence. It does not automatically receive runtime state, configuration, or credentials.

contexts accepts fixed strings or a synchronous callback of ToolRuntime. Pass current application-owned facts using LangChain's runtime context when invoking the agent. The supplied tool call ID becomes the result's sample_id; if absent, a unique ID is generated. For @tool("custom_name"), also set guard_tool(name="custom_name", ...) to record the registered name accurately.

String messages and lists of text blocks are supported. Missing, empty, or multimodal human requests raise instead of substituting an older request or silently discarding evidence. Other state/message formats, additional injected arguments, or custom evaluation panels can use guarded_by with an explicit mapper. The latest request is intentionally the input; add any relevant prior facts to your evidence. See the complete runnable example.

CrewAI and Microsoft Agent Framework tools

Use typed_evals.adapters.crewai.guard_tool to create a native CrewAI tool, or typed_evals.adapters.agent_framework.guard_tool under Microsoft's @tool for functions that accept an injected FunctionInvocationContext. Both share the same argument snapshots and runtime enforcement described above. The adapter guide covers installation, examples, cache behavior, and how each framework reports a blocked tool call.

Advanced checkpoints

flowchart LR
    P[Proposed action and evidence] --> J[Jev metrics and optional calibration]
    J --> D{Checkpoint policy}
    D -->|allow or annotate| E[Execute once]
    D -->|block, retry, escalate| H[Return control to host]
    E --> O[Observed result]
    O --> R[Optional post-check]
    R --> M[Native output and metadata]

Run the complete example without credentials:

python examples/runtime_guardrails.py
# Billable real judgments, using TYPESAFE_API_KEY:
python examples/runtime_guardrails.py --live

The offline example uses synthetic fixture scores to demonstrate execution control. It does not measure Jev's classification accuracy.

Notebook imports after updating package code

If a nonempty list of ToolSafety(...) / ToolAccuracy(...) raises ValueError: metrics must contain at least one Metric, restart the notebook kernel and rerun the imports and guard construction. Older evaluator versions use a class identity check that can fail after partial module reloads or loading the package under multiple import names. Use the root-level package consistently:

from typed_evals import Evaluator, GuardPolicy, RuntimeGuard, ToolAccuracy, ToolSafety

The current evaluator revalidates base metric instances from another reload or import alias of the same source file. Invalid entries now identify their list index and type. Restarting is still needed to load updated code already cached in a running kernel, and to refresh custom metric subclasses after reloading modules.

Guard tool dispatch before execution

from typed_evals import (
    EvaluationSample,
    Evaluator,
    GuardPolicy,
    GuardrailViolation,
    RuntimeGuard,
    ToolAccuracy,
    ToolProposal,
    ToolSafety,
)

guard = RuntimeGuard(
    {
        "before_tool": GuardPolicy(
            Evaluator(
                [
                    ToolSafety(policy="Only read tickets belonging to the authenticated customer."),
                    ToolAccuracy(threshold=0.85),
                ]
            ),
            timeout=10,
        ),
    }
)


def read_ticket(ticket_id):
    return {"id": ticket_id, "refund_period": "30 days"}


sample = EvaluationSample(
    id="call_001",  # Use a unique ID for each tool invocation.
    input="Read ticket T-42.",
    response="",  # No answer has been generated at this boundary.
    proposed_tool_call=ToolProposal(name="read_ticket", arguments={"ticket_id": "T-42"}),
    contexts=(
        "read_ticket(ticket_id: str) reads an existing ticket without modifying it.",
        "The authenticated customer owns T-42 and is authorized to read it.",
    ),
)

try:
    result = guard.call_tool({"read_ticket": read_ticket}, sample)
    tool_output = result.output
    audit_metadata = result.metadata
    tool_name = audit_metadata["jev"]["decisions"][0]["evaluation"]["tool_name"]
except GuardrailViolation as exc:
    decision = exc.decision.to_dict()
    # No tool was dispatched. Return a refusal, request revised arguments,
    # or route an escalation through your application's approval flow.

Use await guard.acall_tool(...) in an async agent loop. Async tools run in the current loop; sync tools run in a worker thread. Tools must accept keyword arguments. Registry lookup and Python signature binding happen before judgment. Annotations on Python function parameters are not runtime type validation; enforce tool schemas and access controls in your dispatcher as well.

call_tool snapshots the selected callable and nested JSON arguments before awaiting Jev. Mutating the original proposal or registry during evaluation cannot change that dispatch. Supply current, application-owned tool specifications, authorization facts, and known argument values as evidence. The tool must still enforce authorization against current state when it executes; a judgment is not an authorization token and cannot prevent external state changes.

ToolProposal has no output/status fields. ToolCall remains an observed event. For an optional post-check, configure another checkpoint and pass after="after_tool". Its sample includes the new observed event in trace and clears proposed_tool_call. Non-JSON native tool outputs need an explicit output_mapper, for example lambda output: {"id": output.id}. The returned native output is preserved. status="success" means the callable returned; whether it achieved the business objective is assessed from the actual output.

Before-tool evaluation metadata includes tool_name from the proposal and sample_id from EvaluationSample.id. Use a unique sample ID for each invocation to distinguish repeated calls to the same tool. tool_name identifies a proposal even when the judgment denies it; it does not establish execution. Evaluations without a proposal, including the post-check above, have tool_name=None. A trace can contain several tools, so it does not supply a single result-level tool name.

A post-check withholds a result from its consumer; it cannot undo effects of the tool that already ran. Tool exceptions propagate unchanged, and tools are never retried automatically. Catch failures in the host and map actual error events to an error/recovery checkpoint if needed.

Annotate or withhold responses

from typed_evals import Faithfulness

response_guard = RuntimeGuard(
    {
        "after_response": GuardPolicy(
            Evaluator([Faithfulness(threshold=0.8)]),
            on_fail="annotate",
        ),
    }
)

native = {"answer": "The refund period is 90 days."}
response = response_guard.respond(
    "after_response",
    EvaluationSample(
        input="What is the refund period?",
        response=native["answer"],
        contexts=("The refund period is 30 days.",),
    ),
    native,
)
print(response.output)  # The exact native object.
print(response.metadata["jev"]["hallucination_suspected"])

hallucination_suspected is True when Jev fails faithfulness or tool_grounding at the configured threshold, False when the evaluated grounding checks pass, and None when no grounding check yields a usable conclusion. It is a judge's evidence-relative finding, not an independent determination of truth. Missing evidence is never reported as a clean grounding result. Other custom metric failures appear under failed_metrics; they do not implicitly acquire hallucination semantics.

Each decision records its checkpoint, unique ID, action, failed/unavailable metrics, per-metric raw and calibrated scores, thresholds, observed judge model, usage, sample hash, and evaluation duration where available. Provider exceptions are recorded by type, without their potentially sensitive message bodies. The guard stores no shared history; accumulate result.decisions in your own run record. Neither evidence nor native output is automatically persisted.

respond/arespond evaluate an already materialized result before releasing it. The sample must describe that exact result. Merge response.metadata into your framework's metadata container if desired. The wrapper never modifies the native response. Choose on_fail="block" to withhold hallucination-suspect responses, or "retry" to let the host regenerate and recheck them.

Policy and failure semantics

ActionMay execution/delivery proceed?Host behavior
allowYesAll checked metrics passed; generated by the guard
annotateYesRetain findings in metadata
blockNoStop this action or withhold this output
retryNoRevise the proposal/output, then run the checkpoint again
escalateNoRoute the decision to an application-owned approval flow

on_fail and on_error default to block. Errors include judge exceptions, timeouts, skipped evidence, and unusable results. Annotation of an error requires an explicit on_error="annotate"; this permits an unevaluated result and retains the error in metadata. Calibration configuration/drift errors always propagate, even with annotation enabled. Cancellation propagates as cancellation.

metric_actions={"answer_relevancy": "retry", "faithfulness": "block"} overrides on_fail per metric. Conflicting actions use the priority block > escalate > retry > annotate > allow; an unavailable metric contributes on_error. Unknown metric overrides and unconfigured checkpoint names raise. Custom metrics must use the existing convention that higher scores mean better compliance. A risk metric should score the probability of absence of the risk, or use Choice pass_options to define its acceptable outcomes.

check/acheck only return decisions: framework middleware must call decision.require_allowed() before invoking its continuation. enforce/ aenforce, run/arun, call_tool/acall_tool, respond/arespond, and guarded_by enforce automatically, raising GuardrailViolation for any action that cannot continue. The exception carries the failing decision.

timeout bounds the async judgment, including evaluator retries, subject to cooperative cancellation. It does not bound tool execution. Once a tool starts, especially a synchronous tool in a worker, cancelling the caller cannot reliably stop its external effects. Keep retry limits, budgets, schema checks, idempotency, and exact authorization rules in deterministic application code.

Checkpoints throughout a workflow

Names below are conventions. You can define any nonempty checkpoint name, and different agents/tools can use different policies or evaluators.

BoundaryEvidence and possible checksTypical disposition
Before accepting inputCandidate input mapped to response; PolicyCompliance or custom abuse/injection rubricBlock or escalate
Before model invocationProposed prompt mapped to response; custom disclosure, scope, and instruction-policy metricsBlock or annotate
After model generationGenerated draft, source passages; Faithfulness, AnswerRelevancy, reference correctnessAnnotate or regenerate
After retrieval, before consumptionRetrieved passages; ContextRelevance, custom source/injection checksRe-retrieve or block
Before tool executionproposed_tool_call, tool specs, authorization facts; ToolSafety, ToolAccuracyBlock or escalate
After tool execution, before consumptionObserved trace; custom output integrity metric, or mapped content with PolicyComplianceWithhold or annotate
Before subagent dispatch/handoffDelegated task or handoff payload mapped to response; scope, privacy, and completeness rubricsBlock or revise
After subagent completionSubagent output plus actual evidence; grounding, correctness, task completionAnnotate or retry
Before memory writeProposed memory in response, supporting contexts; grounding and privacy checksBlock or annotate
After memory readRetrieved memory as contexts; relevance and custom staleness checksWithhold or refresh
Before external deliveryFinal message in response; grounding, policy, and data disclosure checksBlock or annotate
On error/recoveryObserved failed trace and proposed recovery action; custom recovery policyEscalate or revise
Before declaring completionObserved trace, explicit expected_outcome; TaskCompletion, ToolGroundingRetry or mark incomplete

PolicyCompliance(policy="...") provides a reusable content-policy rubric. Custom Metric objects cover application-specific criteria at every checkpoint. Put application policy in the metric definition and untrusted payloads in sample evidence. Metadata and hidden chain-of-thought are not judge evidence. Plans and delegated tasks are proposed artifacts, not proof of execution.

An agent framework adapter needs only to map the current state to an EvaluationSample, invoke the guard before its continuation, and carry the decision metadata forward. Every execution path, including parallel calls and subagents, must traverse the relevant hook. Wrapping only a top-level agent response cannot intercept tools hidden inside that agent.

Generic operations and decorators

guard.run(checkpoint, sample, lambda: operation()) defers any synchronous operation until its pre-check allows it. await guard.arun(...) supports sync and async operations. The host must bind the operation to the evaluated evidence; use call_tool when the library should own tool argument binding. Optional after and sample_builder(output, original_sample) gate a materialized output.

For framework model/agent functions, explicit mappers keep native types intact:

from typed_evals import guarded_by


@guarded_by(
    response_guard,
    after="after_response",
    after_sample=lambda output, args, kwargs: EvaluationSample(
        input=args[0],
        response=output["answer"],
        contexts=output["sources"],
    ),
)
async def ask(question):
    return await your_framework.invoke(question)

To check before invocation, add before="your_checkpoint" and before_sample=lambda args, kwargs: .... Sample builders are synchronous. Decorated functions return GuardedResponse, including all successful boundary decisions and nested enforced decisions. An after-only decorator cannot prevent internal side effects. native_output=True preserves a tool's native return type; its decisions are collected by an enclosing decorated agent. async_mode=True supports a regular function that returns an awaitable, without blocking on a synchronous judge call in an event loop.

Streaming must be buffered behind the checkpoint before exposure. Generator functions/outputs are rejected by the wrappers; framework-specific stream objects must be explicitly materialized by the adapter. Evaluating chunks after they have been sent cannot withhold already delivered content or reliably judge the whole response. The guard does not automatically redact or rewrite content.

Decorate agents directly

guarded_agent applies the same guard to existing agent instances, class constructors, factory functions, and invocation functions. It creates a GuardedAgent proxy for instances, so the original framework object and its internal method calls retain their native behavior. Jev does not import or patch framework internals.

from typed_evals import guarded_agent

protect = guarded_agent(
    response_guard,
    after="after_response",
    after_sample=lambda output, args, kwargs: EvaluationSample(
        input=args[0],
        response=output.text,
        contexts=source_passages,
    ),
    methods=("run",),
    async_methods=("run",),
)
agent = protect(existing_framework_agent)
result = await agent.run("What is the refund period?")
native_output, metadata = result.output, result.metadata

Choose entrypoints to match your agent's public API:

API shapeDecorator configurationInvocation
Synchronous invokemethods=("invoke",)agent.invoke(...)
Asynchronous ainvokemethods=("ainvoke",)await agent.ainvoke(...)
Regular run returning an awaitablemethods=("run",), async_methods=("run",)await agent.run(...)
Sync/async kickoffmethods=("kickoff", "kickoff_async")Call or await the respective method
Callable instancemethods=("__call__",)agent(...) or await agent(...)
Any custom methodmethods=("execute_task",)agent.execute_task(...)

If methods is omitted, invoke, ainvoke, run, arun, kickoff, kickoff_async, chat, achat, and __call__ are discovered when callable. async def methods are recognized automatically. Mark ordinary methods returning awaitables in async_methods; this includes Agent.run in the Microsoft Agent Framework version tested locally. No guessed conversion of framework outputs or source evidence is performed: provide explicit sample builders, with the same (args, kwargs) and (output, args, kwargs) signatures as guarded_by. Their arguments exclude the bound self or cls.

You can attach the decorator at agent construction time:

@guarded_agent(
    response_guard,
    factory=True,
    methods=("run",),
    async_methods=("run",),
    after="after_response",
    after_sample=lambda output, args, kwargs: EvaluationSample(
        input=args[0],
        response=output.text,
        contexts=source_passages,
    ),
)
def create_agent():
    return YourFrameworkAgent(client=client, tools=registered_tools)

Use factory=True for functions returning agents, including async factories. For class constructors it is automatic: @guarded_agent(...) above class Agent returns a constructor that creates guarded instances. Initialization is never judged as an agent response. The decorated class name becomes a constructor function, and instances are proxies rather than instances of the framework class. For frameworks requiring an exact native type, decorate the bound execution callable or use their middleware API instead. The underlying object is available as agent.__wrapped__, and selected methods as agent.guarded_methods.

Read-only attributes and helper methods are forwarded to the native agent. Configure its state and resource lifecycle on the original object before wrapping. Operations that create new agents/runnables, such as binding configuration, must have their returned objects wrapped as well. Invoke selected methods through the proxy; calls through an original reference or helper-created callable bypass it. Known unselected execution methods raise rather than silently bypassing the guard. Known stream methods and stream=True calls are rejected before invocation; custom streaming APIs still require explicit buffering in your adapter.

Guard registered tools without changing their return types

Decorate tools before registering them with the framework:

from typed_evals import guarded_by, ToolProposal


@guarded_by(
    guard,  # Includes a before_tool policy, as in the first example.
    before="before_tool",
    before_sample=lambda args, kwargs: EvaluationSample(
        input="Read the customer's requested ticket.",
        response="",
        proposed_tool_call=ToolProposal(
            name="read_ticket",
            arguments={"ticket_id": kwargs["ticket_id"] if "ticket_id" in kwargs else args[0]},
        ),
        contexts=(
            "read_ticket(ticket_id: str) reads a ticket without modifying it.",
            "The authenticated customer is authorized to read T-42 only.",
        ),
    ),
    native_output=True,
)
def read_ticket(ticket_id: str) -> dict:
    return ticket_store.read(ticket_id)


# Construct the framework agent with the decorated callable:
native_agent = YourFrameworkAgent(client=client, tools=[read_ticket])
agent = protect(native_agent)

The decorated tool preserves its name, annotations, signature, and native return value for framework schema generation and dispatch. Place any framework-specific tool decorator outside @guarded_by so it registers the guarded callable. Supply actual request and authorization evidence in your mapper; the literals above illustrate a single-customer example. Ordinary callable wrappers rely on that mapper accurately describing their arguments. Use guard.call_tool in a dispatcher adapter when Jev should snapshot and bind the exact JSON arguments.

Every enforced decision inside a decorated invocation is collected using scoped context, including checks from nested tool decorators, enforce, and call_tool. Separate concurrent agent calls have separate logs. Awaited child tasks and asyncio.to_thread inherit the scope. Arbitrary external workers/processes require explicit metadata propagation; the decorator does not intercept remote execution. Join child work before completing the agent invocation. Decision order reflects completion order across parallel tasks, not a guaranteed total execution order.

If the framework catches a tool's GuardrailViolation and continues, the tool remains unexecuted and its blocked decision remains in the final agent metadata. Exceptions and cancellation restore the scope. native_output=True without an enclosing decorated invocation still enforces the check, but does not return an audit wrapper. For a standalone tool with returned metadata, use the default native_output=False or call_tool.

An agent's outside entrypoint cannot intercept arbitrary hidden tools. Register guarded tools or supply framework middleware that calls guard.aenforce before the dispatcher continuation; those decisions are also collected by the decorator. The runnable decorated_agent.py demonstrates agent construction, native tool results, blocked calls, and response metadata.

Jev judgments add latency and can be wrong, including on adversarial evidence. Test policies and thresholds on representative labeled data. The runtime layer enforces the configured decisions; the underlying prompts are not a proven prompt-injection defense or a substitute for deterministic permissions.