mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: add internal DSA tool surface (#1943)
This commit is contained in:
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [改进] Web AI 建议页新增主股票上下文,复用最近分析和股票索引候选,并改进表现统计零样本说明。
|
||||
- [改进] 补充本次设置页布局收敛:移动端分类导航改为横向滚动列表并保证设置内容首屏可见,桌面端保留分类说明并收紧字段布局层级与间距,提升首屏效率与可配置信息密度。
|
||||
- [文档] 在 README 快速开始中补充行情数据源配置说明(TUSHARE_TOKEN / Longbridge),明确未配置时仍可走 AkShare、Baostock、YFinance 等免费兜底源,日志中相关提示不影响运行。同步更新docs下的中英双份 README
|
||||
- [改进] 新增 #1743 Phase 6a 内部 DSA Tool Surface 契约,统一工具 schema、stock scope fail-closed guard、结构化错误、审计摘要和脱敏诊断边界,并明确外部 AgentBackend 工具能力仍需 wire-level probe 证明。
|
||||
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
@@ -53,6 +53,7 @@ AGENT_GENERATION_BACKEND=auto
|
||||
- 本地 CLI 执行上限有硬边界:`GENERATION_BACKEND_TIMEOUT_SECONDS` 最大 `3600`,`GENERATION_BACKEND_MAX_OUTPUT_BYTES` 最大 `33554432`,`GENERATION_BACKEND_MAX_CONCURRENCY` 最大 `16`,`LOCAL_CLI_BACKEND_MAX_CONCURRENCY` 最大 `4`。诊断 stdout/stderr 与最终响应合计超过输出上限时会返回结构化 `output_too_large`;对 `--output-last-message` preset,stdout 中重复打印的最终响应不会重复计入,也不会作为 `stdout_preview` 暴露。
|
||||
- 本地 CLI 默认并发为 1;有效并发为 `min(LOCAL_CLI_BACKEND_MAX_CONCURRENCY, GENERATION_BACKEND_MAX_CONCURRENCY)`,不继承 `MAX_WORKERS`。
|
||||
- `AGENT_GENERATION_BACKEND=auto` 不会继承 `GENERATION_BACKEND` 的 local CLI 值;Agent 工具调用继续使用 LiteLLM。Web 设置页仅暴露 `auto|litellm`;手写 `AGENT_GENERATION_BACKEND=codex_cli|claude_code_cli|opencode_cli` 不实现 text-only Agent mode,会返回明确 unsupported tool-calling 诊断。
|
||||
- Phase 6a 的 DSA Tool Surface 只是内部工具 schema、权限元数据、scope guard、结构化错误和审计/脱敏边界,用于后续 AgentBackend 统一消费;stock-scoped 工具调用必须显式传入 `ToolAccessContext.stock_scope`,有 `stock_code` 参数但未声明 stock scope 的工具会 fail-closed。它不新增外部 runtime adapter、MCP server、REST API、Web UI 或 `.env` 配置,也不改变 generation backend / Agent backend 路由。Codex / Claude / OpenCode / Hermes 在完成 wire-level tool call / tool result roundtrip probe 前仍不能绕过 Tool Surface 直接拼 provider-specific tool schema,`codex_cli` / `claude_code_cli` / `opencode_cli` 仍保持 generation-only,`supports_tools=false`。
|
||||
- Web 设置页的生成后端快速检查只读取已保存的 `.env`、运行时兜底值和未保存草稿;它不会写配置、重载运行时,也不会发起真实模型请求。`available` 只表示当前配置具备尝试运行的条件。JSON 冒烟测试是单独的显式操作,会使用服务端固定的 JSON 提示词和 schema 发起一次真实的生成后端请求,用于验证提取器、JSON 契约、超时、输出限制和 usage-unavailable 语义。
|
||||
- `GET /api/v1/system/config/generation-backends/status` 只读取已保存配置;未保存草稿需调用 `POST /api/v1/system/config/generation-backends/status/preview` 或 `POST /api/v1/system/config/generation-backends/smoke-test`。被遮罩的密钥字段会继续沿用已保存值。`health_status` 与 `last_error_code/message` 只代表本次计算结果,不是历史持久健康状态。
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ AGENT_GENERATION_BACKEND=auto
|
||||
- Local CLI execution has hard caps: `GENERATION_BACKEND_TIMEOUT_SECONDS` max `3600`, `GENERATION_BACKEND_MAX_OUTPUT_BYTES` max `33554432`, `GENERATION_BACKEND_MAX_CONCURRENCY` max `16`, and `LOCAL_CLI_BACKEND_MAX_CONCURRENCY` max `4`. Diagnostic stdout/stderr plus the final response are counted together; for `--output-last-message` presets, the final response duplicated to stdout is not counted twice and is not exposed in `stdout_preview`.
|
||||
- Local CLI default concurrency is 1. Effective local CLI concurrency is `min(LOCAL_CLI_BACKEND_MAX_CONCURRENCY, GENERATION_BACKEND_MAX_CONCURRENCY)` and does not inherit `MAX_WORKERS`.
|
||||
- `AGENT_GENERATION_BACKEND=auto` does not inherit local CLI values from `GENERATION_BACKEND`; Agent tool calling remains on LiteLLM. The Web settings page only exposes `auto|litellm`; a hand-written `AGENT_GENERATION_BACKEND=codex_cli|claude_code_cli|opencode_cli` does not enable Agent text-only mode and returns an explicit unsupported tool-calling diagnostic.
|
||||
- Phase 6a DSA Tool Surface is an internal boundary for shared tool schema, permission metadata, scope guards, structured errors, audit summaries, and redacted diagnostics for future AgentBackend adapters. Stock-scoped tool calls must pass an explicit `ToolAccessContext.stock_scope`; tools with a `stock_code` parameter but no declared stock scope fail closed. It does not add external runtime adapters, an MCP server, REST APIs, Web UI, `.env` settings, or generation/Agent backend route changes. Codex / Claude / OpenCode / Hermes must not bypass this Tool Surface with ad-hoc provider-specific schemas before a wire-level tool call / tool result roundtrip probe proves support; `codex_cli` / `claude_code_cli` / `opencode_cli` remain generation-only with `supports_tools=false`.
|
||||
- The Web settings generation-backend quick check only reads saved `.env`, runtime defaults, and unsaved drafts. It does not write config, reload runtime, or send a real model request; `available` only means the current config can be attempted. JSON smoke test is a separate explicit button that sends one real generation-backend request with a server-owned fixed JSON prompt/schema to verify extractor behavior, JSON contract, timeout, output limits, and usage-unavailable semantics.
|
||||
- `GET /api/v1/system/config/generation-backends/status` only reads saved config. Unsaved drafts use `POST /api/v1/system/config/generation-backends/status/preview` or `POST /api/v1/system/config/generation-backends/smoke-test`; masked secrets preserve saved values. `health_status` and `last_error_code/message` describe only the current computation, not persisted historical health.
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ Generation backend 配置是更外层的运行时选择契约。Phase 4 支持 `
|
||||
|
||||
生成后端状态接口与 Web 面板会把轻量检查和冒烟测试分开展示:快速检查只读取已保存 `.env`、运行时兜底值和当前草稿,不写配置、不重载运行时,也不发起真实模型请求;只有 JSON 冒烟测试会使用固定的 JSON 提示词和 schema 发起真实请求。`health_status` 与 `last_error_code/message` 是本次计算结果,不表示历史最后错误。本地 CLI preset 的 `supports_tools=false` 仅表示不支持 DSA Agent 工具调用链路,不代表普通文本生成不可用。
|
||||
|
||||
Phase 6a Tool Surface 只补充 AgentBackend 前置的内部工具面:统一 DSA 工具 schema、public descriptor、MCP-compatible descriptor、scope guard、结构化错误、审计摘要和脱敏诊断。stock-scoped 工具调用必须显式传入 `ToolAccessContext.stock_scope`;有 `stock_code` 参数但未声明 stock scope 的工具会 fail-closed。它不新增 provider、模型、Base URL、README、`.env`、API、Web 配置入口、MCP server 或外部 runtime adapter,也不改变 `GENERATION_BACKEND` / `AGENT_GENERATION_BACKEND` 路由。后续 Codex / Claude / OpenCode / Hermes adapter 必须消费这层 Tool Surface,不能绕过它直接拼 provider-specific tool schema;真实 Agent tools 能力仍必须由 wire-level tool call / tool result roundtrip probe 证明。
|
||||
|
||||
本 PR smoke 验证版本为 `claude 2.1.177 (Claude Code)` 与 `opencode 1.17.11`,不声明更宽最低版本。如果用户安装的 CLI 不支持这些固定 preset 参数或非交互输出契约,DSA 会返回结构化 `capability_unsupported`、`cli_contract_unsupported`、`invalid_json`、`schema_validation_failed` 或对应 backend error,并在配置 backend fallback 时回退到 `litellm`。
|
||||
|
||||
本地 CLI Backend 不等于离线模型。Docker、云服务器和 CI 不天然拥有本机 CLI 登录态;macOS 从 Finder/Dock 启动桌面端时不继承 shell PATH,打包桌面端会在启动后端时补入常见 Homebrew 路径,如果设置检查仍提示找不到 CLI 可执行文件,需要完全退出并重开 DSA。DSA 不读取 Codex/Claude/OpenCode credential 文件,也不为 OpenCode 生成或搬运 provider API key;子进程可能按 CLI 自身机制使用本机登录态或配置,股票代码、新闻、持仓上下文、分析 prompt 和报告草稿可能被对应 CLI 背后的服务处理。DSA 默认只继承最小运行环境,并拒绝通配继承 `CLAUDE_*`、`ANTHROPIC_*`、`OPENCODE_*`、provider API key/token/base-url/model env 和 webhook tokens,降低父进程配置泄漏风险;`CODEX_HOME` 仅作为既有 Codex CLI 登录目录兼容的 exact-name 例外保留。
|
||||
|
||||
@@ -27,6 +27,16 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
from src.agent.llm_adapter import LLMToolAdapter
|
||||
from src.agent.stream_events import stream_event
|
||||
from src.agent.tools.registry import ToolRegistry
|
||||
from src.agent.tools.execution import (
|
||||
_build_tool_cache_key,
|
||||
_guard_tool_stock_scope,
|
||||
_is_non_retriable_tool_result,
|
||||
_is_stock_scoped_tool,
|
||||
_normalize_guard_stock_code,
|
||||
_normalize_tool_stock_code,
|
||||
execute_runner_tool_call,
|
||||
serialize_tool_result,
|
||||
)
|
||||
from src.agent.stock_scope import StockScope
|
||||
from src.llm.usage import should_persist_usage_telemetry
|
||||
from src.utils.data_processing import normalize_report_signal_attribution
|
||||
@@ -34,6 +44,20 @@ from src.storage import persist_llm_usage as _persist_usage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"RunLoopResult",
|
||||
"parse_dashboard_json",
|
||||
"run_agent_loop",
|
||||
"serialize_tool_result",
|
||||
"try_parse_json",
|
||||
"_build_tool_cache_key",
|
||||
"_guard_tool_stock_scope",
|
||||
"_is_non_retriable_tool_result",
|
||||
"_is_stock_scoped_tool",
|
||||
"_normalize_guard_stock_code",
|
||||
"_normalize_tool_stock_code",
|
||||
]
|
||||
|
||||
# Tool name → friendly label for progress messages
|
||||
_THINKING_TOOL_LABELS: Dict[str, str] = {
|
||||
"get_realtime_quote": "行情获取",
|
||||
@@ -84,130 +108,6 @@ class RunLoopResult:
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def serialize_tool_result(result: Any) -> str:
|
||||
"""Serialize a tool result to a JSON string consumable by an LLM."""
|
||||
if result is None:
|
||||
return json.dumps({"result": None})
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if isinstance(result, (dict, list)):
|
||||
try:
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(result)
|
||||
if hasattr(result, "__dict__"):
|
||||
try:
|
||||
d = {k: v for k, v in result.__dict__.items() if not k.startswith("_")}
|
||||
return json.dumps(d, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(result)
|
||||
return str(result)
|
||||
|
||||
|
||||
def _normalize_tool_stock_code(value: Any) -> Any:
|
||||
"""Canonicalize stock code arguments so equivalent HK variants share one cache key."""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
text = value.strip().upper()
|
||||
if not text:
|
||||
return text
|
||||
|
||||
if text.endswith(".HK"):
|
||||
base = text[:-3]
|
||||
if base.isdigit() and 1 <= len(base) <= 5:
|
||||
return f"HK{base.zfill(5)}"
|
||||
|
||||
if text.startswith("HK"):
|
||||
base = text[2:]
|
||||
if base.isdigit() and 1 <= len(base) <= 5:
|
||||
return f"HK{base.zfill(5)}"
|
||||
|
||||
if text.isdigit() and len(text) == 5:
|
||||
return f"HK{text}"
|
||||
|
||||
try:
|
||||
from data_provider.base import canonical_stock_code, normalize_stock_code
|
||||
|
||||
return canonical_stock_code(normalize_stock_code(text))
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def _build_tool_cache_key(tool_name: str, arguments: Dict[str, Any]) -> Optional[str]:
|
||||
"""Build a stable cache key for tool calls with normalized stock-code arguments."""
|
||||
if not isinstance(arguments, dict):
|
||||
return None
|
||||
|
||||
normalized_args: Dict[str, Any] = {}
|
||||
for key, value in arguments.items():
|
||||
if key == "stock_code":
|
||||
normalized_args[key] = _normalize_tool_stock_code(value)
|
||||
else:
|
||||
normalized_args[key] = value
|
||||
|
||||
try:
|
||||
payload = json.dumps(normalized_args, ensure_ascii=False, sort_keys=True, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f"{tool_name}:{payload}"
|
||||
|
||||
|
||||
def _is_non_retriable_tool_result(result: Any) -> bool:
|
||||
"""Return True when a tool result explicitly tells the agent not to retry."""
|
||||
return (
|
||||
isinstance(result, dict)
|
||||
and bool(result.get("error"))
|
||||
and result.get("retriable") is False
|
||||
)
|
||||
|
||||
|
||||
def _is_stock_scoped_tool(tool_registry: ToolRegistry, tool_name: str) -> bool:
|
||||
tool_def = tool_registry.resolve(tool_name)
|
||||
if tool_def is None:
|
||||
return False
|
||||
return any(param.name == "stock_code" for param in tool_def.parameters)
|
||||
|
||||
|
||||
def _normalize_guard_stock_code(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
raw = value if isinstance(value, str) else str(value)
|
||||
normalized = _normalize_tool_stock_code(raw)
|
||||
return normalized if isinstance(normalized, str) else str(normalized)
|
||||
|
||||
|
||||
def _guard_tool_stock_scope(tool_registry: ToolRegistry, tool_name: str, arguments: Dict[str, Any], stock_scope: Optional[StockScope]) -> Optional[Dict[str, Any]]:
|
||||
if stock_scope is None or not isinstance(arguments, dict):
|
||||
return None
|
||||
if not _is_stock_scoped_tool(tool_registry, tool_name):
|
||||
return None
|
||||
if "stock_code" not in arguments:
|
||||
return None
|
||||
|
||||
requested = _normalize_guard_stock_code(arguments.get("stock_code"))
|
||||
|
||||
expected = _normalize_guard_stock_code(stock_scope.expected_stock_code)
|
||||
allowed = {
|
||||
normalized
|
||||
for code in stock_scope.allowed_stock_codes
|
||||
for normalized in [_normalize_guard_stock_code(code)]
|
||||
if normalized
|
||||
}
|
||||
if requested and (requested == expected or requested in allowed):
|
||||
return None
|
||||
|
||||
return {
|
||||
"error": "stock_scope_violation",
|
||||
"expected_stock_code": expected,
|
||||
"requested_stock_code": requested,
|
||||
"allowed_stock_codes": sorted(allowed),
|
||||
"retriable": False,
|
||||
}
|
||||
|
||||
|
||||
def parse_dashboard_json(content: str) -> Optional[Dict[str, Any]]:
|
||||
"""Extract and parse a Decision Dashboard JSON from agent text.
|
||||
|
||||
@@ -709,44 +609,12 @@ def _execute_tools(
|
||||
"""
|
||||
|
||||
def _exec_single(tc_item):
|
||||
t0 = time.time()
|
||||
cache_key = _build_tool_cache_key(tc_item.name, tc_item.arguments)
|
||||
guard_result = _guard_tool_stock_scope(tool_registry, tc_item.name, tc_item.arguments, stock_scope)
|
||||
if guard_result is not None:
|
||||
dur = round(time.time() - t0, 2)
|
||||
result_str = serialize_tool_result(guard_result)
|
||||
if cache_key and non_retriable_tool_results is not None:
|
||||
non_retriable_tool_results[cache_key] = result_str
|
||||
logger.warning(
|
||||
"Tool '%s' blocked by stock scope: requested=%s expected=%s allowed=%s",
|
||||
tc_item.name,
|
||||
guard_result.get("requested_stock_code"),
|
||||
guard_result.get("expected_stock_code"),
|
||||
guard_result.get("allowed_stock_codes"),
|
||||
return execute_runner_tool_call(
|
||||
tool_call=tc_item,
|
||||
tool_registry=tool_registry,
|
||||
stock_scope=stock_scope,
|
||||
non_retriable_tool_results=non_retriable_tool_results,
|
||||
)
|
||||
return tc_item, result_str, False, dur, False, guard_result
|
||||
|
||||
if cache_key and non_retriable_tool_results is not None and cache_key in non_retriable_tool_results:
|
||||
dur = round(time.time() - t0, 2)
|
||||
logger.info(
|
||||
"Tool '%s' skipped via non-retriable cache for arguments=%s",
|
||||
tc_item.name,
|
||||
tc_item.arguments,
|
||||
)
|
||||
return tc_item, non_retriable_tool_results[cache_key], False, dur, True, None
|
||||
|
||||
try:
|
||||
res = tool_registry.execute(tc_item.name, **tc_item.arguments)
|
||||
res_str = serialize_tool_result(res)
|
||||
ok = True
|
||||
if cache_key and non_retriable_tool_results is not None and _is_non_retriable_tool_result(res):
|
||||
non_retriable_tool_results[cache_key] = res_str
|
||||
except Exception as e:
|
||||
res_str = json.dumps({"error": str(e)})
|
||||
ok = False
|
||||
logger.warning("Tool '%s' failed: %s", tc_item.name, e)
|
||||
dur = round(time.time() - t0, 2)
|
||||
return tc_item, res_str, ok, dur, False, None
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ def _normalize_stock_code(value: Any) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
from src.agent.runner import _normalize_tool_stock_code
|
||||
from src.agent.tools.execution import _normalize_tool_stock_code
|
||||
|
||||
normalized = _normalize_tool_stock_code(text)
|
||||
except Exception:
|
||||
|
||||
400
src/agent/tool_surface.py
Normal file
400
src/agent/tool_surface.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Internal DSA Tool Surface for future external Agent runtimes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from src.agent.tools.execution import (
|
||||
ToolAccessContext,
|
||||
_guard_tool_stock_scope,
|
||||
build_tool_audit,
|
||||
redact_diagnostic_value,
|
||||
serialize_tool_result,
|
||||
)
|
||||
from src.agent.tools.registry import (
|
||||
SUPPORTED_TOOL_SURFACE_SCOPE_DIMENSIONS,
|
||||
ToolDefinition,
|
||||
ToolParameter,
|
||||
ToolRegistry,
|
||||
)
|
||||
|
||||
|
||||
_JSON_TYPE_TO_PYTHON = {
|
||||
"string": (str,),
|
||||
"integer": (int,),
|
||||
"number": (int, float),
|
||||
"boolean": (bool,),
|
||||
"array": (list,),
|
||||
"object": (dict,),
|
||||
}
|
||||
|
||||
|
||||
class ToolSurface:
|
||||
"""Internal tool schema and execution surface.
|
||||
|
||||
This is a Python API only. It intentionally does not expose REST, MCP, or
|
||||
provider-specific runtime transport.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def list_tools(self, format: str = "public") -> list[dict]:
|
||||
"""List tools in a stable schema format."""
|
||||
normalized = (format or "public").strip().lower()
|
||||
if normalized == "openai":
|
||||
return self._registry.to_openai_tools()
|
||||
if normalized == "public":
|
||||
return [tool_def.to_public_descriptor() for tool_def in self._registry.list_tools()]
|
||||
if normalized == "mcp_descriptor":
|
||||
return [tool_def.to_mcp_descriptor() for tool_def in self._registry.list_tools()]
|
||||
raise ValueError(f"Unsupported tool surface format: {format}")
|
||||
|
||||
def execute_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: Any,
|
||||
context: Optional[ToolAccessContext] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute one registered tool by exact name and return structured output."""
|
||||
ctx = context or ToolAccessContext()
|
||||
started_at = time.time()
|
||||
tool_name = name if isinstance(name, str) else str(name)
|
||||
tool_def = self._registry.resolve(tool_name) if isinstance(name, str) else None
|
||||
|
||||
if tool_def is None:
|
||||
if isinstance(name, str) and (":" in name or "." in name):
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="invalid_tool_name",
|
||||
message="Tool name must exactly match a registered DSA tool.",
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
arguments=arguments,
|
||||
)
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="tool_not_found",
|
||||
message="Tool not found.",
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
validation_error = _validate_arguments(tool_def, arguments)
|
||||
if validation_error is not None:
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="invalid_arguments",
|
||||
message=validation_error,
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
scope_contract_error = _validate_scope_contract(tool_def)
|
||||
if scope_contract_error is not None:
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="scope_contract_violation",
|
||||
message=scope_contract_error["message"],
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
details=scope_contract_error["details"],
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
guard_result = None
|
||||
if _requires_stock_scope(tool_def):
|
||||
if ctx.stock_scope is None:
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="stock_scope_violation",
|
||||
message="Tool call requires an explicit stock scope.",
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
details={
|
||||
"reason": "stock_scope_required",
|
||||
"scope_dimensions": list(tool_def.policy.scope_dimensions),
|
||||
},
|
||||
arguments=arguments,
|
||||
)
|
||||
guard_result = _guard_tool_stock_scope(
|
||||
self._registry,
|
||||
tool_name,
|
||||
arguments,
|
||||
ctx.stock_scope,
|
||||
)
|
||||
if guard_result is not None:
|
||||
result_text = serialize_tool_result(guard_result)
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="stock_scope_violation",
|
||||
message="Tool call is outside the allowed stock scope.",
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
details={
|
||||
"expected_stock_code": guard_result.get("expected_stock_code"),
|
||||
"requested_stock_code": guard_result.get("requested_stock_code"),
|
||||
"allowed_stock_codes": guard_result.get("allowed_stock_codes", []),
|
||||
},
|
||||
result_text=result_text,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
timeout = ctx.timeout_seconds
|
||||
try:
|
||||
if timeout is not None and timeout > 0:
|
||||
result = _execute_with_timeout(tool_def, arguments, float(timeout))
|
||||
else:
|
||||
result = tool_def.handler(**arguments)
|
||||
except FuturesTimeoutError:
|
||||
timeout_label = f"{float(timeout or 0):.2f}s"
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="timeout",
|
||||
message=f"Tool execution timed out after {timeout_label}.",
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=True,
|
||||
details={
|
||||
"timeout_seconds": float(timeout or 0),
|
||||
"cancel_requested": True,
|
||||
"handler_may_continue": True,
|
||||
},
|
||||
arguments=arguments,
|
||||
)
|
||||
except Exception:
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="handler_error",
|
||||
message="Tool handler failed.",
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
try:
|
||||
result_text = serialize_tool_result(result)
|
||||
except Exception:
|
||||
return self._error_result(
|
||||
tool_name=tool_name,
|
||||
code="serialization_error",
|
||||
message="Tool result could not be serialized.",
|
||||
started_at=started_at,
|
||||
context=ctx,
|
||||
retriable=False,
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
public_result = _public_payload_from_result_text(result_text)
|
||||
result_truncated = False
|
||||
if ctx.max_result_bytes is not None and ctx.max_result_bytes >= 0:
|
||||
result_text, result_truncated = _truncate_text_bytes(result_text, int(ctx.max_result_bytes))
|
||||
public_result = None if result_truncated else _public_payload_from_result_text(result_text)
|
||||
|
||||
duration = time.time() - started_at
|
||||
return {
|
||||
"ok": True,
|
||||
"tool_name": tool_name,
|
||||
"result": public_result,
|
||||
"result_text": result_text,
|
||||
"error": None,
|
||||
"audit": build_tool_audit(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments,
|
||||
result=result_text,
|
||||
duration=duration,
|
||||
context=ctx,
|
||||
),
|
||||
"diagnostics": {
|
||||
"redacted": True,
|
||||
"result_length": len(result_text.encode("utf-8")),
|
||||
"result_truncated": result_truncated,
|
||||
"preview": redact_diagnostic_value(result_text),
|
||||
},
|
||||
}
|
||||
|
||||
def _error_result(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
code: str,
|
||||
message: str,
|
||||
started_at: float,
|
||||
context: ToolAccessContext,
|
||||
retriable: bool,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
result_text: Optional[str] = None,
|
||||
arguments: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
duration = time.time() - started_at
|
||||
safe_text = result_text or json.dumps(
|
||||
{"error": message, "code": code, "retriable": retriable},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
result_truncated = False
|
||||
if context.max_result_bytes is not None and context.max_result_bytes >= 0:
|
||||
safe_text, result_truncated = _truncate_text_bytes(safe_text, int(context.max_result_bytes))
|
||||
return {
|
||||
"ok": False,
|
||||
"tool_name": tool_name,
|
||||
"result": None,
|
||||
"result_text": safe_text,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"retriable": retriable,
|
||||
"details": details or {},
|
||||
},
|
||||
"audit": build_tool_audit(
|
||||
tool_name=tool_name,
|
||||
arguments=arguments if arguments is not None else {},
|
||||
result=safe_text,
|
||||
error_code=code,
|
||||
duration=duration,
|
||||
context=context,
|
||||
),
|
||||
"diagnostics": {
|
||||
"redacted": True,
|
||||
"result_length": len(safe_text.encode("utf-8")),
|
||||
"result_truncated": result_truncated,
|
||||
"preview": redact_diagnostic_value(safe_text),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _execute_with_timeout(tool_def: ToolDefinition, arguments: Dict[str, Any], timeout: float) -> Any:
|
||||
pool = ThreadPoolExecutor(max_workers=1)
|
||||
ctx = contextvars.copy_context()
|
||||
future = pool.submit(ctx.run, tool_def.handler, **arguments)
|
||||
try:
|
||||
return future.result(timeout=timeout)
|
||||
except FuturesTimeoutError:
|
||||
future.cancel()
|
||||
raise
|
||||
finally:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _validate_arguments(tool_def: ToolDefinition, arguments: Any) -> Optional[str]:
|
||||
if not isinstance(arguments, dict):
|
||||
return "arguments must be an object"
|
||||
|
||||
params = {param.name: param for param in tool_def.parameters}
|
||||
for param in tool_def.parameters:
|
||||
if param.required and param.name not in arguments:
|
||||
return f"missing required argument: {param.name}"
|
||||
|
||||
accepts_extra = _handler_accepts_extra_kwargs(tool_def)
|
||||
for key in arguments:
|
||||
if key not in params and not accepts_extra:
|
||||
return f"unexpected argument: {key}"
|
||||
|
||||
for key, value in arguments.items():
|
||||
param = params.get(key)
|
||||
if param is None:
|
||||
continue
|
||||
error = _validate_parameter_value(param, value)
|
||||
if error:
|
||||
return error
|
||||
return None
|
||||
|
||||
|
||||
def _handler_accepts_extra_kwargs(tool_def: ToolDefinition) -> bool:
|
||||
return tool_def.accepts_extra_arguments()
|
||||
|
||||
|
||||
def _requires_stock_scope(tool_def: ToolDefinition) -> bool:
|
||||
return "stock" in tool_def.policy.scope_dimensions
|
||||
|
||||
|
||||
def _validate_scope_contract(tool_def: ToolDefinition) -> Optional[Dict[str, Any]]:
|
||||
dimensions = list(tool_def.policy.scope_dimensions)
|
||||
has_stock_param = any(param.name == "stock_code" for param in tool_def.parameters)
|
||||
declares_stock_scope = "stock" in dimensions
|
||||
unsupported = [
|
||||
dimension
|
||||
for dimension in dimensions
|
||||
if dimension not in SUPPORTED_TOOL_SURFACE_SCOPE_DIMENSIONS
|
||||
]
|
||||
if unsupported:
|
||||
return {
|
||||
"message": "Tool declares scope dimensions that Phase 6a cannot enforce.",
|
||||
"details": {
|
||||
"scope_dimensions": dimensions,
|
||||
"unsupported_scope_dimensions": unsupported,
|
||||
"supported_scope_dimensions": sorted(SUPPORTED_TOOL_SURFACE_SCOPE_DIMENSIONS),
|
||||
},
|
||||
}
|
||||
if has_stock_param and not declares_stock_scope:
|
||||
return {
|
||||
"message": "Tool has stock_code parameter but does not declare stock scope.",
|
||||
"details": {
|
||||
"scope_dimensions": dimensions,
|
||||
"missing_scope_dimension": "stock",
|
||||
},
|
||||
}
|
||||
if declares_stock_scope and not has_stock_param:
|
||||
return {
|
||||
"message": "Tool declares stock scope but has no stock_code parameter.",
|
||||
"details": {
|
||||
"scope_dimensions": dimensions,
|
||||
"missing_parameter": "stock_code",
|
||||
},
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _validate_parameter_value(param: ToolParameter, value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return f"argument {param.name} must not be null"
|
||||
if param.enum and value not in param.enum:
|
||||
return f"argument {param.name} must be one of: {', '.join(map(str, param.enum))}"
|
||||
expected = _JSON_TYPE_TO_PYTHON.get(param.type)
|
||||
if not expected:
|
||||
return None
|
||||
if param.type == "integer":
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return f"argument {param.name} must be integer"
|
||||
return None
|
||||
if param.type == "number":
|
||||
if isinstance(value, bool) or not isinstance(value, expected):
|
||||
return f"argument {param.name} must be number"
|
||||
return None
|
||||
if not isinstance(value, expected):
|
||||
return f"argument {param.name} must be {param.type}"
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_text_bytes(text: str, max_bytes: int) -> tuple[str, bool]:
|
||||
raw = text.encode("utf-8")
|
||||
if len(raw) <= max_bytes:
|
||||
return text, False
|
||||
if max_bytes <= 0:
|
||||
return "", True
|
||||
marker = "<truncated>"
|
||||
marker_bytes = marker.encode("utf-8")
|
||||
if max_bytes <= len(marker_bytes):
|
||||
return raw[:max_bytes].decode("utf-8", errors="ignore"), True
|
||||
prefix = raw[: max_bytes - len(marker_bytes)].decode("utf-8", errors="ignore")
|
||||
return f"{prefix}{marker}", True
|
||||
|
||||
|
||||
def _public_payload_from_result_text(result_text: str) -> Any:
|
||||
try:
|
||||
return json.loads(result_text)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return result_text
|
||||
@@ -6,6 +6,6 @@ Provides ToolRegistry, @tool decorator, and wrapped tools
|
||||
for the stock analysis agent.
|
||||
"""
|
||||
|
||||
from src.agent.tools.registry import ToolRegistry, ToolDefinition, ToolParameter, tool
|
||||
from src.agent.tools.registry import ToolRegistry, ToolDefinition, ToolParameter, ToolPolicy, tool
|
||||
|
||||
__all__ = ["ToolRegistry", "ToolDefinition", "ToolParameter", "tool"]
|
||||
__all__ = ["ToolRegistry", "ToolDefinition", "ToolParameter", "ToolPolicy", "tool"]
|
||||
|
||||
@@ -9,10 +9,17 @@ Tools:
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition, ToolPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ANALYSIS_READ_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read", "db_read"],
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
)
|
||||
|
||||
|
||||
def _fetch_trend_data(stock_code: str):
|
||||
"""Fetch historical OHLCV (DataFrame) for trend analysis. DB first, then DataFetcher fallback."""
|
||||
@@ -96,6 +103,7 @@ analyze_trend_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_analyze_trend,
|
||||
category="analysis",
|
||||
policy=_ANALYSIS_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -187,6 +195,7 @@ calculate_ma_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_calculate_ma,
|
||||
category="analysis",
|
||||
policy=_ANALYSIS_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -303,6 +312,7 @@ get_volume_analysis_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_volume_analysis,
|
||||
category="analysis",
|
||||
policy=_ANALYSIS_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -509,6 +519,7 @@ analyze_pattern_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_analyze_pattern,
|
||||
category="analysis",
|
||||
policy=_ANALYSIS_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,22 @@ Tools:
|
||||
|
||||
import logging
|
||||
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition, ToolPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BACKTEST_READ_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["db_read"],
|
||||
permissions=["backtest:read"],
|
||||
scope_dimensions=["stock"],
|
||||
)
|
||||
_BACKTEST_GLOBAL_READ_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["db_read"],
|
||||
permissions=["backtest:read"],
|
||||
)
|
||||
|
||||
_backtest_service = None
|
||||
|
||||
|
||||
@@ -123,6 +135,7 @@ get_skill_backtest_summary_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_skill_backtest_summary,
|
||||
category="data",
|
||||
policy=_BACKTEST_GLOBAL_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -142,6 +155,7 @@ get_strategy_backtest_summary_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_overall_backtest_summary,
|
||||
category="data",
|
||||
policy=_BACKTEST_GLOBAL_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -231,6 +245,7 @@ get_stock_backtest_summary_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_stock_backtest_summary,
|
||||
category="data",
|
||||
policy=_BACKTEST_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,34 @@ from datetime import date
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition, ToolPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MARKET_DATA_STOCK_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read"],
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
)
|
||||
_MARKET_DATA_CACHE_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read", "db_read", "db_write_cache"],
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
)
|
||||
_ANALYSIS_CONTEXT_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["db_read"],
|
||||
permissions=["analysis_context:read"],
|
||||
scope_dimensions=["stock"],
|
||||
)
|
||||
_PORTFOLIO_READ_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["db_read"],
|
||||
permissions=["portfolio:read"],
|
||||
)
|
||||
|
||||
_fetcher_manager_singleton = None
|
||||
_fetcher_manager_lock = Lock()
|
||||
_DAILY_HISTORY_DEFAULT_DAYS = 60
|
||||
@@ -280,6 +304,7 @@ get_realtime_quote_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_realtime_quote,
|
||||
category="data",
|
||||
policy=_MARKET_DATA_STOCK_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -361,6 +386,7 @@ get_daily_history_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_daily_history,
|
||||
category="data",
|
||||
policy=_MARKET_DATA_CACHE_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -405,6 +431,7 @@ get_chip_distribution_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_chip_distribution,
|
||||
category="data",
|
||||
policy=_MARKET_DATA_STOCK_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -446,6 +473,7 @@ get_analysis_context_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_analysis_context,
|
||||
category="data",
|
||||
policy=_ANALYSIS_CONTEXT_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -503,6 +531,7 @@ get_stock_info_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_stock_info,
|
||||
category="data",
|
||||
policy=_MARKET_DATA_STOCK_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -610,6 +639,7 @@ get_portfolio_snapshot_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_portfolio_snapshot,
|
||||
category="data",
|
||||
policy=_PORTFOLIO_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -688,6 +718,7 @@ get_capital_flow_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_capital_flow,
|
||||
category="data",
|
||||
policy=_MARKET_DATA_STOCK_POLICY,
|
||||
)
|
||||
|
||||
|
||||
|
||||
342
src/agent/tools/execution.py
Normal file
342
src/agent/tools/execution.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shared agent-tool execution helpers.
|
||||
|
||||
This module is intentionally runtime-neutral. It contains the existing
|
||||
runner semantics that later Tool Surface / AgentBackend adapters can reuse
|
||||
without importing the full ReAct loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from src.agent.tools.registry import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUMMARY_LIMIT = 500
|
||||
_TOKEN_PATTERN = re.compile(
|
||||
r"(?i)\b(?:sk|pk|ghp|gho|github_pat|xox[baprs]?|bearer)[-_a-z0-9]{12,}\b"
|
||||
)
|
||||
_AUTH_PATTERN = re.compile(
|
||||
r"(?i)\b((?:proxy[-_]?authorization|authorization)\s*[:=]\s*)"
|
||||
r"[^\s,;\"']+(?:\s+[^\s,;\"']+)?"
|
||||
)
|
||||
_URL_CREDENTIAL_PATTERN = re.compile(r"([a-z][a-z0-9+.-]*://)([^/\s:@]+):([^/\s@]+)@", re.IGNORECASE)
|
||||
_HEADER_SECRET_PATTERN = re.compile(r"(?i)\b(api[-_]?key|token|secret|cookie|set-cookie)\b\s*[:=]\s*[^\s,;]+")
|
||||
_QUOTED_SECRET_FIELD_PATTERN = re.compile(
|
||||
r"(?i)([\"']?(?:authorization|proxy-authorization|api[-_]?key|x-api-key|token|access[-_]?token|"
|
||||
r"refresh[-_]?token|secret|client[-_]?secret|password|passwd|cookie|set-cookie)[\"']?\s*[:=]\s*)([\"']).*?(\2)"
|
||||
)
|
||||
_HOME_PATH_PATTERN = re.compile(r"(/Users/[^/\s]+|/home/[^/\s]+)(/[^\s,;]*)?")
|
||||
_SECRET_KEY_NAMES = {
|
||||
"authorization",
|
||||
"proxy_authorization",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"x_api_key",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"client_secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"cookie",
|
||||
"set_cookie",
|
||||
}
|
||||
_SECRET_KEY_MARKERS = ("api_key", "apikey", "token", "secret", "password", "passwd", "cookie")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolAccessContext:
|
||||
"""Execution context for Tool Surface calls."""
|
||||
|
||||
stock_scope: Any = None
|
||||
market: Optional[str] = None
|
||||
time_range: Optional[dict] = None
|
||||
data_sources: Optional[List[str]] = None
|
||||
backend: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
timeout_seconds: Optional[float] = None
|
||||
max_result_bytes: Optional[int] = None
|
||||
audit_context: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def serialize_tool_result(result: Any) -> str:
|
||||
"""Serialize a tool result to a JSON string consumable by an LLM."""
|
||||
if result is None:
|
||||
return json.dumps({"result": None})
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
if isinstance(result, (dict, list)):
|
||||
try:
|
||||
return json.dumps(result, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(result)
|
||||
if hasattr(result, "__dict__"):
|
||||
try:
|
||||
d = {k: v for k, v in result.__dict__.items() if not k.startswith("_")}
|
||||
return json.dumps(d, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(result)
|
||||
return str(result)
|
||||
|
||||
|
||||
def _normalize_tool_stock_code(value: Any) -> Any:
|
||||
"""Canonicalize stock code arguments so equivalent HK variants share one cache key."""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
text = value.strip().upper()
|
||||
if not text:
|
||||
return text
|
||||
|
||||
if text.endswith(".HK"):
|
||||
base = text[:-3]
|
||||
if base.isdigit() and 1 <= len(base) <= 5:
|
||||
return f"HK{base.zfill(5)}"
|
||||
|
||||
if text.startswith("HK"):
|
||||
base = text[2:]
|
||||
if base.isdigit() and 1 <= len(base) <= 5:
|
||||
return f"HK{base.zfill(5)}"
|
||||
|
||||
if text.isdigit() and len(text) == 5:
|
||||
return f"HK{text}"
|
||||
|
||||
try:
|
||||
from data_provider.base import canonical_stock_code, normalize_stock_code
|
||||
|
||||
return canonical_stock_code(normalize_stock_code(text))
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def _build_tool_cache_key(tool_name: str, arguments: Dict[str, Any]) -> Optional[str]:
|
||||
"""Build a stable cache key for tool calls with normalized stock-code arguments."""
|
||||
if not isinstance(arguments, dict):
|
||||
return None
|
||||
|
||||
normalized_args: Dict[str, Any] = {}
|
||||
for key, value in arguments.items():
|
||||
if key == "stock_code":
|
||||
normalized_args[key] = _normalize_tool_stock_code(value)
|
||||
else:
|
||||
normalized_args[key] = value
|
||||
|
||||
try:
|
||||
payload = json.dumps(normalized_args, ensure_ascii=False, sort_keys=True, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return f"{tool_name}:{payload}"
|
||||
|
||||
|
||||
def _is_non_retriable_tool_result(result: Any) -> bool:
|
||||
"""Return True when a tool result explicitly tells the agent not to retry."""
|
||||
return (
|
||||
isinstance(result, dict)
|
||||
and bool(result.get("error"))
|
||||
and result.get("retriable") is False
|
||||
)
|
||||
|
||||
|
||||
def _is_stock_scoped_tool(tool_registry: ToolRegistry, tool_name: str) -> bool:
|
||||
tool_def = tool_registry.resolve(tool_name)
|
||||
if tool_def is None:
|
||||
return False
|
||||
return any(param.name == "stock_code" for param in tool_def.parameters)
|
||||
|
||||
|
||||
def _normalize_guard_stock_code(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
raw = value if isinstance(value, str) else str(value)
|
||||
normalized = _normalize_tool_stock_code(raw)
|
||||
return normalized if isinstance(normalized, str) else str(normalized)
|
||||
|
||||
|
||||
def _iter_allowed_stock_codes(stock_scope: Any) -> Iterable[Any]:
|
||||
return getattr(stock_scope, "allowed_stock_codes", set()) or set()
|
||||
|
||||
|
||||
def _guard_tool_stock_scope(
|
||||
tool_registry: ToolRegistry,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
stock_scope: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if stock_scope is None or not isinstance(arguments, dict):
|
||||
return None
|
||||
if not _is_stock_scoped_tool(tool_registry, tool_name):
|
||||
return None
|
||||
if "stock_code" not in arguments:
|
||||
return None
|
||||
|
||||
requested = _normalize_guard_stock_code(arguments.get("stock_code"))
|
||||
expected = _normalize_guard_stock_code(getattr(stock_scope, "expected_stock_code", ""))
|
||||
allowed = {
|
||||
normalized
|
||||
for code in _iter_allowed_stock_codes(stock_scope)
|
||||
for normalized in [_normalize_guard_stock_code(code)]
|
||||
if normalized
|
||||
}
|
||||
if requested and (requested == expected or requested in allowed):
|
||||
return None
|
||||
|
||||
return {
|
||||
"error": "stock_scope_violation",
|
||||
"expected_stock_code": expected,
|
||||
"requested_stock_code": requested,
|
||||
"allowed_stock_codes": sorted(allowed),
|
||||
"retriable": False,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_secret_key(key: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", str(key).strip().lower()).strip("_")
|
||||
|
||||
|
||||
def _is_secret_key(key: Any) -> bool:
|
||||
normalized = _normalize_secret_key(key)
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized in _SECRET_KEY_NAMES:
|
||||
return True
|
||||
return any(marker in normalized for marker in _SECRET_KEY_MARKERS)
|
||||
|
||||
|
||||
def _redact_structured_secrets(value: Any, *, _depth: int = 0) -> Any:
|
||||
if _depth > 12:
|
||||
return "<redacted_depth_limit>"
|
||||
if isinstance(value, dict):
|
||||
redacted: Dict[Any, Any] = {}
|
||||
for key, item in value.items():
|
||||
if _is_secret_key(key):
|
||||
redacted[key] = "[REDACTED]"
|
||||
else:
|
||||
redacted[key] = _redact_structured_secrets(item, _depth=_depth + 1)
|
||||
return redacted
|
||||
if isinstance(value, list):
|
||||
return [_redact_structured_secrets(item, _depth=_depth + 1) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_redact_structured_secrets(item, _depth=_depth + 1) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _redact_json_string_if_possible(text: str) -> str:
|
||||
stripped = text.strip()
|
||||
if not stripped or stripped[0] not in "[{":
|
||||
return text
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return text
|
||||
try:
|
||||
return json.dumps(_redact_structured_secrets(parsed), ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return text
|
||||
|
||||
|
||||
def execute_runner_tool_call(
|
||||
*,
|
||||
tool_call: Any,
|
||||
tool_registry: ToolRegistry,
|
||||
stock_scope: Any = None,
|
||||
non_retriable_tool_results: Optional[Dict[str, str]] = None,
|
||||
) -> tuple[Any, str, bool, float, bool, Optional[Dict[str, Any]]]:
|
||||
"""Execute a single tool call using the legacy runner semantics."""
|
||||
t0 = time.time()
|
||||
cache_key = _build_tool_cache_key(tool_call.name, tool_call.arguments)
|
||||
guard_result = _guard_tool_stock_scope(tool_registry, tool_call.name, tool_call.arguments, stock_scope)
|
||||
if guard_result is not None:
|
||||
dur = round(time.time() - t0, 2)
|
||||
result_str = serialize_tool_result(guard_result)
|
||||
if cache_key and non_retriable_tool_results is not None:
|
||||
non_retriable_tool_results[cache_key] = result_str
|
||||
logger.warning(
|
||||
"Tool '%s' blocked by stock scope: requested=%s expected=%s allowed=%s",
|
||||
tool_call.name,
|
||||
guard_result.get("requested_stock_code"),
|
||||
guard_result.get("expected_stock_code"),
|
||||
guard_result.get("allowed_stock_codes"),
|
||||
)
|
||||
return tool_call, result_str, False, dur, False, guard_result
|
||||
|
||||
if cache_key and non_retriable_tool_results is not None and cache_key in non_retriable_tool_results:
|
||||
dur = round(time.time() - t0, 2)
|
||||
logger.info(
|
||||
"Tool '%s' skipped via non-retriable cache for arguments=%s",
|
||||
tool_call.name,
|
||||
tool_call.arguments,
|
||||
)
|
||||
return tool_call, non_retriable_tool_results[cache_key], False, dur, True, None
|
||||
|
||||
try:
|
||||
res = tool_registry.execute(tool_call.name, **tool_call.arguments)
|
||||
res_str = serialize_tool_result(res)
|
||||
ok = True
|
||||
if cache_key and non_retriable_tool_results is not None and _is_non_retriable_tool_result(res):
|
||||
non_retriable_tool_results[cache_key] = res_str
|
||||
except Exception as e:
|
||||
res_str = json.dumps({"error": str(e)})
|
||||
ok = False
|
||||
logger.warning("Tool '%s' failed: %s", tool_call.name, e)
|
||||
dur = round(time.time() - t0, 2)
|
||||
return tool_call, res_str, ok, dur, False, None
|
||||
|
||||
|
||||
def redact_diagnostic_value(value: Any, *, limit: int = _SUMMARY_LIMIT) -> str:
|
||||
"""Return a redacted and truncated diagnostic preview."""
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
text = _redact_json_string_if_possible(value)
|
||||
else:
|
||||
text = json.dumps(_redact_structured_secrets(value), ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
try:
|
||||
text = str(value)
|
||||
except Exception:
|
||||
text = "<unserializable>"
|
||||
|
||||
text = _AUTH_PATTERN.sub(r"\1[REDACTED]", text)
|
||||
text = _URL_CREDENTIAL_PATTERN.sub(r"\1[REDACTED]@", text)
|
||||
text = _QUOTED_SECRET_FIELD_PATTERN.sub(lambda m: f"{m.group(1)}{m.group(2)}[REDACTED]{m.group(3)}", text)
|
||||
text = _HEADER_SECRET_PATTERN.sub(lambda m: f"{m.group(1)}=[REDACTED]", text)
|
||||
text = _TOKEN_PATTERN.sub("[REDACTED_TOKEN]", text)
|
||||
text = _HOME_PATH_PATTERN.sub(lambda m: f"{m.group(1).rsplit('/', 1)[0] if '/' in m.group(1) else m.group(1)}/[REDACTED_PATH]", text)
|
||||
if len(text) > limit:
|
||||
return f"{text[:limit]}...<truncated {len(text) - limit} chars>"
|
||||
return text
|
||||
|
||||
|
||||
def build_tool_audit(
|
||||
*,
|
||||
tool_name: str,
|
||||
arguments: Any,
|
||||
result: Any = None,
|
||||
error_code: Optional[str] = None,
|
||||
duration: float = 0.0,
|
||||
context: Optional[ToolAccessContext] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a redacted Tool Surface audit record."""
|
||||
ctx = context or ToolAccessContext()
|
||||
payload = {
|
||||
"tool_name": tool_name,
|
||||
"arguments_summary": redact_diagnostic_value(arguments),
|
||||
"duration": round(duration, 4),
|
||||
"result_summary": redact_diagnostic_value(result),
|
||||
"error_code": error_code,
|
||||
"backend": ctx.backend,
|
||||
"session_id": ctx.session_id,
|
||||
}
|
||||
if ctx.audit_context:
|
||||
payload["audit_context"] = redact_diagnostic_value(ctx.audit_context)
|
||||
return payload
|
||||
@@ -9,10 +9,16 @@ Tools:
|
||||
|
||||
import logging
|
||||
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition, ToolPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MARKET_READ_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read"],
|
||||
permissions=["market_data:read"],
|
||||
)
|
||||
|
||||
|
||||
def _get_fetcher_manager():
|
||||
"""Lazy import to avoid circular deps."""
|
||||
@@ -55,6 +61,7 @@ get_market_indices_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_market_indices,
|
||||
category="market",
|
||||
policy=_MARKET_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,6 +106,7 @@ get_sector_rankings_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_get_sector_rankings,
|
||||
category="market",
|
||||
policy=_MARKET_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_TOOL_SURFACE_SCOPE_DIMENSIONS = frozenset({"stock"})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Data classes
|
||||
@@ -32,6 +34,46 @@ class ToolParameter:
|
||||
default: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolPolicy:
|
||||
"""Internal policy metadata for DSA Tool Surface descriptors."""
|
||||
|
||||
read_only: Optional[bool] = None
|
||||
side_effects: List[str] = field(default_factory=list)
|
||||
permissions: List[str] = field(default_factory=list)
|
||||
policy_status: str = "unknown"
|
||||
scope_dimensions: List[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def unknown(cls) -> "ToolPolicy":
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
def declared(
|
||||
cls,
|
||||
*,
|
||||
read_only: bool,
|
||||
side_effects: Optional[List[str]] = None,
|
||||
permissions: Optional[List[str]] = None,
|
||||
scope_dimensions: Optional[List[str]] = None,
|
||||
) -> "ToolPolicy":
|
||||
return cls(
|
||||
read_only=read_only,
|
||||
side_effects=list(side_effects or []),
|
||||
permissions=list(permissions or []),
|
||||
policy_status="declared",
|
||||
scope_dimensions=list(scope_dimensions or []),
|
||||
)
|
||||
|
||||
def to_public_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"read_only": self.read_only,
|
||||
"side_effects": list(self.side_effects),
|
||||
"permissions": list(self.permissions),
|
||||
"policy_status": self.policy_status,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolDefinition:
|
||||
"""Complete definition of an agent-callable tool."""
|
||||
@@ -40,6 +82,7 @@ class ToolDefinition:
|
||||
parameters: List[ToolParameter]
|
||||
handler: Callable
|
||||
category: str = "data" # data | analysis | search | action
|
||||
policy: ToolPolicy = field(default_factory=ToolPolicy.unknown)
|
||||
|
||||
# ----- Multi-provider schema converters -----
|
||||
|
||||
@@ -62,6 +105,21 @@ class ToolDefinition:
|
||||
schema["required"] = required
|
||||
return schema
|
||||
|
||||
def _descriptor_json_schema(self) -> dict:
|
||||
"""Return a descriptor schema with explicit empty required list."""
|
||||
schema = self._params_json_schema()
|
||||
schema.setdefault("required", [])
|
||||
schema["additionalProperties"] = self.accepts_extra_arguments()
|
||||
return schema
|
||||
|
||||
def accepts_extra_arguments(self) -> bool:
|
||||
"""Return whether the handler explicitly accepts undeclared kwargs."""
|
||||
try:
|
||||
sig = inspect.signature(self.handler)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return any(param.kind == inspect.Parameter.VAR_KEYWORD for param in sig.parameters.values())
|
||||
|
||||
def to_openai_tool(self) -> dict:
|
||||
"""Convert to OpenAI ``tools`` list element format."""
|
||||
return {
|
||||
@@ -73,6 +131,28 @@ class ToolDefinition:
|
||||
},
|
||||
}
|
||||
|
||||
def to_public_descriptor(self) -> dict:
|
||||
"""Return Tool Surface descriptor without exposing the Python handler."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"category": self.category,
|
||||
"parameters": self._descriptor_json_schema(),
|
||||
"policy": self.policy.to_public_dict(),
|
||||
"scope": {
|
||||
"scope_dimensions": list(self.policy.scope_dimensions),
|
||||
"requires_stock_scope": "stock" in self.policy.scope_dimensions,
|
||||
},
|
||||
}
|
||||
|
||||
def to_mcp_descriptor(self) -> dict:
|
||||
"""Return an MCP-compatible descriptor only; no server/transport implied."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"inputSchema": self._descriptor_json_schema(),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tool Registry
|
||||
@@ -137,6 +217,59 @@ class ToolRegistry:
|
||||
"""Generate OpenAI-format tools list (used by litellm for all providers)."""
|
||||
return [t.to_openai_tool() for t in self._tools.values()]
|
||||
|
||||
def validate_tool_policies(self, *, strict: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Return policy validation issues for registered tools.
|
||||
|
||||
Ordinary registration intentionally stays permissive. Strict mode is
|
||||
used by Tool Surface checks for production/default registries.
|
||||
"""
|
||||
issues: List[Dict[str, Any]] = []
|
||||
for tool_def in self._tools.values():
|
||||
policy = tool_def.policy
|
||||
if policy.policy_status != "declared":
|
||||
if strict:
|
||||
issues.append({
|
||||
"tool": tool_def.name,
|
||||
"code": "policy_unknown",
|
||||
"message": "Tool policy is not declared.",
|
||||
})
|
||||
continue
|
||||
if strict and policy.read_only is None:
|
||||
issues.append({
|
||||
"tool": tool_def.name,
|
||||
"code": "read_only_missing",
|
||||
"message": "Tool policy read_only is not declared.",
|
||||
})
|
||||
if not strict:
|
||||
continue
|
||||
unsupported_scopes = [
|
||||
dimension
|
||||
for dimension in policy.scope_dimensions
|
||||
if dimension not in SUPPORTED_TOOL_SURFACE_SCOPE_DIMENSIONS
|
||||
]
|
||||
for dimension in unsupported_scopes:
|
||||
issues.append({
|
||||
"tool": tool_def.name,
|
||||
"code": "unsupported_scope_dimension",
|
||||
"message": f"Tool declares unsupported scope dimension: {dimension}.",
|
||||
"dimension": dimension,
|
||||
})
|
||||
has_stock_param = any(param.name == "stock_code" for param in tool_def.parameters)
|
||||
declares_stock_scope = "stock" in policy.scope_dimensions
|
||||
if has_stock_param and not declares_stock_scope:
|
||||
issues.append({
|
||||
"tool": tool_def.name,
|
||||
"code": "stock_scope_missing",
|
||||
"message": "Tool has stock_code parameter but does not declare stock scope.",
|
||||
})
|
||||
if declares_stock_scope and not has_stock_param:
|
||||
issues.append({
|
||||
"tool": tool_def.name,
|
||||
"code": "stock_scope_parameter_missing",
|
||||
"message": "Tool declares stock scope but has no stock_code parameter.",
|
||||
})
|
||||
return issues
|
||||
|
||||
# ----- Execution -----
|
||||
|
||||
def execute(self, name: str, **kwargs) -> Any:
|
||||
@@ -177,6 +310,7 @@ def tool(
|
||||
category: str = "data",
|
||||
parameters: Optional[List[ToolParameter]] = None,
|
||||
registry: Optional[ToolRegistry] = None,
|
||||
policy: Optional[ToolPolicy] = None,
|
||||
):
|
||||
"""Decorator to register a function as an agent tool.
|
||||
|
||||
@@ -201,6 +335,7 @@ def tool(
|
||||
parameters=params,
|
||||
handler=func,
|
||||
category=category,
|
||||
policy=policy or ToolPolicy.unknown(),
|
||||
)
|
||||
|
||||
target_registry = registry or get_default_registry()
|
||||
|
||||
@@ -9,10 +9,23 @@ Tools:
|
||||
|
||||
import logging
|
||||
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition
|
||||
from src.agent.tools.registry import ToolParameter, ToolDefinition, ToolPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NEWS_READ_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read", "db_write_cache"],
|
||||
permissions=["news:read"],
|
||||
scope_dimensions=["stock"],
|
||||
)
|
||||
_INTEL_READ_POLICY = ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read", "db_write_cache"],
|
||||
permissions=["intel:read"],
|
||||
scope_dimensions=["stock"],
|
||||
)
|
||||
|
||||
|
||||
def _get_db():
|
||||
"""Lazy import for DatabaseManager."""
|
||||
@@ -128,6 +141,7 @@ search_stock_news_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_search_stock_news,
|
||||
category="search",
|
||||
policy=_NEWS_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
@@ -202,6 +216,7 @@ search_comprehensive_intel_tool = ToolDefinition(
|
||||
],
|
||||
handler=_handle_search_comprehensive_intel,
|
||||
category="search",
|
||||
policy=_INTEL_READ_POLICY,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Covers:
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -26,6 +27,7 @@ from src.agent.tools.registry import (
|
||||
ToolRegistry,
|
||||
ToolDefinition,
|
||||
ToolParameter,
|
||||
ToolPolicy,
|
||||
_infer_parameters,
|
||||
)
|
||||
from src.agent.skills.base import Skill, SkillManager
|
||||
@@ -225,6 +227,26 @@ class TestToolDefinitionSchemas(unittest.TestCase):
|
||||
reg.register(_make_tool("t2"))
|
||||
self.assertEqual(len(reg.to_openai_tools()), 2)
|
||||
|
||||
def test_policy_does_not_change_openai_tool_shape(self):
|
||||
plain = _make_tool("quote_tool")
|
||||
with_policy = ToolDefinition(
|
||||
name=plain.name,
|
||||
description=plain.description,
|
||||
parameters=plain.parameters,
|
||||
handler=plain.handler,
|
||||
category=plain.category,
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read"],
|
||||
permissions=["market_data:read"],
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(with_policy.to_openai_tool(), plain.to_openai_tool())
|
||||
encoded = json.dumps(with_policy.to_openai_tool())
|
||||
self.assertNotIn("policy", encoded)
|
||||
self.assertNotIn("permissions", encoded)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# @tool decorator / _infer_parameters tests
|
||||
|
||||
638
tests/test_agent_tool_surface.py
Normal file
638
tests/test_agent_tool_surface.py
Normal file
@@ -0,0 +1,638 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the internal DSA Tool Surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from src.agent.stock_scope import StockScope
|
||||
from src.agent.tool_surface import ToolSurface
|
||||
from src.agent.tools.execution import ToolAccessContext
|
||||
from src.agent.tools.registry import ToolDefinition, ToolParameter, ToolPolicy, ToolRegistry
|
||||
|
||||
|
||||
def _registry_with_echo(executed=None) -> ToolRegistry:
|
||||
calls = executed if executed is not None else []
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="echo",
|
||||
description="Echo a message.",
|
||||
parameters=[
|
||||
ToolParameter(name="message", type="string", description="Message"),
|
||||
ToolParameter(
|
||||
name="mode",
|
||||
type="string",
|
||||
description="Mode",
|
||||
required=False,
|
||||
default="plain",
|
||||
enum=["plain", "loud"],
|
||||
),
|
||||
],
|
||||
handler=lambda message, mode="plain": calls.append((message, mode)) or {"message": message, "mode": mode},
|
||||
category="data",
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=[],
|
||||
permissions=["test:read"],
|
||||
),
|
||||
)
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
def test_public_descriptor_does_not_expose_handler_and_includes_policy_scope() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="quote",
|
||||
description="Quote",
|
||||
parameters=[ToolParameter(name="stock_code", type="string", description="Stock")],
|
||||
handler=lambda stock_code: {"code": stock_code},
|
||||
category="data",
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
side_effects=["network_read"],
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
descriptor = ToolSurface(registry).list_tools("public")[0]
|
||||
encoded = json.dumps(descriptor, ensure_ascii=False)
|
||||
|
||||
assert descriptor["policy"]["policy_status"] == "declared"
|
||||
assert descriptor["scope"]["scope_dimensions"] == ["stock"]
|
||||
assert descriptor["scope"]["requires_stock_scope"] is True
|
||||
assert "handler" not in encoded
|
||||
assert "callable" not in encoded
|
||||
assert "<function" not in encoded
|
||||
|
||||
|
||||
def test_openai_schema_is_structurally_equal_to_registry_output() -> None:
|
||||
registry = _registry_with_echo()
|
||||
|
||||
assert ToolSurface(registry).list_tools("openai") == registry.to_openai_tools()
|
||||
encoded = json.dumps(ToolSurface(registry).list_tools("openai"))
|
||||
assert "policy" not in encoded
|
||||
assert "permissions" not in encoded
|
||||
assert "side_effects" not in encoded
|
||||
assert "scope" not in encoded
|
||||
|
||||
|
||||
def test_mcp_descriptor_is_descriptor_only() -> None:
|
||||
descriptor = ToolSurface(_registry_with_echo()).list_tools("mcp_descriptor")[0]
|
||||
expected_schema = _registry_with_echo().get("echo")._params_json_schema()
|
||||
expected_schema.setdefault("required", [])
|
||||
expected_schema["additionalProperties"] = False
|
||||
|
||||
assert descriptor == {
|
||||
"name": "echo",
|
||||
"description": "Echo a message.",
|
||||
"inputSchema": expected_schema,
|
||||
}
|
||||
assert "transport" not in descriptor
|
||||
assert "server" not in descriptor
|
||||
|
||||
|
||||
def test_execute_exact_tool_name_success() -> None:
|
||||
calls = []
|
||||
result = ToolSurface(_registry_with_echo(calls)).execute_tool(
|
||||
"echo",
|
||||
{"message": "hello"},
|
||||
ToolAccessContext(backend="test", session_id="s1"),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["result"] == {"message": "hello", "mode": "plain"}
|
||||
assert json.loads(result["result_text"]) == {"message": "hello", "mode": "plain"}
|
||||
assert result["audit"]["backend"] == "test"
|
||||
assert result["audit"]["session_id"] == "s1"
|
||||
assert calls == [("hello", "plain")]
|
||||
|
||||
|
||||
def test_rejects_unregistered_namespaced_and_unknown_tools() -> None:
|
||||
surface = ToolSurface(_registry_with_echo())
|
||||
|
||||
assert surface.execute_tool("default_api:echo", {}, None)["error"]["code"] == "invalid_tool_name"
|
||||
assert surface.execute_tool("provider.tool", {}, None)["error"]["code"] == "invalid_tool_name"
|
||||
assert surface.execute_tool("provider:tool", {}, None)["error"]["code"] == "invalid_tool_name"
|
||||
assert surface.execute_tool("missing", {}, None)["error"]["code"] == "tool_not_found"
|
||||
|
||||
|
||||
def test_registered_dotted_name_uses_exact_match_only() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="provider.tool",
|
||||
description="Exact dotted tool",
|
||||
parameters=[],
|
||||
handler=lambda: {"ok": True},
|
||||
)
|
||||
)
|
||||
surface = ToolSurface(registry)
|
||||
|
||||
assert surface.execute_tool("provider.tool", {}, None)["ok"] is True
|
||||
assert surface.execute_tool("other.tool", {}, None)["error"]["code"] == "invalid_tool_name"
|
||||
|
||||
|
||||
def test_argument_validation_errors_before_handler() -> None:
|
||||
calls = []
|
||||
surface = ToolSurface(_registry_with_echo(calls))
|
||||
|
||||
cases = [
|
||||
(None, "arguments must be an object"),
|
||||
({}, "missing required argument"),
|
||||
({"message": "x", "extra": 1}, "unexpected argument"),
|
||||
({"message": "x", "mode": "quiet"}, "must be one of"),
|
||||
({"message": "x", "mode": None}, "must not be null"),
|
||||
({"message": 123}, "must be string"),
|
||||
]
|
||||
for arguments, expected in cases:
|
||||
result = surface.execute_tool("echo", arguments, None)
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "invalid_arguments"
|
||||
assert expected in result["error"]["message"]
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_optional_null_arguments_are_rejected_but_omitted_defaults_still_work() -> None:
|
||||
calls = []
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="optional_params",
|
||||
description="Optional params",
|
||||
parameters=[
|
||||
ToolParameter(name="message", type="string", description="Message"),
|
||||
ToolParameter(name="count", type="integer", description="Count", required=False, default=1),
|
||||
ToolParameter(name="enabled", type="boolean", description="Enabled", required=False, default=True),
|
||||
ToolParameter(name="metadata", type="object", description="Metadata", required=False),
|
||||
],
|
||||
handler=lambda message, count=1, enabled=True, metadata=None: calls.append(
|
||||
(message, count, enabled, metadata)
|
||||
)
|
||||
or {
|
||||
"message": message,
|
||||
"count": count,
|
||||
"enabled": enabled,
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
)
|
||||
surface = ToolSurface(registry)
|
||||
|
||||
for key in ["count", "enabled", "metadata"]:
|
||||
result = surface.execute_tool("optional_params", {"message": "x", key: None}, None)
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "invalid_arguments"
|
||||
assert "must not be null" in result["error"]["message"]
|
||||
|
||||
result = surface.execute_tool("optional_params", {"message": "x"}, None)
|
||||
assert result["ok"] is True
|
||||
assert result["result"] == {
|
||||
"message": "x",
|
||||
"count": 1,
|
||||
"enabled": True,
|
||||
"metadata": None,
|
||||
}
|
||||
assert calls == [("x", 1, True, None)]
|
||||
|
||||
|
||||
def test_extra_arguments_allowed_when_handler_accepts_kwargs() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="kwargs_tool",
|
||||
description="Allows kwargs",
|
||||
parameters=[],
|
||||
handler=lambda **kwargs: kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
result = ToolSurface(registry).execute_tool("kwargs_tool", {"extra": 1}, None)
|
||||
descriptor = ToolSurface(registry).list_tools("public")[0]
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["result"] == {"extra": 1}
|
||||
assert descriptor["parameters"]["additionalProperties"] is True
|
||||
|
||||
|
||||
def test_stock_scope_violation_blocks_handler() -> None:
|
||||
calls = []
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="quote",
|
||||
description="Quote",
|
||||
parameters=[ToolParameter(name="stock_code", type="string", description="Stock")],
|
||||
handler=lambda stock_code: calls.append(stock_code) or {"code": stock_code},
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"quote",
|
||||
{"stock_code": "AAPL"},
|
||||
ToolAccessContext(stock_scope=StockScope(expected_stock_code="600519", allowed_stock_codes={"600519"})),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "stock_scope_violation"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_declared_stock_scope_requires_explicit_stock_context_before_handler() -> None:
|
||||
calls = []
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="quote",
|
||||
description="Quote",
|
||||
parameters=[ToolParameter(name="stock_code", type="string", description="Stock")],
|
||||
handler=lambda stock_code: calls.append(stock_code) or {"code": stock_code},
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"quote",
|
||||
{"stock_code": "AAPL"},
|
||||
None,
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "stock_scope_violation"
|
||||
assert result["error"]["details"]["reason"] == "stock_scope_required"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_handler_error_is_structured_without_traceback() -> None:
|
||||
def _fail():
|
||||
raise RuntimeError("secret stack")
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="fail", description="Fail", parameters=[], handler=_fail))
|
||||
|
||||
result = ToolSurface(registry).execute_tool("fail", {}, None)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "handler_error"
|
||||
assert "Traceback" not in result["result_text"]
|
||||
assert "secret stack" not in result["result_text"]
|
||||
|
||||
|
||||
def test_serialization_fallback_for_non_json_native_object() -> None:
|
||||
class Payload:
|
||||
def __init__(self) -> None:
|
||||
self.value = "ok"
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="payload", description="Payload", parameters=[], handler=lambda: Payload()))
|
||||
|
||||
result = ToolSurface(registry).execute_tool("payload", {}, None)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["result"] == {"value": "ok"}
|
||||
assert json.loads(result["result_text"]) == {"value": "ok"}
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_audit_and_diagnostics_are_redacted() -> None:
|
||||
plain_secret = "plainsecret1234567890"
|
||||
cookie_secret = "sessionid=abcdef1234567890"
|
||||
basic_auth_secret = "dXNlcjpwYXNzMTIzNDU2"
|
||||
proxy_auth_secret = "cHJveHk6c2VjcmV0MTIz"
|
||||
api_auth_secret = "plainauthsecret123456"
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="secret",
|
||||
description="Secret",
|
||||
parameters=[
|
||||
ToolParameter(name="message", type="string", description="Message"),
|
||||
ToolParameter(name="api_key", type="string", description="API key", required=False),
|
||||
ToolParameter(name="headers", type="object", description="Headers", required=False),
|
||||
],
|
||||
handler=lambda message, api_key=None, headers=None: {
|
||||
"Authorization": "Bearer sk-secret-token-1234567890",
|
||||
"api_key": plain_secret,
|
||||
"token": plain_secret,
|
||||
"secret": plain_secret,
|
||||
"headers": {
|
||||
"cookie": cookie_secret,
|
||||
"set-cookie": cookie_secret,
|
||||
"authorization": plain_secret,
|
||||
},
|
||||
"path": "/Users/massif/private/file.txt",
|
||||
"message": message * 50,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"secret",
|
||||
{
|
||||
"message": (
|
||||
"Authorization: Bearer sk-argument-token-1234567890 "
|
||||
f"Authorization: Basic {basic_auth_secret} "
|
||||
f"Proxy-Authorization: Basic {proxy_auth_secret} "
|
||||
f"authorization=ApiKey {api_auth_secret} "
|
||||
"/Users/massif/.env "
|
||||
),
|
||||
"api_key": plain_secret,
|
||||
"headers": {
|
||||
"cookie": cookie_secret,
|
||||
"set-cookie": cookie_secret,
|
||||
"authorization": plain_secret,
|
||||
},
|
||||
},
|
||||
ToolAccessContext(audit_context={"secret": plain_secret}),
|
||||
)
|
||||
visible = json.dumps({"audit": result["audit"], "diagnostics": result["diagnostics"]}, ensure_ascii=False)
|
||||
|
||||
assert "sk-secret-token-1234567890" not in visible
|
||||
assert "sk-argument-token-1234567890" not in visible
|
||||
assert basic_auth_secret not in visible
|
||||
assert proxy_auth_secret not in visible
|
||||
assert api_auth_secret not in visible
|
||||
assert plain_secret not in visible
|
||||
assert cookie_secret not in visible
|
||||
assert "/Users/massif/private" not in visible
|
||||
assert "/Users/massif/.env" not in visible
|
||||
assert "[REDACTED" in visible or "<truncated" in visible
|
||||
|
||||
|
||||
def test_policy_unknown_does_not_break_registry_but_strict_validation_reports_issue() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="plain", description="Plain", parameters=[], handler=lambda: None))
|
||||
|
||||
issues = registry.validate_tool_policies(strict=True)
|
||||
|
||||
assert registry.validate_tool_policies(strict=False) == []
|
||||
assert issues
|
||||
assert issues[0]["code"] == "policy_unknown"
|
||||
|
||||
|
||||
def test_strict_validation_reports_stock_scope_policy_mismatch() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="undeclared_stock",
|
||||
description="Stock param without policy scope.",
|
||||
parameters=[ToolParameter(name="stock_code", type="string", description="Stock")],
|
||||
handler=lambda stock_code: {"code": stock_code},
|
||||
policy=ToolPolicy.declared(read_only=True, permissions=["market_data:read"]),
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="missing_stock_param",
|
||||
description="Policy scope without stock_code param.",
|
||||
parameters=[ToolParameter(name="ticker", type="string", description="Ticker")],
|
||||
handler=lambda ticker: {"code": ticker},
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
),
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="unsupported_market_scope",
|
||||
description="Unsupported market scope.",
|
||||
parameters=[ToolParameter(name="region", type="string", description="Region")],
|
||||
handler=lambda region: {"region": region},
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["market"],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
issue_codes = {issue["code"] for issue in registry.validate_tool_policies(strict=True)}
|
||||
non_strict_issue_codes = {issue["code"] for issue in registry.validate_tool_policies(strict=False)}
|
||||
|
||||
assert "stock_scope_missing" in issue_codes
|
||||
assert "stock_scope_parameter_missing" in issue_codes
|
||||
assert "unsupported_scope_dimension" in issue_codes
|
||||
assert "stock_scope_missing" not in non_strict_issue_codes
|
||||
assert "stock_scope_parameter_missing" not in non_strict_issue_codes
|
||||
assert "unsupported_scope_dimension" not in non_strict_issue_codes
|
||||
|
||||
|
||||
def test_tool_surface_stock_param_without_declared_scope_fails_closed() -> None:
|
||||
calls = []
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="undeclared_stock",
|
||||
description="Stock param without policy scope.",
|
||||
parameters=[ToolParameter(name="stock_code", type="string", description="Stock")],
|
||||
handler=lambda stock_code: calls.append(stock_code) or {"code": stock_code},
|
||||
policy=ToolPolicy.declared(read_only=True, permissions=["market_data:read"]),
|
||||
)
|
||||
)
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"undeclared_stock",
|
||||
{"stock_code": "AAPL"},
|
||||
ToolAccessContext(stock_scope=StockScope(expected_stock_code="600519", allowed_stock_codes={"600519"})),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "scope_contract_violation"
|
||||
assert result["error"]["details"]["missing_scope_dimension"] == "stock"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_tool_surface_declared_stock_scope_without_stock_code_fails_closed() -> None:
|
||||
calls = []
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="ticker_tool",
|
||||
description="Declares stock scope with ticker parameter.",
|
||||
parameters=[ToolParameter(name="ticker", type="string", description="Ticker")],
|
||||
handler=lambda ticker: calls.append(ticker) or {"code": ticker},
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["stock"],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"ticker_tool",
|
||||
{"ticker": "AAPL"},
|
||||
ToolAccessContext(stock_scope=StockScope(expected_stock_code="600519", allowed_stock_codes={"600519"})),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "scope_contract_violation"
|
||||
assert result["error"]["details"]["missing_parameter"] == "stock_code"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_tool_surface_unsupported_scope_dimension_fails_closed() -> None:
|
||||
calls = []
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="market_tool",
|
||||
description="Declares unsupported market scope.",
|
||||
parameters=[ToolParameter(name="region", type="string", description="Region")],
|
||||
handler=lambda region: calls.append(region) or {"region": region},
|
||||
policy=ToolPolicy.declared(
|
||||
read_only=True,
|
||||
permissions=["market_data:read"],
|
||||
scope_dimensions=["market"],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"market_tool",
|
||||
{"region": "us"},
|
||||
ToolAccessContext(market="cn"),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "scope_contract_violation"
|
||||
assert result["error"]["details"]["unsupported_scope_dimensions"] == ["market"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_default_production_registry_has_supported_declared_policies() -> None:
|
||||
from src.agent.factory import get_tool_registry
|
||||
|
||||
registry = get_tool_registry()
|
||||
|
||||
assert registry.validate_tool_policies(strict=True) == []
|
||||
|
||||
|
||||
def test_future_scope_context_fields_do_not_block_undeclared_tools() -> None:
|
||||
result = ToolSurface(_registry_with_echo()).execute_tool(
|
||||
"echo",
|
||||
{"message": "ok"},
|
||||
ToolAccessContext(
|
||||
market="us",
|
||||
time_range={"from": "2026-01-01", "to": "2026-01-31"},
|
||||
data_sources=["fixture"],
|
||||
),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
|
||||
|
||||
def test_timeout_returns_promptly_without_waiting_for_handler_shutdown() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
ToolDefinition(
|
||||
name="slow",
|
||||
description="Slow",
|
||||
parameters=[],
|
||||
handler=lambda: (time.sleep(0.4), {"done": True})[1],
|
||||
)
|
||||
)
|
||||
|
||||
started = time.time()
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"slow",
|
||||
{},
|
||||
ToolAccessContext(timeout_seconds=0.01),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "timeout"
|
||||
assert result["error"]["details"]["handler_may_continue"] is True
|
||||
assert time.time() - started < 0.2
|
||||
|
||||
|
||||
def test_max_result_bytes_truncates_public_payload_and_marks_diagnostics() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="large", description="Large", parameters=[], handler=lambda: {"text": "x" * 200}))
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"large",
|
||||
{},
|
||||
ToolAccessContext(max_result_bytes=20),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["result"] is None
|
||||
assert result["diagnostics"]["result_truncated"] is True
|
||||
assert result["result_text"].endswith("<truncated>")
|
||||
assert len(result["result_text"].encode("utf-8")) <= 20
|
||||
|
||||
|
||||
def test_max_result_bytes_does_not_return_raw_object_when_text_fits() -> None:
|
||||
class Payload:
|
||||
def __init__(self) -> None:
|
||||
self.value = "ok"
|
||||
self._private = "x" * 10000
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="payload", description="Payload", parameters=[], handler=lambda: Payload()))
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"payload",
|
||||
{},
|
||||
ToolAccessContext(max_result_bytes=100),
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["result_text"] == '{"value": "ok"}'
|
||||
assert result["result"] == {"value": "ok"}
|
||||
assert result["diagnostics"]["result_truncated"] is False
|
||||
|
||||
|
||||
def test_descriptors_include_explicit_empty_required_without_changing_openai_shape() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="empty", description="Empty", parameters=[], handler=lambda: None))
|
||||
|
||||
surface = ToolSurface(registry)
|
||||
|
||||
assert surface.list_tools("public")[0]["parameters"]["required"] == []
|
||||
assert surface.list_tools("public")[0]["parameters"]["additionalProperties"] is False
|
||||
assert surface.list_tools("mcp_descriptor")[0]["inputSchema"]["required"] == []
|
||||
assert surface.list_tools("mcp_descriptor")[0]["inputSchema"]["additionalProperties"] is False
|
||||
assert "required" not in registry.to_openai_tools()[0]["function"]["parameters"]
|
||||
assert "additionalProperties" not in registry.to_openai_tools()[0]["function"]["parameters"]
|
||||
|
||||
|
||||
def test_max_result_bytes_caps_error_result_text() -> None:
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="empty", description="Empty", parameters=[], handler=lambda: None))
|
||||
|
||||
result = ToolSurface(registry).execute_tool(
|
||||
"empty",
|
||||
{"unexpected": "x" * 200},
|
||||
ToolAccessContext(max_result_bytes=16),
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]["code"] == "invalid_arguments"
|
||||
assert result["diagnostics"]["result_truncated"] is True
|
||||
assert len(result["result_text"].encode("utf-8")) <= 16
|
||||
|
||||
|
||||
def test_stock_scope_no_longer_imports_runner_for_normalization() -> None:
|
||||
source = Path("src/agent/stock_scope.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "from src.agent.runner import _normalize_tool_stock_code" not in source
|
||||
Reference in New Issue
Block a user