Skip to content

Commit 7ac3b9c

Browse files
authored
fix(approval): destructive-tool registry (#56)
* refactor(approval): make destructive-tool classification a registry seam * feat(approval): add DeliberationScope contextvar primitive * fix(approval): scope deliberation one-shot to context + generation * feat(approval): bind deliberation scope around per-step tool execution * fix(approval): propagate deliberation scope into tool futures * fix(approval): preserve newer deliberation generations * docs(changelog): note auto-deliberation scoping
1 parent a234fa9 commit 7ac3b9c

7 files changed

Lines changed: 244 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Auto-mode destructive actions deliberate per turn and context.** Auto-deliberation now
19+
scopes destructive-command one-shots to the active execution context and LLM generation,
20+
so duplicate destructive calls in one response keep bouncing while later deliberate retries
21+
and isolated subagent calls are handled independently.
1822
- **Release packaging keeps SDK/core pins in lockstep.** The SDK's `pythinker-core`
1923
dependency is now updated by release automation and checked by CI/release validation,
2024
preventing no-sources binary builds from resolving against a stale core pin.

docs/en/release-notes/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
## Unreleased
1919

20+
- **Auto-mode destructive actions deliberate per turn and context.** Auto-deliberation now
21+
scopes destructive-command one-shots to the active execution context and LLM generation,
22+
so duplicate destructive calls in one response keep bouncing while later deliberate retries
23+
and isolated subagent calls are handled independently.
2024
- **Release packaging keeps SDK/core pins in lockstep.** The SDK's `pythinker-core`
2125
dependency is now updated by release automation and checked by CI/release validation,
2226
preventing no-sources binary builds from resolving against a stale core pin.

src/pythinker_code/soul/approval.py

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import json
44
import uuid
5-
from collections.abc import Callable
5+
from collections.abc import Callable, Generator
6+
from contextlib import contextmanager
7+
from contextvars import ContextVar
8+
from dataclasses import dataclass
69
from typing import Literal
710

811
from pythinker_core.utils.typing import JsonType
@@ -30,6 +33,33 @@
3033
)
3134

3235

36+
@dataclass(frozen=True)
37+
class DeliberationScope:
38+
"""Execution context + LLM generation a deliberation decision is scoped to.
39+
40+
``context_id`` separates the main agent from each subagent (approval state is shared
41+
via ``Approval.share()``); ``generation`` is the step number within that context.
42+
"""
43+
44+
context_id: str
45+
generation: int
46+
47+
48+
_current_deliberation_scope: ContextVar[DeliberationScope | None] = ContextVar(
49+
"deliberation_scope", default=None
50+
)
51+
52+
53+
@contextmanager
54+
def deliberation_scope(context_id: str, generation: int) -> Generator[None, None, None]:
55+
"""Bind the active deliberation scope for the duration of one step's tool execution."""
56+
token = _current_deliberation_scope.set(DeliberationScope(context_id, generation))
57+
try:
58+
yield
59+
finally:
60+
_current_deliberation_scope.reset(token)
61+
62+
3363
class ApprovalResult:
3464
"""Result of an approval request. Behaves as bool for backward compatibility."""
3565

@@ -107,8 +137,9 @@ def __init__(
107137
"""Set of action names that should automatically be approved."""
108138
self.approved_orchestration_fingerprints: set[str] = set()
109139
"""RunAgents orchestration shapes approved for this in-memory session."""
110-
self.deliberated_fingerprints: set[str] = set()
111-
"""Destructive (tool, command) shapes already bounced once; the re-issue runs."""
140+
self.deliberated_fingerprints: dict[str, int] = {}
141+
"""Maps a context-namespaced destructive fingerprint to the generation it was last
142+
bounced at; a re-issue in a later generation of the same context consumes it once."""
112143
self._on_change = on_change
113144

114145
def notify_change(self) -> None:
@@ -211,10 +242,12 @@ def _tool_arguments(tool_call: ToolCall) -> dict[str, JsonType] | None:
211242
return args if isinstance(args, dict) else None
212243

213244
@staticmethod
214-
def _deliberation_fingerprint(tool_name: str, arguments: dict[str, JsonType]) -> str:
215-
"""Stable, tool-agnostic identity for a destructive call (name + sorted args)."""
245+
def _deliberation_fingerprint(
246+
context_id: str, tool_name: str, arguments: dict[str, JsonType]
247+
) -> str:
248+
"""Context-namespaced identity for a destructive call (context + name + sorted args)."""
216249
encoded = json.dumps(arguments, sort_keys=True, separators=(",", ":"))
217-
return f"{tool_name}::{encoded}"
250+
return f"{context_id}::{tool_name}::{encoded}"
218251

219252
def deliberation_gate(self, tool_call: ToolCall) -> str | None:
220253
"""Reason a destructive auto-approved action must deliberate once, else ``None``.
@@ -223,9 +256,11 @@ def deliberation_gate(self, tool_call: ToolCall) -> str | None:
223256
auto-approved (auto *or* yolo — so it gates ahead of the yolo bypass), and the
224257
tool call is destructive per the tool-agnostic classifier in ``permission``
225258
(today only ``Shell``; other destructive tools register their classifier there).
226-
One-shot: the first occurrence is bounced for the agent to weigh alternatives;
227-
the identical re-issue is let through once, so a deliberated ``rm -rf`` runs
228-
without being permanently whitelisted.
259+
One-shot, scoped to (execution context, generation): the first sighting and any
260+
same-generation duplicate are bounced; only a re-issue in a later generation of the
261+
same context is let through once, so a deliberated ``rm -rf`` runs without being
262+
permanently whitelisted, while two identical calls in one model response both
263+
deliberate and a subagent cannot consume the main agent's one-shot.
229264
"""
230265
if not self._state.auto_deliberate:
231266
return None
@@ -239,20 +274,22 @@ def deliberation_gate(self, tool_call: ToolCall) -> str | None:
239274
reason = tool_destructive_reason(tool_call.function.name, arguments)
240275
if reason is None:
241276
return None
242-
fingerprint = self._deliberation_fingerprint(tool_call.function.name, arguments)
243-
# NOTE (known limitation, tracked): the one-shot is keyed only by the
244-
# (tool, command) fingerprint, not by an assistant-turn boundary. If a
245-
# model emits two byte-identical destructive calls within the SAME
246-
# response (no intervening deliberation turn), the second consumes the
247-
# one-shot and runs. Distinguishing that from a genuine re-issue needs a
248-
# turn/generation signal not plumbed into the approval layer; spec §6 #2
249-
# treats the one-shot as an open decision. Only reachable when a user has
250-
# opted into the auto_deliberate policy (not a default), and requires the
251-
# model to emit identical destructive calls in one response.
252-
if fingerprint in self._state.deliberated_fingerprints:
253-
self._state.deliberated_fingerprints.discard(fingerprint) # consume one-shot
254-
return None
255-
self._state.deliberated_fingerprints.add(fingerprint)
277+
scope = _current_deliberation_scope.get()
278+
context_id = scope.context_id if scope is not None else "unscoped"
279+
generation = scope.generation if scope is not None else 0
280+
fingerprint = self._deliberation_fingerprint(context_id, tool_call.function.name, arguments)
281+
# One-shot keyed by (execution context, generation): the first sighting and any
282+
# same-generation duplicate are bounced; only a re-issue in a strictly LATER
283+
# generation of the same context is let through once. The context_id prefix prevents
284+
# a subagent's identical call from consuming the main agent's one-shot (state is
285+
# shared via Approval.share()).
286+
prior_generation = self._state.deliberated_fingerprints.get(fingerprint)
287+
if prior_generation is not None:
288+
if prior_generation < generation:
289+
del self._state.deliberated_fingerprints[fingerprint]
290+
return None
291+
return reason
292+
self._state.deliberated_fingerprints[fingerprint] = generation
256293
return reason
257294

258295
async def request(

src/pythinker_code/soul/permission.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import re
44
import shlex
5+
from collections.abc import Callable
56
from contextvars import ContextVar, Token
67
from dataclasses import dataclass
78
from typing import TYPE_CHECKING, Any, Literal
@@ -497,18 +498,27 @@ def shell_destructive_reason(command: str) -> str | None:
497498
return None
498499

499500

501+
def _shell_args_destructive_reason(arguments: dict[str, Any]) -> str | None:
502+
command = arguments.get("command")
503+
return shell_destructive_reason(command) if isinstance(command, str) else None
504+
505+
506+
# The single, auditable place where a tool opts into auto-deliberation. Today only the
507+
# irreversible surface (Shell, including background shell — same tool name) is classified;
508+
# reversible file tools (WriteFile/StrReplaceFile: restore-point + VCS backed) are
509+
# intentionally excluded. A future destructive tool adds one entry here.
510+
_DESTRUCTIVE_CLASSIFIERS: dict[str, Callable[[dict[str, Any]], str | None]] = {
511+
"Shell": _shell_args_destructive_reason,
512+
}
513+
514+
500515
def tool_destructive_reason(tool_name: str, arguments: dict[str, Any]) -> str | None:
501516
"""Reason a tool call is irreversibly destructive (warrants deliberation), else ``None``.
502517
503-
Tool-agnostic dispatch point for the auto-deliberation gate. Today only ``Shell``
504-
is classified; a future destructive tool registers its own argument classifier
505-
here instead of the gate hard-coding a single tool name.
518+
Declarative dispatch: classification lives in ``_DESTRUCTIVE_CLASSIFIERS``.
506519
"""
507-
if tool_name == "Shell":
508-
command = arguments.get("command")
509-
if isinstance(command, str):
510-
return shell_destructive_reason(command)
511-
return None
520+
classifier = _DESTRUCTIVE_CLASSIFIERS.get(tool_name)
521+
return classifier(arguments) if classifier is not None else None
512522

513523

514524
def _segment_destructive_reason(tokens: list[str]) -> str | None:

src/pythinker_code/soul/pythinkersoul.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
wire_send,
5252
)
5353
from pythinker_code.soul.agent import Agent, Runtime
54+
from pythinker_code.soul.approval import deliberation_scope
5455
from pythinker_code.soul.compaction import (
5556
CompactionResult,
5657
SimpleCompaction,
@@ -311,6 +312,8 @@ def __init__(
311312
self._approval = agent.runtime.approval
312313
self._context = context
313314
self._loop_control = agent.runtime.config.loop_control
315+
self._current_step_no = 0
316+
self._deliberation_generation = 0
314317
self._sleep_inhibitor = SleepInhibitor(enabled=agent.runtime.config.prevent_idle_sleep)
315318
self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
316319

@@ -1329,6 +1332,19 @@ async def _step(self) -> StepOutcome | None:
13291332
# already checked in `run`
13301333
assert self._runtime.llm is not None
13311334
chat_provider = self._runtime.llm.chat_provider
1335+
self._deliberation_generation += 1
1336+
deliberation_generation = self._deliberation_generation
1337+
approval_source = get_current_approval_source_or_none()
1338+
if approval_source is not None:
1339+
deliberation_context_id = f"{approval_source.kind}:{approval_source.id}"
1340+
if approval_source.agent_id is not None:
1341+
deliberation_context_id = f"{deliberation_context_id}:{approval_source.agent_id}"
1342+
elif self._runtime.subagent_id is not None:
1343+
deliberation_context_id = self._runtime.subagent_id
1344+
elif self._runtime.role == "root":
1345+
deliberation_context_id = "root"
1346+
else:
1347+
deliberation_context_id = f"subagent:{self._runtime.session.id}"
13321348

13331349
if self._runtime.role == "root":
13341350

@@ -1394,14 +1410,15 @@ async def _run_step_once() -> StepResult:
13941410
permission_profile_for_runtime(self._runtime)
13951411
)
13961412
try:
1397-
step_result = await pythinker_core.step(
1398-
chat_provider,
1399-
self._agent.system_prompt,
1400-
self._agent.toolset,
1401-
effective_history,
1402-
on_message_part=wire_send,
1403-
on_tool_result=wire_send,
1404-
)
1413+
with deliberation_scope(deliberation_context_id, deliberation_generation):
1414+
step_result = await pythinker_core.step(
1415+
chat_provider,
1416+
self._agent.system_prompt,
1417+
self._agent.toolset,
1418+
effective_history,
1419+
on_message_part=wire_send,
1420+
on_tool_result=wire_send,
1421+
)
14051422
finally:
14061423
reset_step_permission_profile(profile_token)
14071424
except Exception as exc:
@@ -1491,7 +1508,12 @@ async def _pythinker_core_step_with_retry() -> StepResult:
14911508

14921509
# wait for all tool results (may be interrupted)
14931510
plan_mode_before_tools = self._plan_mode
1494-
results = await result.tool_results()
1511+
# Scope the deliberation one-shot to this context + step. Tool futures normally
1512+
# inherit this ContextVar when created during pythinker_core.step above; keeping
1513+
# it bound here also covers any future implementation that starts work lazily in
1514+
# tool_results().
1515+
with deliberation_scope(deliberation_context_id, deliberation_generation):
1516+
results = await result.tool_results()
14951517
logger.debug("Got tool results: {results}", results=results)
14961518

14971519
# If a tool (EnterPlanMode/ExitPlanMode) changed plan mode during execution,

tests/core/test_approval_auto.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import json
66

7-
from pythinker_code.soul.approval import Approval, ApprovalState
7+
from pythinker_code.soul.approval import Approval, ApprovalState, deliberation_scope
88
from pythinker_code.wire.types import ToolCall
99

1010

@@ -15,6 +15,39 @@ def _shell_call(cmd: str) -> ToolCall:
1515
)
1616

1717

18+
def test_tool_destructive_reason_gates_background_shell() -> None:
19+
from pythinker_code.soul.permission import tool_destructive_reason
20+
21+
# Background shell is the same "Shell" tool (run_in_background=true); a destructive
22+
# background command must still be classified as destructive.
23+
reason = tool_destructive_reason(
24+
"Shell", {"command": "rm -rf build", "run_in_background": True}
25+
)
26+
assert reason is not None
27+
28+
29+
def test_tool_destructive_reason_ignores_unregistered_tool() -> None:
30+
from pythinker_code.soul.permission import tool_destructive_reason
31+
32+
assert (
33+
tool_destructive_reason("WriteFile", {"path": "x", "content": "y", "mode": "overwrite"})
34+
is None
35+
)
36+
37+
38+
def test_deliberation_scope_sets_and_restores_contextvar() -> None:
39+
from pythinker_code.soul.approval import (
40+
DeliberationScope,
41+
_current_deliberation_scope,
42+
deliberation_scope,
43+
)
44+
45+
assert _current_deliberation_scope.get() is None
46+
with deliberation_scope("root", 3):
47+
assert _current_deliberation_scope.get() == DeliberationScope("root", 3)
48+
assert _current_deliberation_scope.get() is None
49+
50+
1851
def test_yolo_only() -> None:
1952
approval = Approval(yolo=True)
2053
assert approval.is_yolo() is True
@@ -117,19 +150,43 @@ def test_set_auto_false_clears_runtime_auto() -> None:
117150

118151

119152
def test_destructive_action_deliberates_once_then_proceeds_under_auto() -> None:
120-
"""auto + auto_deliberate: a destructive Shell command deliberates the first
121-
time, the identical re-issue runs once (one-shot retry), and a third issue
122-
deliberates again — so deliberation never permanently whitelists ``rm -rf``."""
153+
"""auto + auto_deliberate: a destructive command deliberates the first time, the
154+
re-issue in a LATER generation runs once, and a fresh issue later deliberates again."""
123155
approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True))
156+
with deliberation_scope("root", 1):
157+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
158+
with deliberation_scope("root", 2):
159+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is None
160+
with deliberation_scope("root", 3):
161+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
124162

