Skip to content

Commit daa7c2c

Browse files
authored
Merge pull request #216 from zts212653/fix/issue-111-leftover-p1-p2
fix(tool): resolve AFTER_TOOL.approved semantic pollution
2 parents d78b0a5 + 52e35ce commit daa7c2c

4 files changed

Lines changed: 45 additions & 18 deletions

File tree

‎dare_framework/agent/_internal/tool_executor.py‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ async def run_tool_loop(
117117
"attempt": attempts,
118118
"success": False,
119119
"error": policy_error,
120-
"approved": True,
120+
"approved": False,
121+
"policy_decision": (
122+
"hook_ask" if before_tool_dispatch.decision is HookDecision.ASK
123+
else "hook_block"
124+
),
121125
"evidence_collected": False,
122126
"duration_ms": (time.perf_counter() - tool_start) * 1000.0,
123127
"budget_stats": agent._budget_stats(),
@@ -347,15 +351,16 @@ async def approval_observer(payload: dict[str, Any]) -> None:
347351
if hasattr(result, "success") and not result.success:
348352
tool_success = False
349353
evidence_collected = bool(getattr(result, "evidence", []))
350-
denied_status = "fail"
351354
denied_output = getattr(result, "output", {})
352-
approved = True
353-
if not tool_success:
354-
if isinstance(denied_output, dict):
355-
candidate_status = denied_output.get("status")
356-
if isinstance(candidate_status, str) and candidate_status:
357-
denied_status = candidate_status
358-
approved = denied_status != "not_allow"
355+
# Derive "approved" from both the pre-invocation policy decision
356+
# AND the runtime approval outcome. When policy_decision is
357+
# "approve_required", the GovernedToolGateway may still deny at
358+
# runtime (returning output.status="not_allow"), so we must also
359+
# check the tool result for an explicit denial signal.
360+
approved = policy_decision != PolicyDecision.DENY.value
361+
if not tool_success and isinstance(denied_output, dict):
362+
if denied_output.get("status") == "not_allow":
363+
approved = False
359364
await agent._emit_hook(
360365
HookPhase.AFTER_TOOL,
361366
{
@@ -374,9 +379,14 @@ async def approval_observer(payload: dict[str, Any]) -> None:
374379
)
375380

376381
if not tool_success:
382+
tool_fail_status = "fail"
383+
if isinstance(denied_output, dict):
384+
candidate = denied_output.get("status")
385+
if isinstance(candidate, str) and candidate:
386+
tool_fail_status = candidate
377387
return {
378388
"success": False,
379-
"status": denied_status,
389+
"status": tool_fail_status,
380390
"error": result.error or "tool failed",
381391
"output": denied_output,
382392
"result": result,
@@ -421,14 +431,13 @@ async def approval_observer(payload: dict[str, Any]) -> None:
421431
"policy_decision": policy_decision,
422432
},
423433
)
424-
approved = True
425-
try:
426-
from dare_framework.tool.exceptions import HumanApprovalRequired
434+
# Derive approved from the policy decision made before execution,
435+
# not from the exception type. Infra errors do not revoke approval.
436+
approved = policy_decision != PolicyDecision.DENY.value
437+
from dare_framework.tool.exceptions import HumanApprovalRequired
427438

428-
if isinstance(exc, HumanApprovalRequired):
429-
approved = False
430-
except Exception:
431-
approved = True
439+
if isinstance(exc, HumanApprovalRequired):
440+
approved = False
432441
await agent._emit_hook(
433442
HookPhase.AFTER_TOOL,
434443
{

‎dare_framework/agent/dare_agent.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ async def _emit_after_tool(
715715
error: str | None,
716716
approved: bool,
717717
evidence_collected: bool,
718+
policy_decision: str | None = None,
718719
) -> None:
719720
await self._emit_hook(
720721
HookPhase.AFTER_TOOL,
@@ -726,6 +727,7 @@ async def _emit_after_tool(
726727
"success": success,
727728
"error": error,
728729
"approved": approved,
730+
"policy_decision": policy_decision,
729731
"evidence_collected": evidence_collected,
730732
"duration_ms": (time.perf_counter() - tool_start) * 1000.0,
731733
"budget_stats": self._budget_stats(),
@@ -807,8 +809,12 @@ async def _emit_after_tool(
807809
await _emit_after_tool(
808810
success=False,
809811
error=policy_error,
810-
approved=True,
812+
approved=False,
811813
evidence_collected=False,
814+
policy_decision=(
815+
"hook_ask" if before_tool_dispatch.decision is HookDecision.ASK
816+
else "hook_block"
817+
),
812818
)
813819
return StepResult(
814820
step_id=step.step_id,
@@ -868,6 +874,7 @@ async def _emit_after_tool(
868874
error=error_text,
869875
approved=True,
870876
evidence_collected=False,
877+
policy_decision="allow",
871878
)
872879
return StepResult(
873880
step_id=step.step_id,
@@ -885,6 +892,7 @@ async def _emit_after_tool(
885892
error=result_error,
886893
approved=True,
887894
evidence_collected=bool(result.evidence),
895+
policy_decision="allow",
888896
)
889897
return result
890898

@@ -894,6 +902,7 @@ async def _emit_after_tool(
894902
error=invalid_error,
895903
approved=True,
896904
evidence_collected=False,
905+
policy_decision="allow",
897906
)
898907
return StepResult(
899908
step_id=step.step_id,

‎dare_framework/observability/_internal/tracing_hook.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ async def _on_after_tool(self, payload: dict[str, Any]) -> None:
371371
success = bool(payload.get("success", False))
372372
error = payload.get("error")
373373
approved = payload.get("approved", True)
374+
policy_decision = payload.get("policy_decision")
374375
evidence_collected = payload.get("evidence_collected", False)
375376

376377
span = self._end_span(
@@ -382,6 +383,8 @@ async def _on_after_tool(self, payload: dict[str, Any]) -> None:
382383
span.set_attribute("success", success)
383384
span.set_attribute(DAREAttributes.TOOL_APPROVED, approved)
384385
span.set_attribute(DAREAttributes.TOOL_EVIDENCE_COLLECTED, evidence_collected)
386+
if policy_decision is not None:
387+
span.set_attribute("dare.tool.policy_decision", policy_decision)
385388
if error:
386389
span.set_attribute(GenAIAttributes.ERROR_TYPE, type(error).__name__)
387390
span.set_attribute(GenAIAttributes.ERROR_MESSAGE, str(error))

‎dare_framework/transport/types.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ class ActionPayload(EnvelopePayload):
9191
code: str | None = None
9292
reason: str | None = None
9393

94+
def __post_init__(self) -> None:
95+
if not isinstance(self.resource_action, str):
96+
raise TypeError(
97+
f"invalid resource_action type: {type(self.resource_action).__name__}"
98+
)
99+
94100

95101
@dataclass(frozen=True)
96102
class ControlPayload(EnvelopePayload):

0 commit comments

Comments
 (0)