langchain-agentx-python 2.1.2__py3-none-any.whl → 2.1.5__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.
- langchain_agentx/__init__.py +1 -1
- langchain_agentx/loop/exit/exit_logic.py +1 -1
- langchain_agentx/loop/exit/reason_codes.py +4 -0
- langchain_agentx/loop/exit/terminal.py +2 -0
- langchain_agentx/loop/graph/builtin_loop_control.py +45 -30
- langchain_agentx/loop/graph/graph_edges.py +2 -0
- langchain_agentx/tool_runtime/auto_mode/hook.py +11 -0
- langchain_agentx/tool_runtime/auto_mode/state.py +2 -2
- langchain_agentx/tool_runtime/classifier_denial_bridge.py +92 -0
- langchain_agentx/tool_runtime/classifier_denial_limit.py +124 -0
- langchain_agentx/tool_runtime/classifier_denial_tracking.py +164 -0
- langchain_agentx/tool_runtime/denial_tracking.py +2 -1
- langchain_agentx/tool_runtime/outer_permission_resolver.py +9 -3
- langchain_agentx/tool_runtime/session_store.py +43 -0
- langchain_agentx/tools/bash/ast_security.py +32 -1
- langchain_agentx/tools/bash/boundary_check.py +80 -46
- langchain_agentx/tools/bash/command_allow_rules.py +4 -1
- langchain_agentx/tools/bash/path_security.py +23 -3
- langchain_agentx/tools/bash/read_only_validation.py +13 -2
- langchain_agentx/tools/bash/redirect_classifier.py +250 -0
- langchain_agentx/tools/bash/tool.py +5 -1
- langchain_agentx/tools/read/models.py +2 -1
- langchain_agentx/tools/read/tool.py +2 -6
- {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.5.dist-info}/METADATA +1 -1
- {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.5.dist-info}/RECORD +28 -24
- {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.5.dist-info}/LICENSE +0 -0
- {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.5.dist-info}/WHEEL +0 -0
- {langchain_agentx_python-2.1.2.dist-info → langchain_agentx_python-2.1.5.dist-info}/top_level.txt +0 -0
langchain_agentx/__init__.py
CHANGED
|
@@ -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 #
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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:
|