Skip to content

Add config-driven agent output policies#5

Open
nickhuo wants to merge 4 commits into
mainfrom
feat/agent-output-policy
Open

Add config-driven agent output policies#5
nickhuo wants to merge 4 commits into
mainfrom
feat/agent-output-policy

Conversation

@nickhuo

@nickhuo nickhuo commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What

Adds an agent output policy mechanism: after an agent produces a result, the framework automatically runs the policies configured for that agent — no manual on_done from the caller.

  • Config: each agent declares output_policies (a list of module:function refs) in global_controller.yaml.
  • Load: GlobalController._write_output_policies() writes them to Redis per agent (agent:<name>:output_policies), mirroring _write_resource_specs / _load_and_write_policies.
  • Run: LocalController._run_output_policies() resolves the refs via importlib and invokes each one in _execute_locally, right after the agent returns. Policy exceptions, unresolvable refs, and policy-loading failures (bad Redis value / Redis blip) are all isolated (logged, skipped) so they can never corrupt the agent's result write-back.

The framework only finds and runs the configured policies; the decision logic lives entirely inside each policy.

Risk / deliberate limitation

Output policies are fire-and-forget side effects. The framework passes the agent output to each policy but ignores the return value, and isolates any exception a policy raises. Consequences:

  • A policy cannot modify the result written back to the caller (no redaction/transform of the output).
  • A policy cannot block or fail the result — a raised exception is logged and swallowed, execution continues.
  • Policies can only observe/react: audit, notify, emit metrics, call external systems.

This is an intentional simplification chosen over a chained/transform model, which was rejected because chaining makes correctness depend on YAML ordering (easy to misconfigure). If transform (rewrite the result) or gate (deny/abort) semantics are needed later, the return-value / exception contract must be explicitly extended — the hook is already placed before the result is written to Redis to leave room for that.

Test

tests/test_output_policy.py (self-contained, stubs gRPC/Redis) — 10 cases: all policies see the same output, return value ignored, order-independent, exception isolated, unresolvable ref skipped, loading failure isolated, no-config no-op, resolution cached, and two real-module tests that resolve + run the actual examples/policies/finance.py through the real dispatch path. Wired into tests/run_tests.sh.

$ uv run python tests/test_output_policy.py
All 10 output-policy tests passed.

TODO (follow-up PR — not included here)

Because ‎ventis build/deploy doesn’t ship the ‎policies/ directory into the agent Docker image, ‎import policies.finance fails inside the container and output policies never run there; they only work in local (non-container) runs for now.

nickhuo added 3 commits July 9, 2026 12:34
Agents can declare `output_policies` (module:function refs) in the
controller config. The global controller writes them to Redis per agent
(agent:<name>:output_policies), and the local controller resolves and
runs them automatically after the agent produces a result — no manual
on_done registration by the caller.

Policies are fire-and-forget side effects: the framework passes the
output to each configured policy and ignores its return value, so
policies observe/react (audit, notify, enforce externally) but do not
modify the result. Framework only finds and runs; all decision logic
lives inside the policy.
- Isolate policy loading (Redis read + JSON parse) inside
  _run_output_policies so a malformed stored value or Redis blip cannot
  propagate and let _execute_locally overwrite a good agent result.
- Add tests exercising the real examples/policies/finance.py through the
  actual resolution + dispatch path, plus a load-failure isolation test.
Extract _policy_context and set request_id/service/function by direct
assignment instead of setdefault, so a caller-controlled request context
(from baggage) cannot spoof the service/request_id/function a policy
sees. Add tests covering the override and the None-context case.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a config-driven agent output policy mechanism: after an agent method returns, the local controller automatically loads and runs configured module:function policy callables for that agent, with failures isolated from the main result write-back path.

Changes:

  • Add output_policies configuration (YAML) and persist per-agent policy refs into Redis from the global controller.
  • Add lazy-loading + importlib-based resolution and automatic post-execution dispatch of output policies in the local controller.
  • Add unit tests covering policy loading, dispatch semantics, failure isolation, caching, and example-policy integration; wire these into the test runner.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
ventis/templates/config/global_controller.yaml Documents the new optional output_policies agent config.
ventis/controller/local_controller.py Loads policy refs from Redis, resolves callables, and runs output policies after agent execution.
ventis/controller/global_controller.py Writes configured per-agent output policy refs into Redis for consumption by local controllers.
tests/test_output_policy.py Adds self-contained unit tests for output policy loading/dispatch and example-policy resolution.
tests/run_tests.sh Runs the new output-policy unit tests as part of the test script.
examples/policies/finance.py Provides example output policies for auditing and sensitive-token alerting.
examples/config/global_controller.yaml Demonstrates configuring output policies for FinanceAgent.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ventis/controller/local_controller.py Outdated
Comment on lines +211 to +215
ctx = dict(context or {})
ctx["request_id"] = request_id
ctx["service"] = service
ctx["function"] = function
return ctx
Comment on lines +451 to +456
# Run any output policies bound to this agent, automatically.
# Policies react to the result (audit / notify / enforce); they do
# not modify it, so ordering between them doesn't affect the output.
policy_ctx = self._policy_context(context, request_id, service, function)
self._run_output_policies(service, result, policy_ctx)

Comment on lines +257 to +258
for redis_client in targets:
redis_client.set(f"agent:{name}:output_policies", policies_json)
- _policy_context coerces a non-mapping context to empty instead of
  letting dict() raise (a non-dict context from baggage could otherwise
  abort _execute_locally and clobber a successful result).
- _run_output_policies hands policies a deepcopy of the output so a
  policy that mutates a dict/list in place cannot change what is
  serialized and written back; uncopyable outputs skip policies safely.
- Add tests for non-dict context coercion and in-place mutation safety.
@nickhuo

nickhuo commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @copilot — went through the three comments:

✅ Accepted — non-dict context could break result write-back (_policy_context, ~L215)
Correct. dict(context) on a non-mapping value (malformed baggage) would raise and, since it ran in _execute_locally's try, overwrite a successful agent result. Fixed by coercing a non-mapping context to {} (framework fields still set authoritatively). Test: test_policy_context_coerces_non_dict_context.

✅ Accepted — in-place mutation of the result (_run_output_policies, ~L456)
Correct. Policies are meant to be side-effect only, but a policy could mutate a dict/list result in place before serialization. _run_output_policies now hands policies a copy.deepcopy of the output; an uncopyable output skips policies rather than risk the write-back. Test: test_policy_cannot_mutate_caller_result.
(Re: the context-building being outside the try — _policy_context is now non-raising by construction, so the policy step can no longer clobber the result.)

❌ Declined — isolating per-Redis write failure in _write_output_policies (global_controller, ~L258)
Leaving as-is. The sibling startup writes (_build_routing_table, _write_resource_specs, _load_and_write_policies) also don't isolate Redis errors, and _write_output_policies runs after all of them — so if Redis were down, startup would already have aborted earlier. Isolating only this one write would be inconsistent and only helps in a razor-thin window. If we want fail-soft startup, it should be a holistic pass over all startup writes — tracked as a possible follow-up rather than done piecemeal here.

Fixes pushed in 55fcbf9. Full suite: 14/14.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants