Compare commits

...

5 Commits

Author SHA1 Message Date
Tomu Hirata aeabcccf6f style: ruff format sessions.py
Co-authored-by: Tomu Hirata
2026-06-22 11:43:09 +09:00
Tomu Hirata 6e81fe6a08 refactor(policies): derive ElicitationRequest.policy_name from policy_names
Remove the redundant policy_name field from ElicitationRequest and replace
it with a computed property returning policy_names[0]. policy_names is now
a required list[str] (non-optional) so the property always has a source.

- approval.py: single policy_names= kwarg replaces policy_name= + the
  conditional policy_names=; policy_names in SSE params now gated on
  len > 1 (consistent with "only include when informative")
- sessions.py: same consolidation for the native elicitation path
- test_approval.py: ElicitationRequest constructions updated to
  policy_names=[...]

Co-authored-by: Tomu Hirata
2026-06-22 11:40:21 +09:00
Tomu Hirata 8b35066b0c fix(policies): update remaining PolicyResult constructor call sites for deciding_policy removal
Removes the stale deciding_policy=None from the ALLOW result in engine.py
and updates test_sessions_policy.py + test_sessions_mcp_proxy_policy_retry.py
to pass deciding_policies=[...] instead of the removed deciding_policy= field.

Co-authored-by: Tomu Hirata
2026-06-22 11:34:40 +09:00
Tomu Hirata 34c0563f15 refactor(policies): derive deciding_policy from deciding_policies[0]
Remove the redundant `deciding_policy` field from `PolicyResult` and
replace it with a computed property returning `deciding_policies[0]`.

- All callers that read `.deciding_policy` continue to work unchanged.
- DENY results now pass `deciding_policies=[name]`; ASK results drop
  the explicit `deciding_policy=` kwarg from the engine.
- Test fixtures updated to construct with `deciding_policies=[...]`.
- `test_engine_last_data_wins_across_multiple_policies` replaced with
  `test_engine_data_chains_sequentially_across_policies`, verifying
  that each policy receives the previous policy's output as content.
- `test_ask_cycle_multiple_askers_combined_approval` gains an assertion
  that `deciding_policies` captures all three ASKing policy names.

Co-authored-by: Tomu Hirata
2026-06-22 11:28:26 +09:00
Tomu Hirata ac5117aa05 fix(policies): chain data transforms sequentially; track all deciding ASK policies
- Feed each policy's `data` result back as `ctx.content` so downstream
  policies in the evaluation chain transform the already-transformed
  payload rather than the original content.
- Replace the single `deciding_ask_policy` sentinel with a
  `deciding_ask_policies` list so all ASK-deciding policies are
  captured; expose them via `PolicyResult.deciding_policies`.
- Add `ElicitationRequest.policy_names` to surface all ASK policy
  names in the SSE elicitation event when multiple policies gate the
  same request.

Co-authored-by: Tomu Hirata
2026-06-22 11:22:11 +09:00
10 changed files with 149 additions and 78 deletions
+39 -14
View File
@@ -209,14 +209,15 @@ class PolicyResult:
accumulated and intends to apply on this decision
(filtering already done). ``None`` when the policy
wrote no labels, e.g. ``{"integrity": "0"}``.
:param deciding_policy: Name of the policy whose action
drove the composed result. Engine-set only —
single-policy results leave it ``None``. On DENY: the
first short-circuiting policy. On ASK: the first
ASKing policy in YAML order. On ALLOW: ``None``.
Powers the ``deciding_policy`` outer-span attribute
(POLICIES.md §11.5) and the per-policy ``ask_timeout``
lookup (§7.2).
:param deciding_policies: Names of all policies that drove
the composed result. Engine-set only — single-policy
results leave it ``None``. On DENY: a single-element
list with the short-circuiting policy. On ASK: all
ASKing policies in YAML order. On ALLOW: ``None``.
``deciding_policy`` is a computed property returning
``deciding_policies[0]`` (or ``None`` when unset);
all existing callers that read ``.deciding_policy``
work unchanged.
:param data: Optional replacement payload returned by the
policy callable. When present on an ALLOW result, the
enforcement site substitutes this value for the original
@@ -225,6 +226,10 @@ class PolicyResult:
phase). ``None`` means "use original content unchanged".
``Any`` because the shape varies by phase: a dict of
tool arguments on TOOL_CALL, a string on TOOL_RESULT.
When multiple policies transform data, each policy
receives the previous policy's output as its input —
the engine feeds the composed result back as
``ctx.content`` before dispatching to the next policy.
:param state_updates: Ordered list of :class:`StateUpdate`
operations to apply to the engine's ``session_state``.
Each entry specifies a key, an action (``SET``,
@@ -240,10 +245,19 @@ class PolicyResult:
action: PolicyAction
reason: str | None = None
set_labels: dict[str, str] | None = None
deciding_policy: str | None = None
deciding_policies: list[str] | None = None
data: Any = None
state_updates: list[StateUpdate] | None = None
@property
def deciding_policy(self) -> str | None:
"""First deciding policy name, or ``None``.
Derived from ``deciding_policies[0]`` so callers that
read ``.deciding_policy`` work without change.
"""
return self.deciding_policies[0] if self.deciding_policies else None
@dataclass(frozen=True)
class ElicitationRequest:
@@ -272,10 +286,12 @@ class ElicitationRequest:
e.g. ``"request"`` or ``"tool_call"``. Surfaces in the
elicitation event's extras so the renderer can label the
prompt.
:param policy_name: Name of the deciding (first-in-YAML-order)
ASKing policy. Drives per-policy ``ask_timeout`` lookup,
observability, and the renderer's "policy X says..." label.
e.g. ``"pii_redact"``.
:param policy_names: Names of all ASKing policies that contributed
to this elicitation, in YAML order. Always a non-empty list.
``policy_name`` is a computed property returning
``policy_names[0]`` — existing callers that read
``.policy_name`` work without change.
e.g. ``["pii_redact"]`` or ``["pii_redact", "cost_gate"]``.
:param content_preview: Truncated snapshot of the content being
gated. Lets a human reviewer see what they're approving
without overwhelming the UI on a 50 KB payload. Surfaces in
@@ -284,10 +300,19 @@ class ElicitationRequest:
message: str
phase: str
policy_name: str
policy_names: list[str]
content_preview: str
requested_schema: dict[str, Any] = field(default_factory=dict)
@property
def policy_name(self) -> str:
"""First ASKing policy name.
Derived from ``policy_names[0]`` so callers that read
``.policy_name`` work without change.
"""
return self.policy_names[0] if self.policy_names else ""
@dataclass
class PolicyLLMClient:
+3 -1
View File
@@ -128,7 +128,7 @@ async def _await_elicitation(
elicitation = ElicitationRequest(
message=result.reason or "",
phase=phase.value,
policy_name=result.deciding_policy or "",
policy_names=result.deciding_policies or [""],
content_preview=_truncate(content_preview, limit=1024),
)
params_json = build_elicitation_params_json(elicitation)
@@ -222,6 +222,8 @@ def build_elicitation_request_event(
"policy_name": elicitation.policy_name,
"content_preview": elicitation.content_preview,
}
if len(elicitation.policy_names) > 1:
params["policy_names"] = elicitation.policy_names
if url is not None:
params["url"] = url
+17 -10
View File
@@ -238,12 +238,17 @@ class PolicyEngine:
d. Action-list validation and the classifier-only
carve-out for FunctionPolicy and PromptPolicy.
e. Accumulate ``set_labels`` writes.
f. If the policy returned ``data``, feed it back
as ``ctx.content`` so the next policy transforms
the already-transformed payload (sequential
chaining across the pipeline).
2. On DENY: short-circuit. Apply accumulated writes
from any ALLOWing predecessors, then return the
DENY result (with ``deciding_policy`` set).
3. After the loop, if any policy ASKed: return an ASK
result carrying accumulated (but unapplied)
writes the caller applies them only on approve
writes and the full ``deciding_policies`` list —
the caller applies them only on approve
(POLICIES.md §7.2).
4. Otherwise: apply writes, return ALLOW.
@@ -267,10 +272,11 @@ class PolicyEngine:
accumulated: dict[str, str] = {}
accumulated_state: list[StateUpdate] = []
ask_reasons: list[str] = []
deciding_ask_policy: str | None = None
# Last non-None data from any ALLOW-or-ASK policy. If multiple
# policies return data, the last one wins — callers that need
# chained transforms should compose them in a single callable.
deciding_ask_policies: list[str] = []
# Sequentially accumulated data: each policy that returns data
# has its output fed back into ctx.content so the next policy
# in the chain transforms the already-transformed payload rather
# than the original. The final value is the fully-composed result.
composed_data: Any = None
context = self._context()
@@ -308,12 +314,14 @@ class PolicyEngine:
)
if result.data is not None:
composed_data = result.data
# Feed the transformed payload forward so the next policy
# in the chain sees this policy's output, not the original.
ctx = replace(ctx, content=composed_data)
if result.action == PolicyAction.ASK:
ask_reasons.append(
f"{policy.spec.name}: {result.reason or 'approval required'}",
)
if deciding_ask_policy is None:
deciding_ask_policy = policy.spec.name
deciding_ask_policies.append(policy.spec.name)
if ask_reasons:
# DO NOT apply label writes or state updates here — the ASK
@@ -326,7 +334,7 @@ class PolicyEngine:
reason="; ".join(ask_reasons),
set_labels=dict(accumulated) if accumulated else None,
state_updates=list(accumulated_state) if accumulated_state else None,
deciding_policy=deciding_ask_policy,
deciding_policies=deciding_ask_policies,
data=composed_data,
)
if not read_only:
@@ -337,7 +345,6 @@ class PolicyEngine:
reason=None,
set_labels=dict(accumulated) if accumulated else None,
state_updates=list(accumulated_state) if accumulated_state else None,
deciding_policy=None,
data=composed_data,
)
@@ -381,7 +388,7 @@ class PolicyEngine:
reason=reason,
set_labels=dict(accumulated) if accumulated else None,
state_updates=list(accumulated_state) if accumulated_state else None,
deciding_policy=deciding_policy,
deciding_policies=[deciding_policy],
)
def _should_fire(
+35 -14
View File
@@ -7563,19 +7563,30 @@ async def _forward_event_to_runner(
_resolve_message_content,
)
try:
forwarded_data["content"] = _resolve_message_content(
forwarded_data["content"],
file_store,
artifact_store,
session_id=session_id,
)
except (ValueError, KeyError):
_logger.warning(
"File reference resolution failed for session=%s",
session_id,
exc_info=True,
)
_unresolved = [
b for b in forwarded_data["content"] if isinstance(b, dict) and "file_id" in b
]
if _unresolved:
try:
forwarded_data["content"] = _resolve_message_content(
forwarded_data["content"],
file_store,
artifact_store,
session_id=session_id,
)
_logger.debug(
"Resolved %d file_id block(s) for session=%s before forwarding",
len(_unresolved),
session_id,
)
except (ValueError, KeyError):
_logger.warning(
"File reference resolution failed for session=%s "
"(unresolved file_id blocks will reach the runner unresolved — "
"runner will attempt fallback resolution)",
session_id,
exc_info=True,
)
# Flatten SessionEventInput {type, data} into the runner's
# discriminated-union shape {type, ...data_fields}. The runner's
@@ -9012,7 +9023,7 @@ async def _register_policy_elicitation(
message=result.reason or "Approval required",
requested_schema={},
phase=Phase.TOOL_CALL.value,
policy_name=result.deciding_policy or "unknown",
policy_names=result.deciding_policies or ["unknown"],
content_preview=arguments_preview[:1024],
)
# Approval state lives on the runner (in-memory
@@ -10601,6 +10612,8 @@ async def _create_session_from_existing_agent(
user_id: str | None = None,
permission_store: PermissionStore | None = None,
liveness_lookup: Callable[[list[str]], dict[str, SessionLiveness]] | None = None,
file_store: FileStore | None = None,
artifact_store: ArtifactStore | None = None,
) -> SessionResponse:
"""
Create a session bound to an already-registered agent.
@@ -10626,6 +10639,10 @@ async def _create_session_from_existing_agent(
``parent_session_id`` and session-scoped ``agent_id``.
:param liveness_lookup: Optional session-scoped liveness lookup
to populate ``SessionResponse.runner_online``.
:param file_store: Optional file metadata store for resolving
``file_id`` references in ``initial_items`` before forwarding
to the runner.
:param artifact_store: Optional binary content store for the same.
:returns: The newly created session snapshot.
:raises OmnigentError: 404 if no agent matches ``body.agent_id``;
403/404 if ``parent_session_id`` or session-scoped ``agent_id``
@@ -10898,6 +10915,8 @@ async def _create_session_from_existing_agent(
conversation_store,
runner_client,
agent_name=agent.name,
file_store=file_store,
artifact_store=artifact_store,
created_by=_attribution_user(user_id),
)
# Re-read rather than reusing the local ``conv``: the label-only branch
@@ -12202,6 +12221,8 @@ def create_sessions_router(
user_id=user_id,
permission_store=permission_store,
liveness_lookup=liveness_lookup,
file_store=file_store,
artifact_store=artifact_store,
)
# Notify the runner about the new session so it can resolve
# the spec and cache sub_agent_name before the first turn.
+6 -6
View File
@@ -113,7 +113,7 @@ def _composed_ask(
action=PolicyAction.ASK,
reason=reason,
set_labels=set_labels,
deciding_policy=deciding_policy,
deciding_policies=[deciding_policy],
)
@@ -248,7 +248,7 @@ def test_params_json_serializes_all_fields() -> None:
req = ElicitationRequest(
message="needs review",
phase="tool_call",
policy_name="confirm_shell",
policy_names=["confirm_shell"],
content_preview="ls -la",
)
data = json.loads(_params_json(req))
@@ -274,7 +274,7 @@ def test_elicitation_request_event_shape() -> None:
req = ElicitationRequest(
message="needs review",
phase="tool_call",
policy_name="confirm_shell",
policy_names=["confirm_shell"],
content_preview="ls -la",
)
event = _elicitation_request_event("elicit_xyz", req)
@@ -308,7 +308,7 @@ def test_elicitation_request_event_url_mode(monkeypatch: pytest.MonkeyPatch) ->
req = ElicitationRequest(
message="approve shell?",
phase="tool_call",
policy_name="shell_gate",
policy_names=["shell_gate"],
content_preview="rm -rf /",
)
event = _elicitation_request_event("elicit_abc", req, session_id="conv_123")
@@ -328,7 +328,7 @@ def test_elicitation_request_event_form_mode_explicit(monkeypatch: pytest.Monkey
req = ElicitationRequest(
message="approve?",
phase="tool_call",
policy_name="gate",
policy_names=["gate"],
content_preview="ls",
)
event = _elicitation_request_event("elicit_abc", req, session_id="conv_123")
@@ -348,7 +348,7 @@ def test_elicitation_request_event_no_session_id_stays_form(
req = ElicitationRequest(
message="approve?",
phase="tool_call",
policy_name="gate",
policy_names=["gate"],
content_preview="",
)
event = _elicitation_request_event("elicit_abc", req)
+3 -1
View File
@@ -317,8 +317,10 @@ async def test_ask_cycle_multiple_askers_combined_approval(
result, approved = await _run_ask_cycle(engine, ctx, harness)
assert approved is True
# First-ASKer-in-YAML wins deciding_policy.
# deciding_policy is derived from deciding_policies[0].
assert result.deciding_policy == "first"
# All three ASKing policies are captured in deciding_policies.
assert result.deciding_policies == ["first", "second", "third"]
# Combined reason mentions all three policies.
assert "first:" in result.reason
assert "second:" in result.reason
+30 -19
View File
@@ -1092,28 +1092,36 @@ async def test_engine_propagates_data_to_composed_allow(
@pytest.mark.asyncio
async def test_engine_last_data_wins_across_multiple_policies(
async def test_engine_data_chains_sequentially_across_policies(
conversation_store: SqlAlchemyConversationStore,
) -> None:
"""When multiple policies return ``data``, the last one wins.
"""Each policy that returns ``data`` receives the previous
policy's output as ``event["data"]`` (i.e. ``ctx.content``),
not the original content.
Rationale: each subsequent policy in the chain operates on
the context, and the final transform is the one the enforcement
site should apply. Callers that need ordered chaining must
compose that in a single callable.
The canonical use case: a two-stage redaction pipeline where
the first policy scrubs PII and the second strips secrets —
the second policy must see the already-PII-scrubbed payload,
not the raw original.
"""
first_data = {"query": "first-transform"}
last_data = {"query": "last-transform"}
seen_by_second: list[dict] = []
def first(_event: dict) -> PolicyResult:
return PolicyResult(
action=PolicyAction.ALLOW,
data={"query": "after-first"},
)
def second(event: dict) -> PolicyResult:
seen_by_second.append(event["data"])
return PolicyResult(
action=PolicyAction.ALLOW,
data={"query": "after-second"},
)
policies = [
FunctionPolicy(
_spec(name="first", phase=Phase.TOOL_CALL),
lambda event: PolicyResult(action=PolicyAction.ALLOW, data=first_data),
),
FunctionPolicy(
_spec(name="last", phase=Phase.TOOL_CALL),
lambda event: PolicyResult(action=PolicyAction.ALLOW, data=last_data),
),
FunctionPolicy(_spec(name="first", phase=Phase.TOOL_CALL), first),
FunctionPolicy(_spec(name="second", phase=Phase.TOOL_CALL), second),
]
engine = _build_engine(conversation_store, policies)
result = await engine.evaluate(
@@ -1124,10 +1132,13 @@ async def test_engine_last_data_wins_across_multiple_policies(
)
)
assert result.action == PolicyAction.ALLOW
assert result.data == last_data, (
f"Last policy's data must win; got {result.data!r}. "
f"If 'first-transform', the engine kept the first data instead of the last."
# The second policy must have seen the first policy's output.
assert seen_by_second == [{"query": "after-first"}], (
f"Second policy must receive first policy's data as content; "
f"got {seen_by_second!r}. If 'original', the engine didn't chain."
)
# The composed result carries the last transform in the chain.
assert result.data == {"query": "after-second"}
# ── Gap 7: legacy (content, phase) callable shim ─────────────────────────────
@@ -42,6 +42,7 @@ def test_policy_result_defaults() -> None:
assert r.reason is None
assert r.set_labels is None
assert r.deciding_policy is None
assert r.deciding_policies is None
def test_policy_result_full_construction() -> None:
@@ -50,13 +51,13 @@ def test_policy_result_full_construction() -> None:
action=PolicyAction.DENY,
reason="blocked",
set_labels={"a": "1"},
deciding_policy="p",
deciding_policies=["p"],
)
r2 = PolicyResult(
action=PolicyAction.DENY,
reason="blocked",
set_labels={"a": "1"},
deciding_policy="p",
deciding_policies=["p"],
)
# Equality is structural — two results with the same
# fields compare equal.
@@ -71,12 +72,14 @@ def test_policy_result_inequality_on_action() -> None:
def test_policy_result_inequality_on_deciding_policy() -> None:
"""deciding_policy participates in equality — used by
observability to distinguish identical-reason results
from different sources."""
a = PolicyResult(action=PolicyAction.DENY, deciding_policy="a")
b = PolicyResult(action=PolicyAction.DENY, deciding_policy="b")
"""deciding_policy (derived from deciding_policies[0]) participates
in equality via deciding_policies — used by observability to
distinguish identical-reason results from different sources."""
a = PolicyResult(action=PolicyAction.DENY, deciding_policies=["a"])
b = PolicyResult(action=PolicyAction.DENY, deciding_policies=["b"])
assert a != b
assert a.deciding_policy == "a"
assert b.deciding_policy == "b"
# ── Evaluation context shape ──────────────────────────
@@ -254,7 +254,7 @@ async def test_forged_retry_with_ask_policy_rejects_unknown_elicitation(
result=PolicyResult(
action=PolicyAction.ASK,
reason="approval required",
deciding_policy="test-gate",
deciding_policies=["test-gate"],
)
)
@@ -420,7 +420,7 @@ async def test_legitimate_retry_with_pending_entry_proceeds(
result=PolicyResult(
action=PolicyAction.ASK,
reason="approval required",
deciding_policy="test-gate",
deciding_policies=["test-gate"],
)
)
+4 -4
View File
@@ -304,7 +304,7 @@ async def test_pending_verdict_registers_elicitation():
ask_result = PolicyResult(
action=PolicyAction.ASK,
reason="Requires user approval",
deciding_policy="approve_shell",
deciding_policies=["approve_shell"],
)
async def _eval(_ctx: Any) -> PolicyResult:
@@ -362,7 +362,7 @@ async def test_pending_verdict_carries_per_policy_ask_timeout():
ask_result = PolicyResult(
action=PolicyAction.ASK,
reason="Requires user approval",
deciding_policy="approve_shell",
deciding_policies=["approve_shell"],
)
async def _eval(_ctx: Any) -> PolicyResult:
@@ -685,7 +685,7 @@ async def test_input_ask_approved_falls_through_to_allow():
ask_result = PolicyResult(
action=PolicyAction.ASK,
reason="Deleting files requires approval",
deciding_policy="llm_prompt_classifier_policy",
deciding_policies=["llm_prompt_classifier_policy"],
)
async def _eval(_ctx: Any) -> PolicyResult:
@@ -763,7 +763,7 @@ async def test_input_ask_declined_denies():
ask_result = PolicyResult(
action=PolicyAction.ASK,
reason="Deleting files requires approval",
deciding_policy="llm_prompt_classifier_policy",
deciding_policies=["llm_prompt_classifier_policy"],
)
async def _eval(_ctx: Any) -> PolicyResult: