Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ GitHub Releases page; `0.8.0` is the new starting line.
- **Auto-mode destructive actions deliberate per turn and context.** Auto-deliberation now
scopes destructive-command one-shots to the active execution context and LLM generation,
so duplicate destructive calls in one response keep bouncing while later deliberate retries
and isolated subagent calls are handled independently.
and isolated subagent calls are handled independently. If a destructive action is ever
evaluated without that turn context, the gate now fails closed — it keeps deliberating
rather than auto-approving the action.
- **Release packaging keeps SDK/core pins in lockstep.** The SDK's `pythinker-core`
dependency is now updated by release automation and checked by CI/release validation,
preventing no-sources binary builds from resolving against a stale core pin.
Expand Down
4 changes: 3 additions & 1 deletion docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ GitHub Releases page; `0.8.0` is the new starting line.
- **Auto-mode destructive actions deliberate per turn and context.** Auto-deliberation now
scopes destructive-command one-shots to the active execution context and LLM generation,
so duplicate destructive calls in one response keep bouncing while later deliberate retries
and isolated subagent calls are handled independently.
and isolated subagent calls are handled independently. If a destructive action is ever
evaluated without that turn context, the gate now fails closed — it keeps deliberating
rather than auto-approving the action.
- **Release packaging keeps SDK/core pins in lockstep.** The SDK's `pythinker-core`
dependency is now updated by release automation and checked by CI/release validation,
preventing no-sources binary builds from resolving against a stale core pin.
Expand Down
24 changes: 19 additions & 5 deletions src/pythinker_code/soul/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,21 +275,35 @@ def deliberation_gate(self, tool_call: ToolCall) -> str | None:
if reason is None:
return None
scope = _current_deliberation_scope.get()
context_id = scope.context_id if scope is not None else "unscoped"
generation = scope.generation if scope is not None else 0
fingerprint = self._deliberation_fingerprint(context_id, tool_call.function.name, arguments)
if scope is None:
# Defensive fallback. The production path (PythinkerSoul._step) always binds a
# scope around step + tool-result collection, and the only caller of this gate
# (Approval.request, via a tool future created inside that scope) inherits it.
# Reaching here means a destructive call was gated with no turn-boundary signal,
# so we cannot distinguish a same-response duplicate from a deliberated re-issue.
# Fail CLOSED: keep bouncing rather than auto-approving a destructive action we
# cannot prove was deliberated. Surface it loudly — it indicates a wiring bug.
logger.warning(
"deliberation_gate reached without a deliberation scope for {tool_name}; "
"bouncing fail-closed (no turn boundary to authorize a retry)",
tool_name=tool_call.function.name,
)
return reason
fingerprint = self._deliberation_fingerprint(
scope.context_id, tool_call.function.name, arguments
)
# One-shot keyed by (execution context, generation): the first sighting and any
# same-generation duplicate are bounced; only a re-issue in a strictly LATER
# generation of the same context is let through once. The context_id prefix prevents
# a subagent's identical call from consuming the main agent's one-shot (state is
# shared via Approval.share()).
prior_generation = self._state.deliberated_fingerprints.get(fingerprint)
if prior_generation is not None:
if prior_generation < generation:
if prior_generation < scope.generation:
del self._state.deliberated_fingerprints[fingerprint]
return None
return reason
self._state.deliberated_fingerprints[fingerprint] = generation
self._state.deliberated_fingerprints[fingerprint] = scope.generation
return reason

async def request(
Expand Down
13 changes: 13 additions & 0 deletions tests/core/test_approval_auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,19 @@ def test_older_generation_duplicate_destructive_call_still_bounces() -> None:
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None


def test_unscoped_destructive_calls_always_bounce_fail_closed() -> None:
# No deliberation scope means no turn-boundary signal to authorize a retry. The gate
# fails CLOSED: every sighting (including identical re-issues) bounces, never auto-
# approving a destructive action it cannot prove was deliberated. Production always
# binds a scope, so reaching this path is a wiring bug, not an expected flow.
approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True))
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
# Fail-closed bounce must not accumulate state for the unscoped path.
assert approval._state.deliberated_fingerprints == {}


def test_deliberation_gate_conditions() -> None:
"""The gate fires only when the feature is on, we would otherwise auto-approve
(auto OR yolo), and the command is destructive."""
Expand Down
Loading