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:
Alfred
2026-06-10 22:45:58 +08:00
committed by GitHub
parent 4f536026f8
commit da274f7061
22 changed files with 2405 additions and 65 deletions

View File

@@ -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."""