Skip to content

Commit 7b9b60b

Browse files
author
yuweihong.666
committed
feat: add independently configured AgentSentry V3 moderation
1 parent b7421c1 commit 7b9b60b

7 files changed

Lines changed: 848 additions & 9 deletions
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import builtins
16+
17+
import json
18+
19+
from types import SimpleNamespace
20+
21+
from unittest.mock import Mock
22+
23+
import pytest
24+
25+
from google.genai import types
26+
27+
from google.adk.models import LlmResponse
28+
29+
30+
@pytest.fixture
31+
def public_env(monkeypatch):
32+
for key in (
33+
"AGENTSENTRY_URL",
34+
"ENABLE_LLM_SHIELD",
35+
"TOOL_LLM_SHIELD_REGION",
36+
"TOOL_LLM_SHIELD_ACCOUNT_ID",
37+
"AGENTSENTRY_AK",
38+
"AGENTSENTRY_SK",
39+
"AGENTSENTRY_ACCOUNT_ID",
40+
"AGENTSENTRY_INSTANCE_ID",
41+
"AGENTSENTRY_REGION",
42+
):
43+
monkeypatch.delenv(key, raising=False)
44+
monkeypatch.setenv("TOOL_LLM_SHIELD_APP_ID", "public-app")
45+
monkeypatch.setenv("TOOL_LLM_SHIELD_API_KEY", "public-api-key")
46+
monkeypatch.setenv("TOOL_LLM_SHIELD_URL", "https://public-custom.example")
47+
# Public construction/calls must not even import the private adapter.
48+
original = builtins.__import__
49+
50+
def guarded(name, *args, **kwargs):
51+
if name.endswith("agentsentry_v3"):
52+
raise AssertionError("Private adapter imported by public mode")
53+
return original(name, *args, **kwargs)
54+
55+
monkeypatch.setattr(builtins, "__import__", guarded)
56+
57+
58+
def test_veadk_public_request_and_callbacks_unchanged(public_env, monkeypatch):
59+
import veadk.tools.builtin_tools.llm_shield as module
60+
61+
p = module.LLMShieldPlugin()
62+
assert p._sentry_v3 is None
63+
post = Mock(
64+
return_value=SimpleNamespace(
65+
status_code=200, json=lambda: {"Result": {"Decision": {"DecisionType": 1}}}
66+
)
67+
)
68+
monkeypatch.setattr(module.requests, "post", post)
69+
assert p._request_llm_shield("text", "user") is None
70+
args = post.call_args
71+
assert args.args[0] == "https://public-custom.example/v2/moderate"
72+
assert args.kwargs["headers"]["x-api-key"] == "public-api-key"
73+
assert args.kwargs["params"] == {"Action": "Moderate", "Version": "2025-08-31"}
74+
assert json.loads(args.kwargs["data"]) == {
75+
"Message": {"Role": "user", "Content": "text", "ContentType": 1},
76+
"Scene": "public-app",
77+
}
78+
p._request_llm_shield = Mock(return_value=None)
79+
ctx = SimpleNamespace()
80+
p.before_model_callback(
81+
ctx,
82+
SimpleNamespace(
83+
contents=[
84+
types.Content(
85+
role="user",
86+
parts=[types.Part(text="first"), types.Part(text="second")],
87+
)
88+
]
89+
),
90+
)
91+
assert p._request_llm_shield.call_args.kwargs == {
92+
"message": "first",
93+
"role": "user",
94+
"hook_name": "before_model",
95+
}
96+
p.after_model_callback(
97+
ctx,
98+
LlmResponse(
99+
content=types.Content(role="model", parts=[types.Part(text="reply")])
100+
),
101+
)
102+
assert p._request_llm_shield.call_args.kwargs["hook_name"] == "after_model"
103+
p.before_tool_callback(SimpleNamespace(name="tool"), {"x": "arg"}, ctx)
104+
assert p._request_llm_shield.call_args.kwargs["role"] == "user"
105+
p.after_tool_callback(None, {}, ctx, "result")
106+
assert p._request_llm_shield.call_args.kwargs["hook_name"] == "after_tool_call"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from types import SimpleNamespace as NS
16+
from unittest.mock import Mock
17+
18+
from google.genai import types
19+
from google.adk.models import LlmResponse
20+
21+
22+
def shield(monkeypatch):
23+
for key, value in {
24+
"AGENTSENTRY_URL": "http://sentry/OpenTOP/V3/Lumen/Moderate",
25+
"AGENTSENTRY_ACCOUNT_ID": "account",
26+
"AGENTSENTRY_INSTANCE_ID": "instance",
27+
"AGENTSENTRY_AK": "ak",
28+
"AGENTSENTRY_SK": "sk",
29+
}.items():
30+
monkeypatch.setenv(key, value)
31+
from veadk.tools.builtin_tools.llm_shield import LLMShieldPlugin
32+
33+
plugin = LLMShieldPlugin()
34+
plugin._sentry_v3.moderate = Mock(return_value=None)
35+
return plugin
36+
37+
38+
def test_hidden_thought_is_never_a_plan(monkeypatch):
39+
plugin = shield(monkeypatch)
40+
ctx = NS(agent_states={})
41+
plugin.after_model_callback(
42+
ctx,
43+
LlmResponse(
44+
content=types.Content(
45+
role="model",
46+
parts=[
47+
types.Part(text="SECRET", thought=True),
48+
types.Part(text="visible"),
49+
types.Part(
50+
function_call=types.FunctionCall(name="record", args={})
51+
),
52+
],
53+
)
54+
),
55+
)
56+
plugin.before_tool_callback(NS(name="record"), {}, ctx)
57+
assert plugin._sentry_v3.moderate.call_args.args[0] == "visible"
58+
59+
60+
def test_dispatch_once_per_invocation_and_no_old_text_for_image(monkeypatch):
61+
plugin = shield(monkeypatch)
62+
user = types.Content(role="user", parts=[types.Part(text="hello")])
63+
ctx = NS(agent_states={}, user_content=user)
64+
req = NS(contents=[user])
65+
plugin.before_model_callback(ctx, req)
66+
plugin.before_model_callback(ctx, req)
67+
assert plugin._sentry_v3.moderate.call_count == 1
68+
image = types.Content(
69+
role="user",
70+
parts=[
71+
types.Part(inline_data=types.Blob(data=b"image", mime_type="image/png"))
72+
],
73+
)
74+
ctx = NS(agent_states={}, user_content=image)
75+
response = plugin.before_model_callback(ctx, NS(contents=[user, image]))
76+
assert response is not None
77+
assert "unsupported" in response.content.parts[0].text.lower()
78+
assert plugin._sentry_v3.moderate.call_count == 1
79+
80+
81+
def test_private_disable_requires_no_credentials(monkeypatch):
82+
plugin = shield(monkeypatch)
83+
monkeypatch.setenv("AGENTSENTRY_ENABLED", "false")
84+
monkeypatch.delenv("AGENTSENTRY_SK")
85+
disabled = type(plugin)()
86+
assert disabled.before_model_callback(NS(), NS()) is None
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from types import SimpleNamespace
16+
from unittest.mock import Mock
17+
import pytest
18+
from google.genai import types
19+
from google.adk.models import LlmResponse
20+
21+
22+
@pytest.fixture
23+
def config(monkeypatch):
24+
for k, v in {
25+
"AGENTSENTRY_URL": "http://sentry/OpenTOP/V3/Lumen/Moderate",
26+
"AGENTSENTRY_ACCOUNT_ID": "account",
27+
"AGENTSENTRY_INSTANCE_ID": "instance",
28+
"AGENTSENTRY_AK": "ak",
29+
"AGENTSENTRY_SK": "sk",
30+
}.items():
31+
monkeypatch.setenv(k, v)
32+
33+
34+
@pytest.fixture
35+
def veadk(config):
36+
from veadk.tools.builtin_tools.llm_shield import LLMShieldPlugin
37+
38+
shield = LLMShieldPlugin()
39+
shield._sentry_v3.moderate = Mock(return_value=None)
40+
return shield
41+
42+
43+
def test_all_input_parts_and_hook(veadk):
44+
context = SimpleNamespace(agent_states={})
45+
request = SimpleNamespace(
46+
contents=[
47+
types.Content(
48+
role="user", parts=[types.Part(text="first"), types.Part(text="second")]
49+
)
50+
]
51+
)
52+
assert veadk.before_model_callback(context, request) is None
53+
args = veadk._sentry_v3.moderate.call_args
54+
assert args.args[0] == "first\nsecond"
55+
assert args.kwargs["hook_name"] == "before_dispatch"
56+
57+
58+
def test_plan_and_tool_are_linked_and_blocked(veadk):
59+
ctx = SimpleNamespace(agent_states={}, function_call_id="call")
60+
content = types.Content(
61+
role="model",
62+
parts=[
63+
types.Part(text="first"),
64+
types.Part(text="risk in second"),
65+
types.Part(
66+
function_call=types.FunctionCall(name="record", args={"action": "test"})
67+
),
68+
],
69+
)
70+
veadk.after_model_callback(ctx, LlmResponse(content=content))
71+
veadk._sentry_v3.moderate.return_value = "blocked"
72+
assert veadk.before_tool_callback(
73+
SimpleNamespace(name="record"), {"action": "test"}, ctx
74+
) == {"result": "blocked"}
75+
call = veadk._sentry_v3.moderate.call_args
76+
assert call.args[0] == "first\nrisk in second"
77+
assert call.kwargs["role"] == "assistant"
78+
assert call.kwargs["tool_calls"][0]["Function"]["Name"] == "record"
79+
# A different invocation may not use the first invocation's plan.
80+
other = SimpleNamespace(agent_states={})
81+
assert (
82+
veadk.before_tool_callback(
83+
SimpleNamespace(name="record"), {"action": "test"}, other
84+
)["reason"]
85+
== "missing_visible_execution_plan"
86+
)
87+
88+
89+
def test_unsupported_outputs_not_relabelled(veadk):
90+
ctx = SimpleNamespace(agent_states={})
91+
assert (
92+
veadk.after_model_callback(
93+
ctx,
94+
LlmResponse(
95+
content=types.Content(role="model", parts=[types.Part(text="final")])
96+
),
97+
)
98+
is None
99+
)
100+
assert veadk.after_tool_callback(None, {}, ctx, "result") is None
101+
veadk._sentry_v3.moderate.assert_not_called()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
18+
@pytest.fixture
19+
def independent(monkeypatch):
20+
for key in (
21+
"TOOL_LLM_SHIELD_URL",
22+
"TOOL_LLM_SHIELD_APP_ID",
23+
"TOOL_LLM_SHIELD_API_KEY",
24+
"TOOL_LLM_SHIELD_REGION",
25+
"TOOL_LLM_SHIELD_ACCOUNT_ID",
26+
"ENABLE_LLM_SHIELD",
27+
):
28+
monkeypatch.delenv(key, raising=False)
29+
for key, value in {
30+
"AGENTSENTRY_URL": "http://sentry/OpenTOP/V3/Lumen/Moderate",
31+
"AGENTSENTRY_ACCOUNT_ID": "account",
32+
"AGENTSENTRY_INSTANCE_ID": "instance",
33+
"AGENTSENTRY_AK": "ak",
34+
"AGENTSENTRY_SK": "sk",
35+
}.items():
36+
monkeypatch.setenv(key, value)
37+
monkeypatch.delenv("AGENTSENTRY_REGION", raising=False)
38+
39+
40+
@pytest.mark.parametrize("polluted", [False, True])
41+
def test_independent_configuration(independent, monkeypatch, polluted):
42+
if polluted:
43+
for key, value in {
44+
"TOOL_LLM_SHIELD_URL": "https://public.example",
45+
"TOOL_LLM_SHIELD_APP_ID": "cloud-app",
46+
"TOOL_LLM_SHIELD_API_KEY": "cloud-secret",
47+
"TOOL_LLM_SHIELD_REGION": "cloud-region",
48+
"ENABLE_LLM_SHIELD": "false",
49+
}.items():
50+
monkeypatch.setenv(key, value)
51+
from veadk.tools.builtin_tools.llm_shield import LLMShieldPlugin
52+
53+
plugin = LLMShieldPlugin()
54+
assert plugin._sentry_v3 is not None
55+
assert plugin._sentry_v3.account_id == "account"
56+
assert plugin._sentry_v3.access_key == "ak"
57+
assert plugin.region == "cn-private"
58+
assert plugin.api_key == ""
59+
60+
61+
def test_invalid_private_url_never_falls_back(independent, monkeypatch):
62+
from veadk.tools.builtin_tools.llm_shield import LLMShieldPlugin
63+
64+
monkeypatch.setenv("AGENTSENTRY_URL", "http://sentry/wrong-path")
65+
66+
factory = LLMShieldPlugin
67+
with pytest.raises(ValueError, match="V3 Moderate URL"):
68+
factory()

0 commit comments

Comments
 (0)