125-
first = approval.deliberation_gate(_shell_call("rm -rf build"))
126-
assert first is not None, "first destructive issue should deliberate"
127163

128-
second = approval.deliberation_gate(_shell_call("rm -rf build"))
129-
assert second is None, "identical re-issue is the one-shot retry: allowed through"
164+
def test_same_generation_duplicate_destructive_calls_both_bounce() -> None:
165+
# Property (a): two byte-identical destructive calls in ONE generation both deliberate.
166+
approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True))
167+
with deliberation_scope("root", 1):
168+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
169+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
170+
130171

131-
third = approval.deliberation_gate(_shell_call("rm -rf build"))
132-
assert third is not None, "one-shot consumed; a fresh issue deliberates again"
172+
def test_subagent_identical_call_does_not_consume_main_one_shot() -> None:
173+
# Property (c): a subagent's identical call must not ride on the main agent's bounce.
174+
approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True))
175+
with deliberation_scope("root", 1):
176+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
177+
with deliberation_scope("sub-1", 1):
178+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
179+
180+
181+
def test_older_generation_duplicate_destructive_call_still_bounces() -> None:
182+
# Defensive guard: only a strictly later generation can consume a prior bounce.
183+
approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True))
184+
with deliberation_scope("root", 2):
185+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
186+
with deliberation_scope("root", 1):
187+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
188+
with deliberation_scope("root", 2):
189+
assert approval.deliberation_gate(_shell_call("rm -rf build")) is not None
133190

134191

135192
def test_deliberation_gate_conditions() -> None:
@@ -164,14 +221,16 @@ async def test_request_bounces_destructive_then_approves_retry() -> None:
164221

165222
approval = Approval(state=ApprovalState(auto=True, auto_deliberate=True))
166223
with tool_call_context("Shell", arguments={"command": "rm -rf build"}):
167-
first = await approval.request("Shell", "run command", "Run command `rm -rf build`")
224+
with deliberation_scope("root", 1):
225+
first = await approval.request("Shell", "run command", "Run command `rm -rf build`")
168226
assert not first, "destructive action is bounced for deliberation"
169227
assert first.deliberation is True
170228
assert "irreversible" in first.feedback
171229
assert "rejected by the user" not in first.rejection_error().message
172230

173-
second = await approval.request("Shell", "run command", "Run command `rm -rf build`")
174-
assert second, "one-shot consumed: the deliberated retry runs"
231+
with deliberation_scope("root", 2):
232+
second = await approval.request("Shell", "run command", "Run command `rm -rf build`")
233+
assert second, "one-shot consumed in a later generation: the deliberated retry runs"
175234

176235

177236
def test_approval_state_honors_auto_deliberate_flag() -> None:

0 commit comments

Comments
 (0)