mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: 锁定问股追问的当前标的上下文 P2 (#1619)
* fix: keep ask-stock follow-ups scoped * fix: normalize watchlist stock comparisons * docs: document watchlist stock code equivalence * fix: tighten ask-stock tool scope guard * fix: close ask-stock review gaps * fix: close ask-stock review gaps
This commit is contained in:
@@ -36,6 +36,7 @@ from src.agent.executor import (
|
||||
)
|
||||
from src.agent.llm_adapter import LLMResponse, ToolCall
|
||||
from src.agent.runner import parse_dashboard_json, run_agent_loop, serialize_tool_result
|
||||
from src.agent.stock_scope import StockScope, resolve_stock_scope
|
||||
from src.agent.tools.registry import ToolRegistry, ToolDefinition, ToolParameter
|
||||
from src.analysis_context_pack_prompt import format_analysis_context_pack_prompt_section
|
||||
from src.config import Config
|
||||
@@ -65,6 +66,46 @@ def _make_registry_with_echo():
|
||||
return registry
|
||||
|
||||
|
||||
def _make_stock_registry(executed_calls):
|
||||
"""Create a registry with stock-scoped and non-stock tools."""
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="get_realtime_quote",
|
||||
description="Gets realtime quote",
|
||||
parameters=[
|
||||
ToolParameter(name="stock_code", type="string", description="Stock code"),
|
||||
],
|
||||
handler=lambda stock_code: executed_calls.append(("quote", stock_code)) or {"stock_code": stock_code},
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="search_stock_news",
|
||||
description="Searches stock news",
|
||||
parameters=[
|
||||
ToolParameter(name="stock_code", type="string", description="Stock code"),
|
||||
ToolParameter(name="stock_name", type="string", description="Stock name"),
|
||||
],
|
||||
handler=lambda stock_code, stock_name: executed_calls.append(("news", stock_code, stock_name)) or {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="echo",
|
||||
description="Echoes back the input",
|
||||
parameters=[
|
||||
ToolParameter(name="message", type="string", description="Message to echo"),
|
||||
],
|
||||
handler=lambda message: executed_calls.append(("echo", message)) or {"echo": message},
|
||||
)
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def _make_mock_adapter():
|
||||
"""Create a MagicMock LLMToolAdapter."""
|
||||
adapter = MagicMock()
|
||||
@@ -154,8 +195,9 @@ class TestAgentExecutor(unittest.TestCase):
|
||||
executor = AgentExecutor(registry, adapter, max_steps=2)
|
||||
captured = {}
|
||||
|
||||
def fake_run_loop(messages, tool_decls, parse_dashboard, progress_callback=None):
|
||||
def fake_run_loop(messages, tool_decls, parse_dashboard, progress_callback=None, stock_scope=None):
|
||||
captured["messages"] = messages
|
||||
captured["stock_scope"] = stock_scope
|
||||
return AgentResult(success=True, content="assistant reply")
|
||||
|
||||
compressed_history = [
|
||||
@@ -187,6 +229,628 @@ class TestAgentExecutor(unittest.TestCase):
|
||||
assert messages[3]["content"].startswith("[系统提供的历史分析上下文,可供参考对比]")
|
||||
assert messages[4]["role"] == "assistant"
|
||||
assert messages[-1] == {"role": "user", "content": "当前问题"}
|
||||
assert captured["stock_scope"].expected_stock_code == "600519"
|
||||
|
||||
def test_chat_switches_effective_context_and_clears_previous_stock_fields(self):
|
||||
registry = _make_registry_with_echo()
|
||||
adapter = _make_mock_adapter()
|
||||
adapter._config = MagicMock()
|
||||
executor = AgentExecutor(registry, adapter, max_steps=2)
|
||||
captured = {}
|
||||
|
||||
def fake_run_loop(messages, tool_decls, parse_dashboard, progress_callback=None, stock_scope=None):
|
||||
captured["messages"] = messages
|
||||
captured["stock_scope"] = stock_scope
|
||||
return AgentResult(success=True, content="assistant reply")
|
||||
|
||||
stale_context = {
|
||||
"stock_code": "600519",
|
||||
"stock_name": "贵州茅台",
|
||||
"previous_analysis_summary": {"summary": "old"},
|
||||
"previous_strategy": {"action": "hold"},
|
||||
"previous_price": 1800,
|
||||
"previous_change_pct": 1.2,
|
||||
"skills": ["bull_trend"],
|
||||
}
|
||||
|
||||
with patch.object(executor, "_run_loop", side_effect=fake_run_loop):
|
||||
with patch(
|
||||
"src.agent.executor.build_agent_chat_context_bundle",
|
||||
return_value=SimpleNamespace(context_messages=[], diagnostics={}),
|
||||
):
|
||||
with patch("src.agent.conversation.conversation_manager.get_or_create"):
|
||||
with patch("src.agent.conversation.conversation_manager.add_message"):
|
||||
executor.chat("换成 AAPL 看看,不考虑 600519", "session-1", context=stale_context)
|
||||
|
||||
history_context = "\n".join(
|
||||
msg["content"] for msg in captured["messages"] if msg["role"] == "user"
|
||||
)
|
||||
self.assertIn("股票代码: AAPL", history_context)
|
||||
self.assertNotIn("股票名称: 贵州茅台", history_context)
|
||||
self.assertNotIn("上次分析摘要", history_context)
|
||||
self.assertNotIn("上次策略分析", history_context)
|
||||
self.assertEqual(captured["stock_scope"].mode, "switch")
|
||||
self.assertEqual(captured["stock_scope"].expected_stock_code, "AAPL")
|
||||
self.assertEqual(captured["stock_scope"].allowed_stock_codes, {"AAPL"})
|
||||
|
||||
def test_chat_does_not_trust_exchange_token_from_public_context(self):
|
||||
registry = _make_registry_with_echo()
|
||||
adapter = _make_mock_adapter()
|
||||
adapter._config = MagicMock()
|
||||
executor = AgentExecutor(registry, adapter, max_steps=2)
|
||||
captured = {}
|
||||
|
||||
def fake_run_loop(messages, tool_decls, parse_dashboard, progress_callback=None, stock_scope=None):
|
||||
captured["messages"] = messages
|
||||
captured["stock_scope"] = stock_scope
|
||||
return AgentResult(success=True, content="assistant reply")
|
||||
|
||||
with patch.object(executor, "_run_loop", side_effect=fake_run_loop):
|
||||
with patch(
|
||||
"src.agent.executor.build_agent_chat_context_bundle",
|
||||
return_value=SimpleNamespace(context_messages=[], diagnostics={}),
|
||||
):
|
||||
with patch("src.agent.conversation.conversation_manager.get_or_create"):
|
||||
with patch("src.agent.conversation.conversation_manager.add_message"):
|
||||
executor.chat(
|
||||
"继续看",
|
||||
"session-1",
|
||||
context={"stock_code": "HK", "stock_name": "港股"},
|
||||
)
|
||||
|
||||
history_context = "\n".join(
|
||||
msg["content"] for msg in captured["messages"] if msg["role"] == "user"
|
||||
)
|
||||
self.assertNotIn("股票代码: HK", history_context)
|
||||
self.assertNotIn("股票名称: 港股", history_context)
|
||||
self.assertEqual(captured["stock_scope"].expected_stock_code, "")
|
||||
self.assertEqual(captured["stock_scope"].allowed_stock_codes, set())
|
||||
|
||||
def test_run_does_not_pass_stock_scope_to_dashboard_path(self):
|
||||
registry = _make_registry_with_echo()
|
||||
adapter = _make_mock_adapter()
|
||||
executor = AgentExecutor(registry, adapter, max_steps=2)
|
||||
captured = {}
|
||||
|
||||
def fake_run_loop(messages, tool_decls, parse_dashboard, progress_callback=None, stock_scope=None):
|
||||
captured["stock_scope"] = stock_scope
|
||||
return AgentResult(success=True, content=json.dumps(SAMPLE_DASHBOARD, ensure_ascii=False))
|
||||
|
||||
with patch.object(executor, "_run_loop", side_effect=fake_run_loop):
|
||||
result = executor.run("Analyze 600519", context={"stock_code": "600519"})
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertIsNone(captured["stock_scope"])
|
||||
|
||||
def test_resolve_stock_scope_compare_collects_multiple_normalized_codes(self):
|
||||
result = resolve_stock_scope(
|
||||
"比较 600519 和 AAPL",
|
||||
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||
)
|
||||
|
||||
self.assertEqual(result.stock_scope.mode, "compare")
|
||||
self.assertEqual(result.effective_context["stock_code"], "600519")
|
||||
self.assertEqual(result.effective_context["stock_name"], "贵州茅台")
|
||||
self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519", "AAPL"})
|
||||
|
||||
def test_resolve_stock_scope_keeps_ambiguous_bare_code_on_current_stock(self):
|
||||
result = resolve_stock_scope("AAPL", {"stock_code": "600519", "stock_name": "贵州茅台"})
|
||||
|
||||
self.assertEqual(result.stock_scope.mode, "maintain")
|
||||
self.assertEqual(result.effective_context["stock_code"], "600519")
|
||||
self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"})
|
||||
|
||||
def test_run_agent_loop_blocks_conflicting_stock_scoped_tool_and_keeps_tool_result(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(id="quote_1", name="get_realtime_quote", arguments={"stock_code": "TTM"}),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="I will stay on the current stock.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "如果不考虑 TTM 呢"},
|
||||
]
|
||||
result = run_agent_loop(
|
||||
messages=messages,
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=StockScope(expected_stock_code="600519", allowed_stock_codes={"600519"}),
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(executed_calls, [])
|
||||
self.assertEqual(len(result.tool_calls_log), 1)
|
||||
log_entry = result.tool_calls_log[0]
|
||||
self.assertFalse(log_entry["success"])
|
||||
self.assertTrue(log_entry["guarded"])
|
||||
self.assertEqual(log_entry["expected_stock_code"], "600519")
|
||||
self.assertEqual(log_entry["requested_stock_code"], "TTM")
|
||||
tool_messages = [msg for msg in result.messages if msg.get("role") == "tool"]
|
||||
self.assertEqual(len(tool_messages), 1)
|
||||
self.assertEqual(tool_messages[0]["tool_call_id"], "quote_1")
|
||||
self.assertIn("stock_scope_violation", tool_messages[0]["content"])
|
||||
|
||||
def test_run_agent_loop_blocks_numeric_conflicting_stock_code(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(id="quote_1", name="get_realtime_quote", arguments={"stock_code": 123456}),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Blocked wrong numeric code.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "继续看当前标的"},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=StockScope(expected_stock_code="600519", allowed_stock_codes={"600519"}),
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(executed_calls, [])
|
||||
self.assertTrue(result.tool_calls_log[0]["guarded"])
|
||||
self.assertEqual(result.tool_calls_log[0]["requested_stock_code"], "123456")
|
||||
tool_messages = [msg for msg in result.messages if msg.get("role") == "tool"]
|
||||
self.assertEqual(len(tool_messages), 1)
|
||||
self.assertIn("stock_scope_violation", tool_messages[0]["content"])
|
||||
|
||||
def test_run_agent_loop_allows_explicit_allowed_stock_code_and_hk_equivalent(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(id="quote_1", name="get_realtime_quote", arguments={"stock_code": "1810.HK"}),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="AAPL and HK allowed.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "比较 HK01810 和 600519"},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=StockScope(
|
||||
expected_stock_code="600519",
|
||||
allowed_stock_codes={"600519", "HK01810"},
|
||||
mode="compare",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(executed_calls, [("quote", "1810.HK")])
|
||||
self.assertFalse(result.tool_calls_log[0].get("guarded", False))
|
||||
|
||||
def test_run_agent_loop_allows_compare_hint_stock_code(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(id="quote_1", name="get_realtime_quote", arguments={"stock_code": "AAPL"}),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Compared allowed stock.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
message = "分析 600519 和 AAPL 的差异"
|
||||
scope = resolve_stock_scope(message, {"stock_code": "600519", "stock_name": "贵州茅台"}).stock_scope
|
||||
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=scope,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(scope.mode, "compare")
|
||||
self.assertEqual(scope.allowed_stock_codes, {"600519", "AAPL"})
|
||||
self.assertEqual(executed_calls, [("quote", "AAPL")])
|
||||
self.assertFalse(result.tool_calls_log[0].get("guarded", False))
|
||||
|
||||
def test_run_agent_loop_allows_plain_hk_code_from_compare_scope(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(id="quote_1", name="get_realtime_quote", arguments={"stock_code": "01810"}),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Compared allowed HK stock.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
message = "比较 01810 和 AAPL"
|
||||
scope = resolve_stock_scope(message, {"stock_code": "600519", "stock_name": "贵州茅台"}).stock_scope
|
||||
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=scope,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(scope.mode, "compare")
|
||||
self.assertEqual(scope.allowed_stock_codes, {"600519", "HK01810", "AAPL"})
|
||||
self.assertEqual(executed_calls, [("quote", "01810")])
|
||||
self.assertFalse(result.tool_calls_log[0].get("guarded", False))
|
||||
|
||||
def test_run_agent_loop_allows_choice_compare_stock_codes(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quotes.",
|
||||
tool_calls=[
|
||||
ToolCall(id="quote_1", name="get_realtime_quote", arguments={"stock_code": "AAPL"}),
|
||||
ToolCall(id="quote_2", name="get_realtime_quote", arguments={"stock_code": "TSLA"}),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Compared allowed stocks.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
message = "AAPL 和 TSLA 哪个更值得买"
|
||||
scope = resolve_stock_scope(message, {"stock_code": "600519", "stock_name": "贵州茅台"}).stock_scope
|
||||
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=scope,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(scope.mode, "compare")
|
||||
self.assertEqual(scope.allowed_stock_codes, {"600519", "AAPL", "TSLA"})
|
||||
self.assertEqual(executed_calls, [("quote", "AAPL"), ("quote", "TSLA")])
|
||||
self.assertFalse(result.tool_calls_log[0].get("guarded", False))
|
||||
self.assertFalse(result.tool_calls_log[1].get("guarded", False))
|
||||
|
||||
def test_run_agent_loop_blocks_exchange_affix_tokens_from_compare_scope(self):
|
||||
cases = [
|
||||
("比较 1810.HK 和 AAPL", "HK"),
|
||||
("比较 600519.SH 和 AAPL", "SH"),
|
||||
("比较 000001.SZ 和 AAPL", "SZ"),
|
||||
("比较 600519.SS 和 AAPL", "SS"),
|
||||
("比较 SH600519 和 AAPL", "SH"),
|
||||
("比较 SZ000001 和 AAPL", "SZ"),
|
||||
("比较 BJ920748 和 AAPL", "BJ"),
|
||||
("比较 HK01810 和 AAPL", "HK"),
|
||||
("比较 600519 SH 和 AAPL", "SH"),
|
||||
("比较 000001 SZ 和 AAPL", "SZ"),
|
||||
("比较 920748 BJ 和 AAPL", "BJ"),
|
||||
("比较 01810 HK 和 AAPL", "HK"),
|
||||
("比较 600519 SS 和 AAPL", "SS"),
|
||||
]
|
||||
|
||||
for message, requested_code in cases:
|
||||
with self.subTest(message=message, requested_code=requested_code):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="quote_1",
|
||||
name="get_realtime_quote",
|
||||
arguments={"stock_code": requested_code},
|
||||
),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Blocked invalid suffix token.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
scope = resolve_stock_scope(message, {"stock_code": "600519"}).stock_scope
|
||||
|
||||
self.assertNotIn(requested_code, scope.allowed_stock_codes)
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=scope,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(executed_calls, [])
|
||||
self.assertTrue(result.tool_calls_log[0]["guarded"])
|
||||
self.assertEqual(result.tool_calls_log[0]["requested_stock_code"], requested_code)
|
||||
tool_messages = [msg for msg in result.messages if msg.get("role") == "tool"]
|
||||
self.assertEqual(len(tool_messages), 1)
|
||||
self.assertIn("stock_scope_violation", tool_messages[0]["content"])
|
||||
|
||||
def test_run_agent_loop_blocks_indicator_tokens_from_followup(self):
|
||||
cases = [
|
||||
("分析 MA 均线", "MA"),
|
||||
("分析 KDJ 指标", "KDJ"),
|
||||
]
|
||||
|
||||
for message, requested_code in cases:
|
||||
with self.subTest(message=message, requested_code=requested_code):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="quote_1",
|
||||
name="get_realtime_quote",
|
||||
arguments={"stock_code": requested_code},
|
||||
),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Blocked indicator token.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
scope = resolve_stock_scope(message, {"stock_code": "600519"}).stock_scope
|
||||
|
||||
self.assertEqual(scope.allowed_stock_codes, {"600519"})
|
||||
self.assertNotIn(requested_code, scope.allowed_stock_codes)
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=scope,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(executed_calls, [])
|
||||
self.assertTrue(result.tool_calls_log[0]["guarded"])
|
||||
self.assertEqual(result.tool_calls_log[0]["requested_stock_code"], requested_code)
|
||||
tool_messages = [msg for msg in result.messages if msg.get("role") == "tool"]
|
||||
self.assertEqual(len(tool_messages), 1)
|
||||
self.assertIn("stock_scope_violation", tool_messages[0]["content"])
|
||||
|
||||
def test_run_agent_loop_blocks_untrusted_context_denied_token(self):
|
||||
cases = [
|
||||
("继续看", "HK", "港股"),
|
||||
("继续看", "KDJ", "KDJ 指标"),
|
||||
("分析 MA 均线", "MA", "均线"),
|
||||
]
|
||||
|
||||
for message, requested_code, stock_name in cases:
|
||||
with self.subTest(message=message, requested_code=requested_code):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need quote.",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="quote_1",
|
||||
name="get_realtime_quote",
|
||||
arguments={"stock_code": requested_code},
|
||||
),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Blocked untrusted context.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
scope_resolution = resolve_stock_scope(
|
||||
message,
|
||||
{"stock_code": requested_code, "stock_name": stock_name},
|
||||
)
|
||||
scope = scope_resolution.stock_scope
|
||||
|
||||
self.assertEqual(scope.allowed_stock_codes, set())
|
||||
self.assertNotIn("stock_code", scope_resolution.effective_context)
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=scope,
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(executed_calls, [])
|
||||
self.assertTrue(result.tool_calls_log[0]["guarded"])
|
||||
self.assertEqual(result.tool_calls_log[0]["requested_stock_code"], requested_code)
|
||||
tool_messages = [msg for msg in result.messages if msg.get("role") == "tool"]
|
||||
self.assertEqual(len(tool_messages), 1)
|
||||
self.assertIn("stock_scope_violation", tool_messages[0]["content"])
|
||||
|
||||
def test_run_agent_loop_rejects_namespaced_tool_name_without_executing_handler(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need news.",
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="news_1",
|
||||
name="default_api:search_stock_news",
|
||||
arguments={"stock_code": "AAPL", "stock_name": "贵州茅台"},
|
||||
),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="gemini",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Blocked wrong code.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="gemini",
|
||||
),
|
||||
]
|
||||
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "如果不考虑 AAPL 呢"},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=StockScope(expected_stock_code="600519", allowed_stock_codes={"600519"}),
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(executed_calls, [])
|
||||
self.assertFalse(result.tool_calls_log[0]["success"])
|
||||
self.assertNotIn("guarded", result.tool_calls_log[0])
|
||||
self.assertEqual(result.tool_calls_log[0]["tool"], "default_api:search_stock_news")
|
||||
tool_messages = [msg for msg in result.messages if msg.get("role") == "tool"]
|
||||
self.assertEqual(len(tool_messages), 1)
|
||||
self.assertIn("not found in registry", tool_messages[0]["content"])
|
||||
|
||||
def test_parallel_tool_batch_guards_only_conflicting_stock_calls(self):
|
||||
executed_calls = []
|
||||
registry = _make_stock_registry(executed_calls)
|
||||
adapter = _make_mock_adapter()
|
||||
adapter.call_with_tools.side_effect = [
|
||||
LLMResponse(
|
||||
content="Need mixed tools.",
|
||||
tool_calls=[
|
||||
ToolCall(id="quote_ok", name="get_realtime_quote", arguments={"stock_code": "600519"}),
|
||||
ToolCall(id="quote_bad", name="get_realtime_quote", arguments={"stock_code": "AAPL"}),
|
||||
ToolCall(id="echo_1", name="echo", arguments={"message": "not stock scoped"}),
|
||||
],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
LLMResponse(
|
||||
content="Done.",
|
||||
tool_calls=[],
|
||||
usage={"total_tokens": 10},
|
||||
provider="openai",
|
||||
),
|
||||
]
|
||||
|
||||
result = run_agent_loop(
|
||||
messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "继续看当前标的"},
|
||||
],
|
||||
tool_registry=registry,
|
||||
llm_adapter=adapter,
|
||||
max_steps=3,
|
||||
stock_scope=StockScope(expected_stock_code="600519", allowed_stock_codes={"600519"}),
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertIn(("quote", "600519"), executed_calls)
|
||||
self.assertIn(("echo", "not stock scoped"), executed_calls)
|
||||
self.assertNotIn(("quote", "AAPL"), executed_calls)
|
||||
guarded = [entry for entry in result.tool_calls_log if entry.get("guarded")]
|
||||
self.assertEqual(len(guarded), 1)
|
||||
self.assertEqual(guarded[0]["requested_stock_code"], "AAPL")
|
||||
|
||||
def test_prompt_omits_hardcoded_trend_baseline_when_default_policy_is_empty(self):
|
||||
"""Explicit skill runs should not silently keep the legacy trend baseline."""
|
||||
|
||||
Reference in New Issue
Block a user