langchain-agentx-python 2.1.2__py3-none-any.whl → 2.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. langchain_agentx/__init__.py +1 -1
  2. langchain_agentx/loop/exit/exit_logic.py +1 -1
  3. langchain_agentx/loop/exit/reason_codes.py +4 -0
  4. langchain_agentx/loop/exit/terminal.py +2 -0
  5. langchain_agentx/loop/graph/builtin_loop_control.py +45 -30
  6. langchain_agentx/loop/graph/graph_edges.py +2 -0
  7. langchain_agentx/tool_runtime/auto_mode/hook.py +11 -0
  8. langchain_agentx/tool_runtime/auto_mode/state.py +2 -2
  9. langchain_agentx/tool_runtime/classifier_denial_bridge.py +92 -0
  10. langchain_agentx/tool_runtime/classifier_denial_limit.py +124 -0
  11. langchain_agentx/tool_runtime/classifier_denial_tracking.py +164 -0
  12. langchain_agentx/tool_runtime/denial_tracking.py +2 -1
  13. langchain_agentx/tool_runtime/outer_permission_resolver.py +9 -3
  14. langchain_agentx/tool_runtime/session_store.py +43 -0
  15. langchain_agentx/tools/bash/ast_security.py +32 -1
  16. langchain_agentx/tools/bash/boundary_check.py +80 -46
  17. langchain_agentx/tools/bash/command_allow_rules.py +4 -1
  18. langchain_agentx/tools/bash/path_security.py +23 -3
  19. langchain_agentx/tools/bash/read_only_validation.py +13 -2
  20. langchain_agentx/tools/bash/redirect_classifier.py +250 -0
  21. langchain_agentx/tools/bash/tool.py +5 -1
  22. {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.4.dist-info}/METADATA +1 -1
  23. {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.4.dist-info}/RECORD +26 -22
  24. {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.4.dist-info}/LICENSE +0 -0
  25. {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.4.dist-info}/WHEEL +0 -0
  26. {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.4.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,7 @@ from langchain_agentx import create_loop_agent
11
11
  ```
12
12
  """
13
13
 
14
- __version__ = "2.1.2"
14
+ __version__ = "2.1.4"
15
15
 
16
16
  from .loop import ( # noqa: F401
17
17
  create_loop_agent,
@@ -263,7 +263,7 @@ class AgentLoopState(TypedDict):
263
263
  _run_id: str | None # 当前 run 标识(loop_start_node 注入)
264
264
  token_budget_continuation_count: int # CC:token budget nudge 已执行次数
265
265
  token_budget_continue_requested: bool # 为 True 时 loop_controller 注入 nudge 并回到 model
266
- abort_terminal_reason: str | None # CC 式中断终态:aborted_streaming / aborted_tools / hook_stopped
266
+ abort_terminal_reason: str | None # aborted_streaming / aborted_tools / hook_stopped / classifier_denial_limit
267
267
  finish_reason: str | None # Provider 协议层:这一轮 completion 为何停
268
268
  finish_reasons: list[str] # finish_reason 历史(观测用)
269
269
  section_cache: dict[str, str | None] # SystemPromptSection session 级缓存(key=name, value=compute() 结果)
@@ -23,6 +23,8 @@ TERMINAL_REASON_INVARIANT_VIOLATION = "invariant_violation"
23
23
  TERMINAL_REASON_ABORTED_STREAMING = "aborted_streaming"
24
24
  TERMINAL_REASON_ABORTED_TOOLS = "aborted_tools"
25
25
  TERMINAL_REASON_HOOK_STOPPED = "hook_stopped"
26
+ # Auto classifier 熔断到顶(headless):仅终止当前 Agent loop,对齐 CC AbortError
27
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT = "classifier_denial_limit"
26
28
 
27
29
  # ---------------------------------------------------------------------------
28
30
  # transition.reason(控制流路由观测,不是终态)
@@ -57,6 +59,7 @@ ERROR_TERMINAL_REASONS = frozenset(
57
59
  TERMINAL_REASON_INVARIANT_VIOLATION,
58
60
  TERMINAL_REASON_MAX_TOKENS,
59
61
  TERMINAL_REASON_BLOCKING_LIMIT,
62
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
60
63
  }
61
64
  )
62
65
 
@@ -78,6 +81,7 @@ __all__ = [
78
81
  "TERMINAL_REASON_ABORTED_STREAMING",
79
82
  "TERMINAL_REASON_ABORTED_TOOLS",
80
83
  "TERMINAL_REASON_HOOK_STOPPED",
84
+ "TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT",
81
85
  "ERROR_TERMINAL_REASONS",
82
86
  "TRANSITION_NEXT_TURN",
83
87
  "TRANSITION_API_ERROR_TERMINAL",
@@ -19,6 +19,7 @@ from .reason_codes import (
19
19
  TERMINAL_REASON_ABORTED_STREAMING,
20
20
  TERMINAL_REASON_ABORTED_TOOLS,
21
21
  TERMINAL_REASON_BLOCKING_LIMIT,
22
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
22
23
  TERMINAL_REASON_CONTENT_FILTER,
23
24
  TERMINAL_REASON_HOOK_STOPPED,
24
25
  TERMINAL_REASON_STOP_HOOK_PREVENTED,
@@ -58,6 +59,7 @@ _ERROR_TERMINAL_REASONS = frozenset(
58
59
  TERMINAL_REASON_HOOK_STOPPED,
59
60
  TERMINAL_REASON_ABORTED_STREAMING,
60
61
  TERMINAL_REASON_ABORTED_TOOLS,
62
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
61
63
  TERMINAL_REASON_INVARIANT_VIOLATION,
62
64
  }
63
65
  )
@@ -18,10 +18,12 @@ from langgraph.types import Command
18
18
  from ..exit.reason_codes import (
19
19
  TERMINAL_REASON_ABORTED_STREAMING,
20
20
  TERMINAL_REASON_ABORTED_TOOLS,
21
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
21
22
  TERMINAL_REASON_HOOK_STOPPED,
22
23
  )
23
24
  from ..loop_abort import LoopAbortSignal
24
25
  from ..model.orphan_tool_results import synthetic_tool_messages_for_orphan_tool_calls
26
+ from ...tool_runtime.classifier_denial_limit import ClassifierDenialLimitAbort
25
27
  from ...tool_runtime.permission_context import clear_auto_approved_window
26
28
  from ...tool_runtime.tool_message_status import apply_tool_message_status_to_result
27
29
 
@@ -144,28 +146,47 @@ def _with_injected_agent_tool_model(request: Any) -> Any:
144
146
  return patched_request
145
147
 
146
148
 
149
+ def _tool_abort_command(
150
+ request: Any,
151
+ *,
152
+ terminal_reason: str,
153
+ content_template: str | None = None,
154
+ ) -> Command:
155
+ tc = request.tool_call
156
+ pending = [dict(tc)] if isinstance(tc, dict) else []
157
+ synth = (
158
+ synthetic_tool_messages_for_orphan_tool_calls(
159
+ pending_tool_calls=pending,
160
+ reason="user_interrupted",
161
+ content_template=content_template,
162
+ )
163
+ if pending
164
+ else []
165
+ )
166
+ return Command(
167
+ update={
168
+ "abort_terminal_reason": terminal_reason,
169
+ "messages": synth,
170
+ }
171
+ )
172
+
173
+
147
174
  def builtin_sync_tool_wrapper(request: Any, handler: Any) -> Any:
148
175
  try:
149
176
  result = handler(_with_injected_agent_tool_model(request))
150
177
  return apply_tool_message_status_to_result(result)
178
+ except ClassifierDenialLimitAbort as exc:
179
+ return _tool_abort_command(
180
+ request,
181
+ terminal_reason=TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
182
+ content_template=exc.message,
183
+ )
151
184
  except LoopAbortSignal as sig:
152
185
  if sig.terminal_reason != TERMINAL_REASON_ABORTED_TOOLS:
153
186
  raise
154
- tc = request.tool_call
155
- pending = [dict(tc)] if isinstance(tc, dict) else []
156
- synth = (
157
- synthetic_tool_messages_for_orphan_tool_calls(
158
- pending_tool_calls=pending,
159
- reason="user_interrupted",
160
- )
161
- if pending
162
- else []
163
- )
164
- return Command(
165
- update={
166
- "abort_terminal_reason": TERMINAL_REASON_ABORTED_TOOLS,
167
- "messages": synth,
168
- }
187
+ return _tool_abort_command(
188
+ request,
189
+ terminal_reason=TERMINAL_REASON_ABORTED_TOOLS,
169
190
  )
170
191
 
171
192
 
@@ -173,24 +194,18 @@ async def builtin_async_tool_wrapper(request: Any, handler: Any) -> Any:
173
194
  try:
174
195
  result = await handler(_with_injected_agent_tool_model(request))
175
196
  return apply_tool_message_status_to_result(result)
197
+ except ClassifierDenialLimitAbort as exc:
198
+ return _tool_abort_command(
199
+ request,
200
+ terminal_reason=TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
201
+ content_template=exc.message,
202
+ )
176
203
  except LoopAbortSignal as sig:
177
204
  if sig.terminal_reason != TERMINAL_REASON_ABORTED_TOOLS:
178
205
  raise
179
- tc = request.tool_call
180
- pending = [dict(tc)] if isinstance(tc, dict) else []
181
- synth = (
182
- synthetic_tool_messages_for_orphan_tool_calls(
183
- pending_tool_calls=pending,
184
- reason="user_interrupted",
185
- )
186
- if pending
187
- else []
188
- )
189
- return Command(
190
- update={
191
- "abort_terminal_reason": TERMINAL_REASON_ABORTED_TOOLS,
192
- "messages": synth,
193
- }
206
+ return _tool_abort_command(
207
+ request,
208
+ terminal_reason=TERMINAL_REASON_ABORTED_TOOLS,
194
209
  )
195
210
 
196
211
 
@@ -58,6 +58,7 @@ from ..exit.reason_codes import (
58
58
  DEFAULT_TOKEN_BUDGET_MAX_CONTINUATIONS,
59
59
  TERMINAL_REASON_ABORTED_STREAMING,
60
60
  TERMINAL_REASON_ABORTED_TOOLS,
61
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
61
62
  TERMINAL_REASON_COMPLETED,
62
63
  TERMINAL_REASON_HOOK_STOPPED,
63
64
  TERMINAL_REASON_INVARIANT_VIOLATION,
@@ -84,6 +85,7 @@ _ABORT_TERMINAL_REASONS = frozenset(
84
85
  TERMINAL_REASON_ABORTED_STREAMING,
85
86
  TERMINAL_REASON_ABORTED_TOOLS,
86
87
  TERMINAL_REASON_HOOK_STOPPED,
88
+ TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT,
87
89
  }
88
90
  )
89
91
 
@@ -93,6 +93,16 @@ class AutoModeAuthHook:
93
93
  """热更新 Auto Mode 配置(下一笔 decide 立即生效)。"""
94
94
  self._config = config
95
95
 
96
+ def _track_classifier_outcome(
97
+ self,
98
+ decision: AutoModeDecision,
99
+ ctx: "ToolExecutionContext",
100
+ ) -> None:
101
+ """L4 allow/deny → ClassifierDenialTracker(与 GAP-7 分轨;到顶动作 Phase 2)。"""
102
+ from ..classifier_denial_bridge import apply_classifier_denial_tracking
103
+
104
+ apply_classifier_denial_tracking(decision, ctx)
105
+
96
106
  def _emit_event(
97
107
  self,
98
108
  decision: AutoModeDecision,
@@ -236,6 +246,7 @@ class AutoModeAuthHook:
236
246
  llm_decision = await self._decide_llm(tool_name, tool_input, ctx, start_time)
237
247
  # L4 给出明确判断,直接返回
238
248
  if llm_decision.behavior in ("allow", "deny"):
249
+ self._track_classifier_outcome(llm_decision, ctx)
239
250
  self._emit_event(llm_decision, tool_name, tool_input, ctx)
240
251
  return llm_decision
241
252
  # L4 不可用(behavior == "ask"),继续到 L5
@@ -9,8 +9,8 @@ tool_runtime/auto_mode/state.py — Auto Mode 全局状态
9
9
  AgentLoopConfig / settings.json → AutoModeState → AutoModeAuthHook
10
10
 
11
11
  当前裁剪范围:
12
- - 暂不实现"连续 N deny 自动退出 auto"逻辑(Phase 1 仅实现开关)
13
- - denial_count 仅用于统计,不影响决策
12
+ - 熔断 SSOT ClassifierDenialTracker(classifier_denial_tracking.py),不读 denial_count
13
+ - denial_count 仅观测累计;classifier deny 时由 bridge 可选 +1
14
14
 
15
15
  CC 源码对照:
16
16
  src/utils/permissions/autoModeState.ts
@@ -0,0 +1,92 @@
1
+ """
2
+ classifier_denial_bridge.py — Auto Mode L4 ↔ ClassifierDenialTracker 接线
3
+
4
+ 职责:
5
+ 判定哪些 AutoModeDecision 可计入 classifier 熔断;经 session_store 读写 tracker。
6
+ 与 GAP-7 denial_tracking_bridge(L3 用户拒)严格分轨。
7
+
8
+ 链路位置:
9
+ AutoModeAuthHook.decide L4 出口 → 本模块计数;
10
+ 到顶双分支见 classifier_denial_limit.apply_classifier_denial_limit_outcome。
11
+
12
+ 当前裁剪范围:
13
+ 本模块仅计数;boundary / L1 / L3 / L5 / 合成拒不计。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING
19
+
20
+ from .classifier_denial_tracking import (
21
+ ClassifierDenialConfig,
22
+ ClassifierDenialTracker,
23
+ ClassifierLimitDecision,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from .auto_mode.decision import AutoModeDecision
28
+ from .models import ToolExecutionContext
29
+
30
+
31
+ def is_countable_classifier_deny(decision: AutoModeDecision) -> bool:
32
+ """仅 L4 classifier 明确 deny 可计(对齐 CC recordDenial)。"""
33
+ return decision.layer == "L4" and decision.behavior == "deny"
34
+
35
+
36
+ def is_countable_classifier_allow(decision: AutoModeDecision) -> bool:
37
+ """仅 L4 classifier 明确 allow 清连续(对齐 CC recordSuccess)。"""
38
+ return decision.layer == "L4" and decision.behavior == "allow"
39
+
40
+
41
+ def resolve_classifier_tracker(
42
+ ctx: ToolExecutionContext,
43
+ config: ClassifierDenialConfig | None = None,
44
+ ) -> ClassifierDenialTracker | None:
45
+ store = getattr(ctx, "session_store", None)
46
+ if store is None:
47
+ return None
48
+ return store.get_classifier_denial_tracker(config=config)
49
+
50
+
51
+ def apply_classifier_denial_tracking(
52
+ decision: AutoModeDecision,
53
+ ctx: ToolExecutionContext,
54
+ *,
55
+ config: ClassifierDenialConfig | None = None,
56
+ ) -> ClassifierLimitDecision | None:
57
+ """按决策更新 tracker;不可计时返回 None。
58
+
59
+ 可选同步 AutoModeState.denial_count 作观测(熔断不读该字段)。
60
+ """
61
+ tracker = resolve_classifier_tracker(ctx, config=config)
62
+ if tracker is None:
63
+ return None
64
+
65
+ if is_countable_classifier_deny(decision):
66
+ limit = tracker.record_denial()
67
+ _observe_auto_mode_denial_count()
68
+ return limit
69
+
70
+ if is_countable_classifier_allow(decision):
71
+ tracker.record_success()
72
+ return tracker.should_fallback()
73
+
74
+ return None
75
+
76
+
77
+ def _observe_auto_mode_denial_count() -> None:
78
+ """观测计数;非熔断 SSOT。"""
79
+ try:
80
+ from .auto_mode.state import get_auto_mode_state
81
+
82
+ get_auto_mode_state().increment_denial()
83
+ except Exception:
84
+ return
85
+
86
+
87
+ __all__ = [
88
+ "apply_classifier_denial_tracking",
89
+ "is_countable_classifier_allow",
90
+ "is_countable_classifier_deny",
91
+ "resolve_classifier_tracker",
92
+ ]
@@ -0,0 +1,124 @@
1
+ """
2
+ classifier_denial_limit.py — Classifier 熔断到顶双分支(CC handleDenialLimitExceeded)
3
+
4
+ 职责:
5
+ 会话级 tracker 到顶后:CLI → ask;headless → 终止当前 Agent loop。
6
+
7
+ 链路位置:
8
+ OuterPermissionResolver 收到 L4 deny 后 → apply_classifier_denial_limit_outcome;
9
+ headless Abort 由 builtin_loop_control 捕获并写 abort_terminal_reason。
10
+
11
+ 当前裁剪范围:
12
+ 仅作用于可计数的 L4 classifier deny;boundary / L3 / GAP-7 不走本路径。
13
+ Abort 作用域 = 当前 Agent loop,不杀整个 workflow graph。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING
19
+
20
+ from langchain_agentx.loop.exit.reason_codes import TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT
21
+
22
+ from .classifier_denial_bridge import resolve_classifier_tracker
23
+ from .models import AuthorizationDecision
24
+ from .permission_decision import (
25
+ PermissionDecisionType,
26
+ attach_decision_reason,
27
+ make_decision_reason,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from .models import ToolExecutionContext
32
+
33
+ HEADLESS_ABORT_MESSAGE = (
34
+ "Agent aborted: too many classifier denials in headless mode"
35
+ )
36
+
37
+
38
+ class ClassifierDenialLimitAbort(Exception):
39
+ """Headless 下 classifier 熔断到顶:终止当前 Agent loop(非整 workflow)。"""
40
+
41
+ terminal_reason = TERMINAL_REASON_CLASSIFIER_DENIAL_LIMIT
42
+
43
+ def __init__(self, message: str = HEADLESS_ABORT_MESSAGE) -> None:
44
+ self.message = message
45
+ super().__init__(message)
46
+
47
+
48
+ def _is_l4_classifier_deny(decision: AuthorizationDecision) -> bool:
49
+ """仅 Auto Mode L4 deny 参与到顶动作(与 bridge 可计口径一致)。"""
50
+ if decision.behavior != "deny":
51
+ return False
52
+ meta = decision.metadata or {}
53
+ return meta.get("auto_mode_layer") == "L4"
54
+
55
+
56
+ def apply_classifier_denial_limit_outcome(
57
+ decision: AuthorizationDecision,
58
+ ctx: ToolExecutionContext,
59
+ *,
60
+ headless: bool,
61
+ ) -> AuthorizationDecision:
62
+ """对齐 CC handleDenialLimitExceeded。
63
+
64
+ 未到顶或非 L4 deny → 原样返回;
65
+ CLI 到顶 → ask(warning + latest reason);
66
+ headless 到顶 → raise ClassifierDenialLimitAbort。
67
+ """
68
+ if not _is_l4_classifier_deny(decision):
69
+ return decision
70
+
71
+ tracker = resolve_classifier_tracker(ctx)
72
+ if tracker is None:
73
+ return decision
74
+
75
+ lim = tracker.should_fallback()
76
+ if not lim.exceeded:
77
+ return decision
78
+
79
+ hit_total = lim.limit_kind == "total"
80
+ warning = (
81
+ f"{lim.total_denials} actions were blocked this session. "
82
+ "Please review the transcript before continuing."
83
+ if hit_total
84
+ else (
85
+ f"{lim.consecutive_denials} consecutive actions were blocked. "
86
+ "Please review the transcript before continuing."
87
+ )
88
+ )
89
+ latest = decision.message or "blocked by classifier"
90
+ reason_text = f"{warning}\n\nLatest blocked action: {latest}"
91
+
92
+ if headless:
93
+ raise ClassifierDenialLimitAbort(HEADLESS_ABORT_MESSAGE)
94
+
95
+ if hit_total:
96
+ tracker.reset_after_total_limit()
97
+
98
+ meta = dict(decision.metadata or {})
99
+ meta["classifier_denial_limit"] = lim.limit_kind
100
+ meta["classifier_denial_consecutive"] = lim.consecutive_denials
101
+ meta["classifier_denial_total"] = lim.total_denials
102
+
103
+ ask = AuthorizationDecision(
104
+ behavior="ask",
105
+ message=reason_text,
106
+ policy_id=decision.policy_id,
107
+ ask_prompt=reason_text,
108
+ metadata=meta,
109
+ )
110
+ return attach_decision_reason(
111
+ ask,
112
+ make_decision_reason(
113
+ PermissionDecisionType.CLASSIFIER,
114
+ reason=reason_text,
115
+ classifier="auto-mode",
116
+ ),
117
+ )
118
+
119
+
120
+ __all__ = [
121
+ "ClassifierDenialLimitAbort",
122
+ "HEADLESS_ABORT_MESSAGE",
123
+ "apply_classifier_denial_limit_outcome",
124
+ ]
@@ -0,0 +1,164 @@
1
+ """
2
+ classifier_denial_tracking.py — Auto classifier 连续拒熔断计数(CC denialTracking.ts)
3
+
4
+ 职责:
5
+ 会话级 consecutive/total 计数;仅服务 auto-mode LLM classifier deny/allow。
6
+ 与 GAP-7 L3 DenialTracker 分通道,互不写入。
7
+
8
+ 链路位置:
9
+ AutoModeAuthHook L4 出口 → ClassifierDenialTracker;
10
+ Phase 2 到顶双分支(CLI ask / 问答停当前 Agent loop)读取 should_fallback。
11
+
12
+ 当前裁剪范围:
13
+ 计数 + should_fallback;到顶 ask/Abort 见 classifier_denial_limit。
14
+ 默认 enabled=True(对齐 CC 始终 tracking)。
15
+ 子代理 fork_local() 隔离(对齐 CC localDenialTracking)。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Any, Literal
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ClassifierDenialConfig:
26
+ """Classifier 熔断配置(默认启用,对齐 CC DENIAL_LIMITS)。"""
27
+
28
+ enabled: bool = True
29
+ max_consecutive: int = 3
30
+ max_total: int = 20
31
+
32
+
33
+ @dataclass
34
+ class ClassifierDenialState:
35
+ consecutive_denials: int = 0
36
+ total_denials: int = 0
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ClassifierLimitDecision:
41
+ """是否已到顶(对齐 shouldFallbackToPrompting)。"""
42
+
43
+ exceeded: bool
44
+ limit_kind: Literal["consecutive", "total"] | None
45
+ consecutive_denials: int
46
+ total_denials: int
47
+
48
+
49
+ class ClassifierDenialTracker:
50
+ """会话级 classifier 拒绝跟踪(OOP;非 per-tool 分桶)。"""
51
+
52
+ def __init__(self, config: ClassifierDenialConfig | None = None) -> None:
53
+ self._config = config or ClassifierDenialConfig()
54
+ self._state = ClassifierDenialState()
55
+
56
+ @property
57
+ def config(self) -> ClassifierDenialConfig:
58
+ return self._config
59
+
60
+ @property
61
+ def consecutive_denials(self) -> int:
62
+ return self._state.consecutive_denials
63
+
64
+ @property
65
+ def total_denials(self) -> int:
66
+ return self._state.total_denials
67
+
68
+ @staticmethod
69
+ def create_empty(config: ClassifierDenialConfig | None = None) -> ClassifierDenialTracker:
70
+ return ClassifierDenialTracker(config)
71
+
72
+ def record_denial(self) -> ClassifierLimitDecision:
73
+ """classifier deny:consecutive++ total++;返回是否已到顶。"""
74
+ if not self._config.enabled:
75
+ return self.should_fallback()
76
+ self._state.consecutive_denials += 1
77
+ self._state.total_denials += 1
78
+ return self.should_fallback()
79
+
80
+ def record_success(self) -> None:
81
+ """classifier allow:连续计数清零(total 不变)。"""
82
+ if not self._config.enabled:
83
+ return
84
+ if self._state.consecutive_denials == 0:
85
+ return
86
+ self._state.consecutive_denials = 0
87
+
88
+ def should_fallback(self) -> ClassifierLimitDecision:
89
+ """对齐 CC shouldFallbackToPrompting。"""
90
+ consec = self._state.consecutive_denials
91
+ total = self._state.total_denials
92
+ if not self._config.enabled:
93
+ return ClassifierLimitDecision(
94
+ exceeded=False,
95
+ limit_kind=None,
96
+ consecutive_denials=consec,
97
+ total_denials=total,
98
+ )
99
+ if consec >= self._config.max_consecutive:
100
+ return ClassifierLimitDecision(
101
+ exceeded=True,
102
+ limit_kind="consecutive",
103
+ consecutive_denials=consec,
104
+ total_denials=total,
105
+ )
106
+ if total >= self._config.max_total:
107
+ return ClassifierLimitDecision(
108
+ exceeded=True,
109
+ limit_kind="total",
110
+ consecutive_denials=consec,
111
+ total_denials=total,
112
+ )
113
+ return ClassifierLimitDecision(
114
+ exceeded=False,
115
+ limit_kind=None,
116
+ consecutive_denials=consec,
117
+ total_denials=total,
118
+ )
119
+
120
+ def reset_after_total_limit(self) -> None:
121
+ """对齐 CC:累计到顶后在交互 ask 路径清零 consecutive+total。"""
122
+ self._state.consecutive_denials = 0
123
+ self._state.total_denials = 0
124
+
125
+ def fork_local(self) -> ClassifierDenialTracker:
126
+ """子代理独立 tracker(对齐 CC localDenialTracking)。"""
127
+ return ClassifierDenialTracker(self._config)
128
+
129
+ def export_state(self) -> dict[str, Any]:
130
+ return {
131
+ "config": {
132
+ "enabled": self._config.enabled,
133
+ "max_consecutive": self._config.max_consecutive,
134
+ "max_total": self._config.max_total,
135
+ },
136
+ "state": {
137
+ "consecutive_denials": self._state.consecutive_denials,
138
+ "total_denials": self._state.total_denials,
139
+ },
140
+ }
141
+
142
+ @classmethod
143
+ def from_state(cls, data: dict[str, Any]) -> ClassifierDenialTracker:
144
+ raw_cfg = data.get("config") or {}
145
+ config = ClassifierDenialConfig(
146
+ enabled=bool(raw_cfg.get("enabled", True)),
147
+ max_consecutive=int(raw_cfg.get("max_consecutive", 3)),
148
+ max_total=int(raw_cfg.get("max_total", 20)),
149
+ )
150
+ tracker = cls(config)
151
+ raw_state = data.get("state") or {}
152
+ tracker._state = ClassifierDenialState(
153
+ consecutive_denials=int(raw_state.get("consecutive_denials", 0)),
154
+ total_denials=int(raw_state.get("total_denials", 0)),
155
+ )
156
+ return tracker
157
+
158
+
159
+ __all__ = [
160
+ "ClassifierDenialConfig",
161
+ "ClassifierDenialState",
162
+ "ClassifierDenialTracker",
163
+ "ClassifierLimitDecision",
164
+ ]
@@ -9,7 +9,8 @@ denial_tracking.py — GAP-7 L3 连续拒绝跟踪(CC denialTracking.ts 对齐
9
9
  AgentSessionStore.denial_tracker namespace;Phase 2 由 HitlRaceCoordinator / pipeline 读写。
10
10
 
11
11
  当前裁剪范围:
12
- 默认 disabled(opt-in);不含 Auto Mode 分类器 deny(见 AutoModeState.denial_count)。
12
+ 默认 disabled(opt-in);仅 L3 用户拒。
13
+ Auto classifier 连续拒见 classifier_denial_tracking.py(分通道,勿混入本模块)。
13
14
  子代理通过 fork_local() 隔离(对齐 CC localDenialTracking)。
14
15
  """
15
16
 
@@ -3,6 +3,7 @@ outer_permission_resolver.py — 段 6 outer 裁决(GAP-3)
3
3
 
4
4
  职责:
5
5
  inner 产出 ask 后:auto 分类器消化 / headless fail-close deny;非 ask 原样返回。
6
+ classifier 熔断到顶:CLI → ask;headless → ClassifierDenialLimitAbort。
6
7
 
7
8
  链路位置:
8
9
  PermissionOrchestrator._resolve_outer
@@ -15,6 +16,7 @@ from __future__ import annotations
15
16
 
16
17
  from typing import TYPE_CHECKING, Any
17
18
 
19
+ from .classifier_denial_limit import apply_classifier_denial_limit_outcome
18
20
  from .models import AuthorizationDecision
19
21
  from .permission_decision import PermissionDecisionType, attach_decision_reason, make_decision_reason
20
22
  from .policy import resolve_default_policy_engine
@@ -62,9 +64,13 @@ class OuterPermissionResolver:
62
64
  if mode == "auto":
63
65
  auto_decision = await self._run_auto_mode_hook(tool, data, ctx)
64
66
  if auto_decision is not None:
65
- if auto_decision.behavior in ("allow", "deny"):
66
- return auto_decision
67
- if auto_decision.behavior != "ask":
67
+ # L4 deny 后可能到顶:CLI→ask / headless→Abort(非整 workflow)
68
+ auto_decision = apply_classifier_denial_limit_outcome(
69
+ auto_decision,
70
+ ctx,
71
+ headless=headless,
72
+ )
73
+ if auto_decision.behavior in ("allow", "deny", "ask"):
68
74
  return auto_decision
69
75
 
70
76
  if headless: