Skip to content

✨ feat(agent-runtime): 新增金融工具动态发现与注入能力#52

Merged
kevinmatthe merged 1 commit intomasterfrom
feature-agentic
Mar 26, 2026
Merged

✨ feat(agent-runtime): 新增金融工具动态发现与注入能力#52
kevinmatthe merged 1 commit intomasterfrom
feature-agentic

Conversation

@kevinmatthe
Copy link
Member

@kevinmatthe kevinmatthe commented Mar 26, 2026

  • 新增resolveDiscoveredFinanceTools逻辑,识别finance_tool_discover调用结果
  • 扩展聊天链路支持AdditionalTools参数,动态注入工具到下一轮对话
  • 限制单次注入最多3个金融工具,避免schema过大影响模型效果
  • 新增单元测试覆盖注入全链路流程

✨ feat(finance-tools): 新增只读高层金融数据工具集

  • 新增finance_market_data_get、finance_news_get、economy_indicator_get三个只读工具
  • 底层封装多源fallback逻辑,单源失败自动重试备用数据源
  • 工具默认不加入首轮工具集,仅通过discover动态注入,无副作用无需审批
  • 配套完整单测覆盖工具定义、provider、handler全链路

✨ feat(chat-tools): 新增群成员查询工具集

  • 新增get_chat_members工具,查询当前群成员open_id与姓名列表
  • 新增get_recent_active_members工具,查询最近发言的活跃成员,含发言统计
  • 严格限定仅可查询当前chat_id范围数据,禁止跨群越权访问
  • 配套完整单测覆盖边界场景

🐛 fix(aktool): 修复股票简称查询接口兼容性

  • 切换股票信息查询接口从东方财富到雪球,提升数据稳定性
  • 兼容新旧接口返回字段,优先取org_short_name_cn作为股票简称
  • 调整symbol前缀拼接逻辑,适配雪球接口要求

📝 docs(plans): 新增功能实现计划文档

  • 新增chat被动限流v2实现方案与上线记录文档
  • 新增金融工具基于akshare统一实现计划文档

🔧 chore(config): 优化基础配置

  • 新增.worktrees目录到.gitignore,适配多工作流开发
  • 调高akshareapi默认重试次数从3到5,提升接口调用成功率

💄 style(ui): 调整agent流式卡片标题

  • 将流式对话卡片标题修改为"Agentic Chat For You",优化用户感知

Summary by Sourcery

在代理运行时中引入动态金融工具发现与注入机制,新增只读金融工具和聊天成员工具,并优化基础设施、配置和界面以支持这些能力。

New Features:

  • 在运行时中添加对通过 finance_tool_discover 动态发现金融工具的支持,并将其中的受限子集注入到后续模型轮次中。
  • 引入只读金融工具栈(finance_market_data_getfinance_news_geteconomy_indicator_get),由统一的基于 akshare 的提供方驱动,并带有内部数据源回退机制。
  • 暴露新的聊天成员工具(get_chat_membersget_recent_active_members),严格在当前聊天范围内运行,并提供结构化的成员和活动数据。

Bug Fixes:

  • 通过切换到基于雪球的接口、调整证券代码前缀,并优先使用 org_short_name_cn(同时保持对旧字段的兼容),修复股票简称查询问题。

Enhancements:

  • 扩展运行时和响应构建路径以传递 AdditionalTools,在去重的前提下将其与默认工具合并,并更新能力工具的接线方式,以区分默认工具与感知能力的工具集。
  • 在运行时行为元数据中将新的与金融相关的工具标记为无副作用,以避免不必要的审批流程,并确保它们被视为纯数据访问。
  • 调整代理流式卡片的文案,使用更面向用户的标题文本。

Build:

  • 提高 akshare API 客户端调用的默认重试次数,以提升金融数据获取的稳健性。

Documentation:

  • 增加实施方案文档,说明金融工具 akshare 统一方案以及被动聊天限频第二版的设计。

Tests:

  • 添加全面的测试,覆盖金融目录/提供方行为、金融工具发现与处理器、回复循环中的动态工具注入、响应构建中的附加工具合并,以及新聊天成员工具的暴露和行为。

Chores:

  • 在版本控制中忽略 .worktrees 目录,以更好地支持多 worktree 的开发流程。
Original summary in English

Summary by Sourcery

Introduce dynamic finance tool discovery and injection into the agent runtime, add read‑only finance and chat member tooling, and refine infrastructure, configuration, and UI to support these capabilities.

New Features:

  • Add runtime support for dynamically discovering finance tools via finance_tool_discover and injecting a limited set of them into subsequent model turns.
  • Introduce a read‑only finance tools stack (finance_market_data_get, finance_news_get, economy_indicator_get) backed by a unified akshare-based provider with internal source fallback.
  • Expose new chat member utilities (get_chat_members, get_recent_active_members) that operate strictly within the current chat scope and surface structured member and activity data.

Bug Fixes:

  • Fix stock short-name lookup by switching to a Snowball-based endpoint, adjusting symbol prefixes, and preferring org_short_name_cn while keeping compatibility with legacy fields.

Enhancements:

  • Extend runtime and response-building paths to carry AdditionalTools, merge them with default tools while deduplicating, and update capability tool wiring to distinguish default tools from capability-aware toolsets.
  • Mark new finance-related tools as side-effect-free in runtime behavior metadata to avoid unnecessary approval flows and ensure they are treated as pure data access.
  • Adjust agent streaming card copy to use a more user-facing title text.

Build:

  • Increase default retry attempts for akshare API client calls to improve robustness of financial data retrieval.

Documentation:

  • Add implementation plans documenting the finance tools akshare unification and the second version of passive chat rate limiting.

Tests:

  • Add comprehensive tests covering finance catalog/provider behavior, finance tool discovery and handlers, dynamic tool injection in reply loops, additional tool merging in response construction, and new chat member tools exposure and behavior.

Chores:

  • Ignore .worktrees directories in version control to better support multi-worktree development workflows.

- 新增resolveDiscoveredFinanceTools逻辑,识别finance_tool_discover调用结果
- 扩展聊天链路支持AdditionalTools参数,动态注入工具到下一轮对话
- 限制单次注入最多3个金融工具,避免schema过大影响模型效果
- 新增单元测试覆盖注入全链路流程

✨ feat(finance-tools): 新增只读高层金融数据工具集
- 新增finance_market_data_get、finance_news_get、economy_indicator_get三个只读工具
- 底层封装多源fallback逻辑,单源失败自动重试备用数据源
- 工具默认不加入首轮工具集,仅通过discover动态注入,无副作用无需审批
- 配套完整单测覆盖工具定义、provider、handler全链路

✨ feat(chat-tools): 新增群成员查询工具集
- 新增get_chat_members工具,查询当前群成员open_id与姓名列表
- 新增get_recent_active_members工具,查询最近发言的活跃成员,含发言统计
- 严格限定仅可查询当前chat_id范围数据,禁止跨群越权访问
- 配套完整单测覆盖边界场景

🐛 fix(aktool): 修复股票简称查询接口兼容性
- 切换股票信息查询接口从东方财富到雪球,提升数据稳定性
- 兼容新旧接口返回字段,优先取org_short_name_cn作为股票简称
- 调整symbol前缀拼接逻辑,适配雪球接口要求

📝 docs(plans): 新增功能实现计划文档
- 新增chat被动限流v2实现方案与上线记录文档
- 新增金融工具基于akshare统一实现计划文档

🔧 chore(config): 优化基础配置
- 新增.worktrees目录到.gitignore,适配多工作流开发
- 调高akshareapi默认重试次数从3到5,提升接口调用成功率

💄 style(ui): 调整agent流式卡片标题
- 将流式对话卡片标题修改为"Agentic Chat For You",优化用户感知
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sourcery-ai
Copy link

sourcery-ai bot commented Mar 26, 2026

Reviewer's Guide

在代理聊天运行时中实现对只读金融工具的动态发现和注入;新增金融与聊天成员相关工具,并完成完整的路由与测试;调整 ark/akshare 相关管线(包括重试机制和股票代码查询);更新文档以及部分 UI/配置细节。

动态金融工具发现与注入的时序图

sequenceDiagram
    actor User
    participant LarkBot
    participant ChatflowRuntime
    participant ArkResponses
    participant FinanceToolDiscoverHandler
    participant FinanceToolInjection
    participant FinanceToolHandler
    participant FinanceProvider

    User->>LarkBot: send message asking for finance analysis
    LarkBot->>ChatflowRuntime: InitialChatLoopRequest
    ChatflowRuntime->>ArkResponses: ResponseTurnRequest (Tools includes finance_tool_discover)

    ArkResponses-->>ChatflowRuntime: Model stream with tool call finance_tool_discover
    ChatflowRuntime->>FinanceToolDiscoverHandler: Handle finance_tool_discover
    FinanceToolDiscoverHandler->>FinanceToolDiscoverHandler: FinanceToolCatalog + filters
    FinanceToolDiscoverHandler-->>ChatflowRuntime: JSON tools list (tool_name, schema,...)

    ChatflowRuntime->>FinanceToolInjection: resolveDiscoveredFinanceTools(functionName, output, capabilityTools)
    FinanceToolInjection->>FinanceToolInjection: validate tool names, limit to 3
    FinanceToolInjection-->>ChatflowRuntime: arktools Impl with selected finance tools

    ChatflowRuntime->>ArkResponses: Next ResponseTurnRequest
    Note right of ChatflowRuntime: PreviousResponseID set
    ChatflowRuntime->>ArkResponses: AdditionalTools from injection
    ArkResponses->>ArkResponses: mergeResponseTools(base, AdditionalTools)
    ArkResponses-->>ChatflowRuntime: Model stream with finance_market_data_get call

    ChatflowRuntime->>FinanceToolHandler: Handle finance_market_data_get
    FinanceToolHandler->>FinanceProvider: GetMarketData / GetNews / GetEconomyIndicator
    FinanceProvider->>FinanceProvider: callInto akshareapi endpoints with fallback
    FinanceProvider-->>FinanceToolHandler: FinanceToolResult
    FinanceToolHandler-->>ChatflowRuntime: serialized JSON result

    ChatflowRuntime->>ArkResponses: ResponseTurnRequest with ToolOutput
    ArkResponses-->>ChatflowRuntime: Final assistant reply
    ChatflowRuntime-->>LarkBot: reply text
    LarkBot-->>User: streaming agentic card with answer
Loading

金融目录、提供方与工具处理器的类图

classDiagram
    class FinanceToolCategory {
        <<enum>>
        FinanceToolCategoryMarketData
        FinanceToolCategoryNews
        FinanceToolCategoryEconomy
    }

    class FinanceSourceSpec {
        +string Name
        +string EndpointName
    }

    class FinanceSourceRoute {
        +string RequestKind
        +[]FinanceSourceSpec Fallbacks
    }

    class FinanceToolDefinition {
        +string Name
        +FinanceToolCategory Category
        +string Description
        +arktools_Param Schema
        +[]string Examples
        +[]FinanceSourceRoute Routes
        +SourceRoute(kind string) FinanceSourceRoute,bool
    }

    class FinanceToolCatalogAPI {
        +FinanceToolCatalog() []FinanceToolDefinition
        +LookupFinanceToolDefinition(name string) FinanceToolDefinition,bool
    }

    class FinanceToolResult {
        +string ToolName
        +string Category
        +string Source
        +map[string]any Query
        +string Summary
        +[]map[string]any Records
    }

    class FinanceMarketDataRequest {
        +string AssetType
        +string Symbol
        +string Interval
        +int Limit
        +string StartTime
        +string EndTime
    }

    class FinanceNewsRequest {
        +string TopicType
        +string Symbol
        +int Limit
    }

    class FinanceEconomyIndicatorRequest {
        +string Indicator
        +int Limit
    }

    class FinanceProvider {
        -httpProvider http
        +NewFinanceProvider(baseURL string) FinanceProvider
        +GetMarketData(ctx context.Context, req FinanceMarketDataRequest) FinanceToolResult,error
        +GetNews(ctx context.Context, req FinanceNewsRequest) FinanceToolResult,error
        +GetEconomyIndicator(ctx context.Context, req FinanceEconomyIndicatorRequest) FinanceToolResult,error
        -fetchMarketData(ctx context.Context, def FinanceToolDefinition, source FinanceSourceSpec, req FinanceMarketDataRequest) FinanceToolResult,error
        -fetchNews(ctx context.Context, def FinanceToolDefinition, source FinanceSourceSpec, req FinanceNewsRequest) FinanceToolResult,error
        -fetchEconomyIndicator(ctx context.Context, def FinanceToolDefinition, source FinanceSourceSpec, req FinanceEconomyIndicatorRequest) FinanceToolResult,error
    }

    class httpProvider {
        +callInto(ctx context.Context, endpoint string, params any, out any) error
        +GetStockSymbolInfo(ctx context.Context, symbol string) string,error
    }

    class financeMarketDataHandler {
        -FinanceProvider provider
        +ParseTool(raw string) FinanceMarketDataArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg FinanceMarketDataArgs) error
    }

    class financeNewsHandler {
        -FinanceProvider provider
        +ParseTool(raw string) FinanceNewsArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg FinanceNewsArgs) error
    }

    class economyIndicatorHandler {
        -FinanceProvider provider
        +ParseTool(raw string) EconomyIndicatorArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg EconomyIndicatorArgs) error
    }

    class financeToolDiscoverHandler {
        +ParseTool(raw string) FinanceToolDiscoverArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg FinanceToolDiscoverArgs) error
    }

    class FinanceToolDiscoverArgs {
        +string Query
        +string Category
        +[]string ToolNames
        +int Limit
    }

    class FinanceToolDiscoverItem {
        +string ToolName
        +string Description
        +arktools_Param Schema
        +[]string Required
        +[]string Examples
        +[]string Categories
    }

    class FinanceToolDiscoverResult {
        +[]FinanceToolDiscoverItem Tools
    }

    FinanceToolDefinition "1" o-- "*" FinanceSourceRoute
    FinanceSourceRoute "1" o-- "*" FinanceSourceSpec

    FinanceToolCatalogAPI <.. FinanceProvider : uses
    FinanceToolCatalogAPI <.. financeToolDiscoverHandler : uses

    FinanceProvider --> FinanceToolDefinition : consults

    financeMarketDataHandler --> FinanceProvider : uses
    financeNewsHandler --> FinanceProvider : uses
    economyIndicatorHandler --> FinanceProvider : uses

    financeToolDiscoverHandler --> FinanceToolDiscoverArgs
    financeToolDiscoverHandler --> FinanceToolDiscoverResult

    FinanceToolDiscoverResult "1" o-- "*" FinanceToolDiscoverItem
Loading

代理运行时与 ark 响应中动态工具注入的类图

classDiagram
    class arktools_Impl_larkim_P2MessageReceiveV1 {
        +map[string]ToolUnit FunctionCallMap
        +Add(unit *ToolUnit)
        +Get(name string) ToolUnit,bool
        +Tools() []*responses_ResponsesTool
    }

    class InitialChatTurnRequest {
        +InitialChatExecutionPlan Plan
        +string PreviousResponseID
        +InitialChatToolOutput ToolOutput
        +arktools_Impl_larkim_P2MessageReceiveV1 AdditionalTools
    }

    class ReplyTurnLoopRequest {
        +InitialChatTurnRequest TurnRequest
        +int ToolTurns
        +func TurnExecutor
        +CapabilityRequest BaseRequest
        +CapabilityRegistry Registry
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +CapabilityReplyPlan FallbackPlan
        +InitialTraceRecorder Recorder
    }

    class ReplyTurnLoopResult {
        +CapabilityReplyPlan Plan
        +ToolExecutionSnapshot LastSnapshot
    }

    class InitialChatLoopRequest {
        +InitialChatExecutionPlan Plan
        +int ToolTurns
        +func TurnExecutor
        +larkim_P2MessageReceiveV1 Event
        +CapabilityRegistry Registry
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +func Finalizer
    }

    class CapabilityReplyTurnRequest {
        +AgentSession Session
        +AgentRun Run
        +AgentStep Step
        +CapabilityCallInput Input
        +CapabilityResult Result
        +InitialTraceRecorder Recorder
        +arktools_Impl_larkim_P2MessageReceiveV1 AdditionalTools
    }

    class ContinuationReplyTurnRequest {
        +AgentSession Session
        +AgentRun Run
        +ResumeSource Source
        +WaitingReason WaitingReason
        +StepKind PreviousStepKind
        +string PreviousStepTitle
        +string PreviousStepExternalRef
        +string ResumeSummary
        +[]byte ResumePayloadJSON
        +string ThoughtFallback
        +string ReplyFallback
        +InitialTraceRecorder Recorder
        +arktools_Impl_larkim_P2MessageReceiveV1 AdditionalTools
    }

    class replyTurnRuntime {
        +string chatID
        +string openID
        +string modelID
        +arktools_Impl_larkim_P2MessageReceiveV1 tools
        +arktools_Impl_larkim_P2MessageReceiveV1 capabilityTools
        +CapabilityRegistry registry
    }

    class CapabilityRegistryTools {
        +arktools_Impl_larkim_P2MessageReceiveV1 Tools
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +func Builder
        +func TurnExecutor
        +int DefaultToolTurns
    }

    class RuntimeInitialChatLoopOptions {
        +ChatGenerationPlan Plan
        +arktools_Impl_larkim_P2MessageReceiveV1 Tools
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +func Builder
        +func TurnExecutor
        +int DefaultToolTurns
        +func Finalizer
    }

    class ResponseTurnRequest {
        +string ModelID
        +string SystemPrompt
        +string UserInput
        +[]string Files
        +string PreviousResponseID
        +ToolOutputInput ToolOutput
        +[]responses_ResponsesTool AdditionalTools
    }

    class ResponsesImpl_T {
        +[]*responses_ResponsesTool tools
        +StreamTurn(ctx context.Context, req ResponseTurnRequest) (iter_Seq,func() ResponseTurnSnapshot,error)
        -buildTurnRequest(req ResponseTurnRequest) *responses_CreateResponseTurnReq,error
    }

    class FinanceToolInjection {
        +int maxInjectedFinanceTools
        +resolveDiscoveredFinanceTools(functionName string, output string, capabilityTools *arktools_Impl_larkim_P2MessageReceiveV1) *arktools_Impl_larkim_P2MessageReceiveV1,bool
        +financeToolNames(toolset *arktools_Impl_larkim_P2MessageReceiveV1) []string
        +coalesceToolset(primary *arktools_Impl_larkim_P2MessageReceiveV1, fallback *arktools_Impl_larkim_P2MessageReceiveV1) *arktools_Impl_larkim_P2MessageReceiveV1
    }

    class mergeResponseToolsAPI {
        +mergeResponseTools(base []*responses_ResponsesTool, extra []*responses_ResponsesTool) []*responses_ResponsesTool
        +responseToolName(tool *responses_ResponsesTool) string
        +responseToolNames(tools []*responses_ResponsesTool) []string
    }

    InitialChatLoopRequest --> InitialChatTurnRequest : builds
    ReplyTurnLoopRequest --> InitialChatTurnRequest : owns

    CapabilityReplyTurnRequest --> ReplyTurnLoopRequest : used to build
    ContinuationReplyTurnRequest --> ReplyTurnLoopRequest : used to build

    RuntimeInitialChatLoopOptions --> InitialChatLoopRequest : configures
    CapabilityRegistryTools --> InitialChatLoopRequest : passed as tools

    replyTurnRuntime --> CapabilityRegistryTools : constructed from

    InitialChatTurnRequest --> ResponseTurnRequest : mapped by ExecuteInitialChatTurn

    ResponsesImpl_T --> ResponseTurnRequest : consumes
    ResponsesImpl_T --> mergeResponseToolsAPI : uses

    FinanceToolInjection <.. ReplyTurnLoopRequest : used in ExecuteReplyTurnLoop
    FinanceToolInjection <.. InitialChatLoopRequest : used in StreamInitialChatLoop

    arktools_Impl_larkim_P2MessageReceiveV1 <.. CapabilityRegistryTools
    arktools_Impl_larkim_P2MessageReceiveV1 <.. RuntimeInitialChatLoopOptions
    arktools_Impl_larkim_P2MessageReceiveV1 <.. replyTurnRuntime
    arktools_Impl_larkim_P2MessageReceiveV1 <.. InitialChatTurnRequest
Loading

从聊天到 akshareapi 的金融数据工具流转图

flowchart LR
    A[Lark chat message
user asks for finance data] --> B[Chatflow runtime
builds execution plan]
    B --> C[Ark Responses
model turn]
    C --> D{Model decides
next action}
    D -->|Call finance_tool_discover| E[finance_tool_discover_handler
filters FinanceToolCatalog]
    E --> F[Discover JSON
list of tools]
    F --> G[FinanceToolInjection
resolveDiscoveredFinanceTools]
    G --> H[InitialChatTurnRequest
with AdditionalTools]
    H --> I[ResponsesImpl
buildTurnRequest
mergeResponseTools]
    I --> J{Model function call
finance_market_data_get /
finance_news_get /
economy_indicator_get}
    J --> K[Finance tool handler
finance_market_data_handler /
finance_news_handler /
economy_indicator_handler]
    K --> L[FinanceProvider
GetMarketData/GetNews/
GetEconomyIndicator]
    L --> M[akshareapi endpoints
with retry and fallback]
    M --> L
    L --> K
    K --> N[ToolOutput JSON
FinanceToolResult]
    N --> O[Ark Responses
next model turn
with ToolOutput]
    O --> P[Chatflow runtime
compose final reply]
    P --> Q[LarkBot sends
Agentic Chat For You card]
Loading

文件级变更

Change Details Files
在代理回复与初始聊天循环中串联能力工具集与动态注入工具,使通过 finance_tool_discover 发现的金融工具可以被添加到后续模型轮次中(最多注入 3 个)。
  • 扩展运行时依赖注入,支持独立的能力工具提供者,并在运行时结构与循环参数中携带 CapabilityTools。
  • 为 InitialChatTurnRequest、CapabilityReplyTurnRequest、ContinuationReplyTurnRequest、ReplyTurnLoopRequest 和 InitialChatLoopRequest 增加 AdditionalTools/CapabilityTools 字段。
  • 更新 ExecuteInitialChatTurn、ExecuteReplyTurnLoop 和 StreamInitialChatLoop,将 AdditionalTools 传入 ark_dal.ResponseTurnRequest,并调用 resolveDiscoveredFinanceTools 构建下一轮需要注入的工具,在需要时使用 coalesceToolset 回退到基础工具集。
  • 扩展 replyTurnRuntime 和 resolveReplyTurnRuntime,从 capability tools 构建能力注册表,而不是仅从基础工具集构建。
internal/application/lark/agentruntime/reply_turn.go
internal/application/lark/agentruntime/chatflow/turn.go
internal/application/lark/agentruntime/chatflow/runtime_types.go
允许 ark responses 接收每轮额外工具,将其与基础工具集合并并去重,并记录其使用情况,同时补充测试。
  • 为 ResponseTurnRequest 增加 AdditionalTools 字段,并在 ResponsesImpl.StreamTurn 和 buildTurnRequest 中完成参数传递。
  • 引入 mergeResponseTools/responseToolName/responseToolNames 辅助方法,将基础工具和额外工具合并,并按名称去重(包括 web search sentinel)。
  • 添加手动测试,验证续写请求会追加额外工具,并在名称冲突时进行去重。
internal/infrastructure/ark_dal/responses.go
internal/infrastructure/ark_dal/responses_manual.go
internal/infrastructure/ark_dal/responses_manual_test.go
在 akshare 之上引入结构化的只读金融数据(行情、新闻、宏观指标)目录与提供方,并通过发现与处理器层将其暴露为可注入工具。
  • 新增 FinanceToolDefinition 目录,包含 finance_market_data_get、finance_news_get 和 economy_indicator_get 的分类、schema、示例以及数据源路由(包括端点级别的 fallback 定义)。
  • 实现 FinanceProvider 及其 GetMarketData/GetNews/GetEconomyIndicator,将高层请求映射到 akshare 端点,规范化输入、截断记录,并在 fallback 失败时聚合诊断错误。
  • 提供关于目录正确性和 provider fallback/错误行为的单元测试。
  • 添加 finance_data_handler,将 FinanceProvider 封装为三个只读工具处理器,并测试它们能生成结构化 JSON。
  • 实现 finance_tool_discover 处理器/类型,从目录返回 tool_name、schema、examples 和 categories,支持过滤与 limit,并测试发现行为及工具注册边界。
internal/infrastructure/aktool/finance_catalog.go
internal/infrastructure/aktool/finance_provider.go
internal/infrastructure/aktool/finance_provider_test.go
internal/application/lark/handlers/finance_data_handler.go
internal/application/lark/handlers/finance_tools_test.go
internal/application/lark/handlers/finance_tool_discover_handler.go
internal/application/lark/handlers/finance_tool_discover_types.go
通过 Lark 工具注册表暴露可注入金融工具和新的聊天成员查询工具,并将它们的运行时行为定义为无副作用。
  • 新增 BuildInjectableFinanceTools 和 BuildRuntimeCapabilityTools,用于构建基础与扩展工具集,并正确注册金融发现与可注入金融工具。
  • 在 handlers.tools 中注册 finance_tool_discover 和新的只读金融工具,同时确保默认工具集与可注入子集按预期有所区别。
  • 引入 get_chat_members 和 get_recent_active_members 工具,具备严格的 chat_id 作用域、基于历史记录的活跃度分析,以及全面测试(包括参数暴露与行为)。
  • 在 toolmeta runtime_behavior 中,将 finance_tool_discover 和所有新金融工具标记为 SideEffectLevelNone 且不需要审批,并提供相应测试。
internal/application/lark/handlers/tools.go
internal/application/lark/handlers/tools_test.go
internal/application/lark/handlers/member_tools.go
internal/application/lark/handlers/member_tools_test.go
internal/application/lark/toolmeta/runtime_behavior.go
internal/application/lark/toolmeta/runtime_behavior_test.go
将金融能力工具集集成到应用启动与能力注册表中,并添加一个外部集成测试,验证在回复轮询中端到端的金融工具注入。
  • 将默认聊天能力工具提供者设置为 BuildRuntimeCapabilityTools,并在运行时接线中构建能力工具时使用它。
  • 新增外部测试,从 BuildRuntimeCapabilityTools 构建能力,模拟首轮中的 finance_tool_discover 调用,并断言 finance_market_data_get 会被注入到第二轮的 AdditionalTools 中。
  • 确保能力注册表在 buildInitialChatCapabilityRegistry 和回复轮询中使用 capabilityTools。
cmd/larkrobot/bootstrap.go
internal/application/lark/agentruntime/reply_turn.go
internal/application/lark/agentruntime/finance_tool_injection.go
internal/application/lark/agentruntime/finance_tool_injection_external_test.go
提升 akshare 客户端的健壮性,并修复股票代码短名查询,使其适配雪球兼容端点。
  • 将 akshareapi Client 的 defaultMaxAttempts 从 3 提升到 5,以增强重试的鲁棒性。
  • 将 GetStockSymbolInfo 切换为使用 EndpointStockIndividualSpotXq,并为 symbol 添加前缀;在保持兼容旧的 “股票简称” 字段的同时,更偏向使用 org_short_name_cn。
internal/infrastructure/akshareapi/client.go
internal/infrastructure/aktool/aktool.go
为金融工具统一方案和聊天被动限流 v2 添加文档,并微调部分 UI/配置行为。
  • 新增详细规划文档,说明基于 akshare 统一金融工具方案以及聊天被动限流 v2 的实现计划和任务清单。
  • 在 git 中忽略 .worktrees,调整代理流式卡片标题文案,并确保 gitignore 的更新被记录。
docs/superpowers/plans/2026-03-26-finance-tools-akshare-plan.md
docs/superpowers/plans/2026-03-26-chat-passive-ratelimit-v2.md
.gitignore
internal/infrastructure/lark_dal/larkmsg/streaming_agentic.go

Tips and commands

Interacting with Sourcery

  • 触发新一轮审查: 在 pull request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审查评论。
  • 从审查评论生成 GitHub issue: 在审查评论下回复,要求 Sourcery 从该评论生成 issue。你也可以直接回复 @sourcery-ai issue 来从该评论创建 issue。
  • 生成 pull request 标题: 在 pull request 标题任意位置写入 @sourcery-ai,即可随时生成标题。你也可以在 pull request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 pull request 摘要: 在 pull request 正文任意位置写入 @sourcery-ai summary,即可在对应位置生成 PR 摘要。也可以在 pull request 中评论 @sourcery-ai summary 来(重新)生成摘要。
  • 生成审阅者指南: 在 pull request 中评论 @sourcery-ai guide,即可(重新)生成审阅者指南。
  • 一次性解决所有 Sourcery 评论: 在 pull request 中评论 @sourcery-ai resolve,以解决所有 Sourcery 评论。当你已经处理完所有评论且不想再看到它们时很有用。
  • 一次性取消所有 Sourcery 审查: 在 pull request 中评论 @sourcery-ai dismiss,以取消所有现有 Sourcery 审查。特别适合需要从头开始新一轮审查的场景——别忘了随后评论 @sourcery-ai review 来触发新审查!

Customizing Your Experience

访问你的 dashboard 可以:

  • 启用或禁用审查功能,例如 Sourcery 生成的 pull request 摘要、审阅者指南等。
  • 修改审查语言。
  • 添加、移除或编辑自定义审查说明。
  • 调整其他审查设置。

Getting Help

Original review guide in English

Reviewer's Guide

Implements dynamic discovery and injection of read-only finance tools into the agent chat runtime, adds new finance and chat-member tools with full routing and tests, adjusts ark/akshare plumbing (including retries and stock symbol lookup), and updates docs and minor UI/config details.

Sequence diagram for dynamic finance tool discovery and injection

sequenceDiagram
    actor User
    participant LarkBot
    participant ChatflowRuntime
    participant ArkResponses
    participant FinanceToolDiscoverHandler
    participant FinanceToolInjection
    participant FinanceToolHandler
    participant FinanceProvider

    User->>LarkBot: send message asking for finance analysis
    LarkBot->>ChatflowRuntime: InitialChatLoopRequest
    ChatflowRuntime->>ArkResponses: ResponseTurnRequest (Tools includes finance_tool_discover)

    ArkResponses-->>ChatflowRuntime: Model stream with tool call finance_tool_discover
    ChatflowRuntime->>FinanceToolDiscoverHandler: Handle finance_tool_discover
    FinanceToolDiscoverHandler->>FinanceToolDiscoverHandler: FinanceToolCatalog + filters
    FinanceToolDiscoverHandler-->>ChatflowRuntime: JSON tools list (tool_name, schema,...)

    ChatflowRuntime->>FinanceToolInjection: resolveDiscoveredFinanceTools(functionName, output, capabilityTools)
    FinanceToolInjection->>FinanceToolInjection: validate tool names, limit to 3
    FinanceToolInjection-->>ChatflowRuntime: arktools Impl with selected finance tools

    ChatflowRuntime->>ArkResponses: Next ResponseTurnRequest
    Note right of ChatflowRuntime: PreviousResponseID set
    ChatflowRuntime->>ArkResponses: AdditionalTools from injection
    ArkResponses->>ArkResponses: mergeResponseTools(base, AdditionalTools)
    ArkResponses-->>ChatflowRuntime: Model stream with finance_market_data_get call

    ChatflowRuntime->>FinanceToolHandler: Handle finance_market_data_get
    FinanceToolHandler->>FinanceProvider: GetMarketData / GetNews / GetEconomyIndicator
    FinanceProvider->>FinanceProvider: callInto akshareapi endpoints with fallback
    FinanceProvider-->>FinanceToolHandler: FinanceToolResult
    FinanceToolHandler-->>ChatflowRuntime: serialized JSON result

    ChatflowRuntime->>ArkResponses: ResponseTurnRequest with ToolOutput
    ArkResponses-->>ChatflowRuntime: Final assistant reply
    ChatflowRuntime-->>LarkBot: reply text
    LarkBot-->>User: streaming agentic card with answer
Loading

Class diagram for finance catalog, provider, and tool handlers

classDiagram
    class FinanceToolCategory {
        <<enum>>
        FinanceToolCategoryMarketData
        FinanceToolCategoryNews
        FinanceToolCategoryEconomy
    }

    class FinanceSourceSpec {
        +string Name
        +string EndpointName
    }

    class FinanceSourceRoute {
        +string RequestKind
        +[]FinanceSourceSpec Fallbacks
    }

    class FinanceToolDefinition {
        +string Name
        +FinanceToolCategory Category
        +string Description
        +arktools_Param Schema
        +[]string Examples
        +[]FinanceSourceRoute Routes
        +SourceRoute(kind string) FinanceSourceRoute,bool
    }

    class FinanceToolCatalogAPI {
        +FinanceToolCatalog() []FinanceToolDefinition
        +LookupFinanceToolDefinition(name string) FinanceToolDefinition,bool
    }

    class FinanceToolResult {
        +string ToolName
        +string Category
        +string Source
        +map[string]any Query
        +string Summary
        +[]map[string]any Records
    }

    class FinanceMarketDataRequest {
        +string AssetType
        +string Symbol
        +string Interval
        +int Limit
        +string StartTime
        +string EndTime
    }

    class FinanceNewsRequest {
        +string TopicType
        +string Symbol
        +int Limit
    }

    class FinanceEconomyIndicatorRequest {
        +string Indicator
        +int Limit
    }

    class FinanceProvider {
        -httpProvider http
        +NewFinanceProvider(baseURL string) FinanceProvider
        +GetMarketData(ctx context.Context, req FinanceMarketDataRequest) FinanceToolResult,error
        +GetNews(ctx context.Context, req FinanceNewsRequest) FinanceToolResult,error
        +GetEconomyIndicator(ctx context.Context, req FinanceEconomyIndicatorRequest) FinanceToolResult,error
        -fetchMarketData(ctx context.Context, def FinanceToolDefinition, source FinanceSourceSpec, req FinanceMarketDataRequest) FinanceToolResult,error
        -fetchNews(ctx context.Context, def FinanceToolDefinition, source FinanceSourceSpec, req FinanceNewsRequest) FinanceToolResult,error
        -fetchEconomyIndicator(ctx context.Context, def FinanceToolDefinition, source FinanceSourceSpec, req FinanceEconomyIndicatorRequest) FinanceToolResult,error
    }

    class httpProvider {
        +callInto(ctx context.Context, endpoint string, params any, out any) error
        +GetStockSymbolInfo(ctx context.Context, symbol string) string,error
    }

    class financeMarketDataHandler {
        -FinanceProvider provider
        +ParseTool(raw string) FinanceMarketDataArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg FinanceMarketDataArgs) error
    }

    class financeNewsHandler {
        -FinanceProvider provider
        +ParseTool(raw string) FinanceNewsArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg FinanceNewsArgs) error
    }

    class economyIndicatorHandler {
        -FinanceProvider provider
        +ParseTool(raw string) EconomyIndicatorArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg EconomyIndicatorArgs) error
    }

    class financeToolDiscoverHandler {
        +ParseTool(raw string) FinanceToolDiscoverArgs,error
        +ToolSpec() xcommand_ToolSpec
        +Handle(ctx context.Context, data *larkim_P2MessageReceiveV1, metaData *xhandler_BaseMetaData, arg FinanceToolDiscoverArgs) error
    }

    class FinanceToolDiscoverArgs {
        +string Query
        +string Category
        +[]string ToolNames
        +int Limit
    }

    class FinanceToolDiscoverItem {
        +string ToolName
        +string Description
        +arktools_Param Schema
        +[]string Required
        +[]string Examples
        +[]string Categories
    }

    class FinanceToolDiscoverResult {
        +[]FinanceToolDiscoverItem Tools
    }

    FinanceToolDefinition "1" o-- "*" FinanceSourceRoute
    FinanceSourceRoute "1" o-- "*" FinanceSourceSpec

    FinanceToolCatalogAPI <.. FinanceProvider : uses
    FinanceToolCatalogAPI <.. financeToolDiscoverHandler : uses

    FinanceProvider --> FinanceToolDefinition : consults

    financeMarketDataHandler --> FinanceProvider : uses
    financeNewsHandler --> FinanceProvider : uses
    economyIndicatorHandler --> FinanceProvider : uses

    financeToolDiscoverHandler --> FinanceToolDiscoverArgs
    financeToolDiscoverHandler --> FinanceToolDiscoverResult

    FinanceToolDiscoverResult "1" o-- "*" FinanceToolDiscoverItem
Loading

Class diagram for agent runtime and ark responses dynamic tool injection

classDiagram
    class arktools_Impl_larkim_P2MessageReceiveV1 {
        +map[string]ToolUnit FunctionCallMap
        +Add(unit *ToolUnit)
        +Get(name string) ToolUnit,bool
        +Tools() []*responses_ResponsesTool
    }

    class InitialChatTurnRequest {
        +InitialChatExecutionPlan Plan
        +string PreviousResponseID
        +InitialChatToolOutput ToolOutput
        +arktools_Impl_larkim_P2MessageReceiveV1 AdditionalTools
    }

    class ReplyTurnLoopRequest {
        +InitialChatTurnRequest TurnRequest
        +int ToolTurns
        +func TurnExecutor
        +CapabilityRequest BaseRequest
        +CapabilityRegistry Registry
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +CapabilityReplyPlan FallbackPlan
        +InitialTraceRecorder Recorder
    }

    class ReplyTurnLoopResult {
        +CapabilityReplyPlan Plan
        +ToolExecutionSnapshot LastSnapshot
    }

    class InitialChatLoopRequest {
        +InitialChatExecutionPlan Plan
        +int ToolTurns
        +func TurnExecutor
        +larkim_P2MessageReceiveV1 Event
        +CapabilityRegistry Registry
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +func Finalizer
    }

    class CapabilityReplyTurnRequest {
        +AgentSession Session
        +AgentRun Run
        +AgentStep Step
        +CapabilityCallInput Input
        +CapabilityResult Result
        +InitialTraceRecorder Recorder
        +arktools_Impl_larkim_P2MessageReceiveV1 AdditionalTools
    }

    class ContinuationReplyTurnRequest {
        +AgentSession Session
        +AgentRun Run
        +ResumeSource Source
        +WaitingReason WaitingReason
        +StepKind PreviousStepKind
        +string PreviousStepTitle
        +string PreviousStepExternalRef
        +string ResumeSummary
        +[]byte ResumePayloadJSON
        +string ThoughtFallback
        +string ReplyFallback
        +InitialTraceRecorder Recorder
        +arktools_Impl_larkim_P2MessageReceiveV1 AdditionalTools
    }

    class replyTurnRuntime {
        +string chatID
        +string openID
        +string modelID
        +arktools_Impl_larkim_P2MessageReceiveV1 tools
        +arktools_Impl_larkim_P2MessageReceiveV1 capabilityTools
        +CapabilityRegistry registry
    }

    class CapabilityRegistryTools {
        +arktools_Impl_larkim_P2MessageReceiveV1 Tools
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +func Builder
        +func TurnExecutor
        +int DefaultToolTurns
    }

    class RuntimeInitialChatLoopOptions {
        +ChatGenerationPlan Plan
        +arktools_Impl_larkim_P2MessageReceiveV1 Tools
        +arktools_Impl_larkim_P2MessageReceiveV1 CapabilityTools
        +func Builder
        +func TurnExecutor
        +int DefaultToolTurns
        +func Finalizer
    }

    class ResponseTurnRequest {
        +string ModelID
        +string SystemPrompt
        +string UserInput
        +[]string Files
        +string PreviousResponseID
        +ToolOutputInput ToolOutput
        +[]responses_ResponsesTool AdditionalTools
    }

    class ResponsesImpl_T {
        +[]*responses_ResponsesTool tools
        +StreamTurn(ctx context.Context, req ResponseTurnRequest) (iter_Seq,func() ResponseTurnSnapshot,error)
        -buildTurnRequest(req ResponseTurnRequest) *responses_CreateResponseTurnReq,error
    }

    class FinanceToolInjection {
        +int maxInjectedFinanceTools
        +resolveDiscoveredFinanceTools(functionName string, output string, capabilityTools *arktools_Impl_larkim_P2MessageReceiveV1) *arktools_Impl_larkim_P2MessageReceiveV1,bool
        +financeToolNames(toolset *arktools_Impl_larkim_P2MessageReceiveV1) []string
        +coalesceToolset(primary *arktools_Impl_larkim_P2MessageReceiveV1, fallback *arktools_Impl_larkim_P2MessageReceiveV1) *arktools_Impl_larkim_P2MessageReceiveV1
    }

    class mergeResponseToolsAPI {
        +mergeResponseTools(base []*responses_ResponsesTool, extra []*responses_ResponsesTool) []*responses_ResponsesTool
        +responseToolName(tool *responses_ResponsesTool) string
        +responseToolNames(tools []*responses_ResponsesTool) []string
    }

    InitialChatLoopRequest --> InitialChatTurnRequest : builds
    ReplyTurnLoopRequest --> InitialChatTurnRequest : owns

    CapabilityReplyTurnRequest --> ReplyTurnLoopRequest : used to build
    ContinuationReplyTurnRequest --> ReplyTurnLoopRequest : used to build

    RuntimeInitialChatLoopOptions --> InitialChatLoopRequest : configures
    CapabilityRegistryTools --> InitialChatLoopRequest : passed as tools

    replyTurnRuntime --> CapabilityRegistryTools : constructed from

    InitialChatTurnRequest --> ResponseTurnRequest : mapped by ExecuteInitialChatTurn

    ResponsesImpl_T --> ResponseTurnRequest : consumes
    ResponsesImpl_T --> mergeResponseToolsAPI : uses

    FinanceToolInjection <.. ReplyTurnLoopRequest : used in ExecuteReplyTurnLoop
    FinanceToolInjection <.. InitialChatLoopRequest : used in StreamInitialChatLoop

    arktools_Impl_larkim_P2MessageReceiveV1 <.. CapabilityRegistryTools
    arktools_Impl_larkim_P2MessageReceiveV1 <.. RuntimeInitialChatLoopOptions
    arktools_Impl_larkim_P2MessageReceiveV1 <.. replyTurnRuntime
    arktools_Impl_larkim_P2MessageReceiveV1 <.. InitialChatTurnRequest
Loading

Flow diagram for finance data tools from chat to akshareapi

flowchart LR
    A[Lark chat message
user asks for finance data] --> B[Chatflow runtime
builds execution plan]
    B --> C[Ark Responses
model turn]
    C --> D{Model decides
next action}
    D -->|Call finance_tool_discover| E[finance_tool_discover_handler
filters FinanceToolCatalog]
    E --> F[Discover JSON
list of tools]
    F --> G[FinanceToolInjection
resolveDiscoveredFinanceTools]
    G --> H[InitialChatTurnRequest
with AdditionalTools]
    H --> I[ResponsesImpl
buildTurnRequest
mergeResponseTools]
    I --> J{Model function call
finance_market_data_get /
finance_news_get /
economy_indicator_get}
    J --> K[Finance tool handler
finance_market_data_handler /
finance_news_handler /
economy_indicator_handler]
    K --> L[FinanceProvider
GetMarketData/GetNews/
GetEconomyIndicator]
    L --> M[akshareapi endpoints
with retry and fallback]
    M --> L
    L --> K
    K --> N[ToolOutput JSON
FinanceToolResult]
    N --> O[Ark Responses
next model turn
with ToolOutput]
    O --> P[Chatflow runtime
compose final reply]
    P --> Q[LarkBot sends
Agentic Chat For You card]
Loading

File-Level Changes

Change Details Files
Wire capability toolsets and dynamically injected tools through agent reply and initial chat loops so finance tools discovered by finance_tool_discover can be added to subsequent model turns (capped at 3).
  • Extend runtime dependency wiring to support a separate capability tool provider and carry CapabilityTools through runtime structures and loop options.
  • Augment InitialChatTurnRequest, CapabilityReplyTurnRequest, ContinuationReplyTurnRequest, ReplyTurnLoopRequest, and InitialChatLoopRequest with AdditionalTools/CapabilityTools fields.
  • Update ExecuteInitialChatTurn, ExecuteReplyTurnLoop, and StreamInitialChatLoop to pass AdditionalTools into ark_dal.ResponseTurnRequest and to call resolveDiscoveredFinanceTools to build next turn’s injected tools, using coalesceToolset to fall back to base tools when needed.
  • Expand replyTurnRuntime and resolveReplyTurnRuntime to build capability registries from capability tools instead of only the base tools.
internal/application/lark/agentruntime/reply_turn.go
internal/application/lark/agentruntime/chatflow/turn.go
internal/application/lark/agentruntime/chatflow/runtime_types.go
Allow ark responses to accept per-turn additional tools, merge them with the base toolset with deduplication, and log their usage, with tests.
  • Extend ResponseTurnRequest with AdditionalTools and plumb it through ResponsesImpl.StreamTurn and buildTurnRequest.
  • Introduce mergeResponseTools/responseToolName/responseToolNames helpers to merge base and extra tools while deduping by name (including web search sentinel).
  • Add manual tests verifying that continuation requests append additional tools and dedupe when names collide.
internal/infrastructure/ark_dal/responses.go
internal/infrastructure/ark_dal/responses_manual.go
internal/infrastructure/ark_dal/responses_manual_test.go
Introduce a structured catalog and provider for read-only finance data (market, news, macro indicators) on top of akshare with source fallback, plus discovery and handler layers that expose these as injectable tools.
  • Add FinanceToolDefinition catalog with categories, schemas, examples, and source routes for finance_market_data_get, finance_news_get, and economy_indicator_get, including endpoint-level fallback definitions.
  • Implement FinanceProvider with GetMarketData/GetNews/GetEconomyIndicator, mapping high-level requests into akshare endpoints, normalizing inputs, truncating records, and aggregating diagnostic errors when fallbacks fail.
  • Provide unit tests for catalog correctness and provider fallback/error behavior.
  • Add finance_data_handler that wraps FinanceProvider into three read-only tool handlers, plus tests that they produce structured JSON.
  • Implement finance_tool_discover handler/types that return tool_name, schema, examples, and categories from the catalog, with filtering and limit, and tests verifying discover behavior and tool registration boundaries.
internal/infrastructure/aktool/finance_catalog.go
internal/infrastructure/aktool/finance_provider.go
internal/infrastructure/aktool/finance_provider_test.go
internal/application/lark/handlers/finance_data_handler.go
internal/application/lark/handlers/finance_tools_test.go
internal/application/lark/handlers/finance_tool_discover_handler.go
internal/application/lark/handlers/finance_tool_discover_types.go
Expose injectable finance tools and new chat member lookup tools via Lark tool registries, and define their runtime behaviors as side-effect-free.
  • Add BuildInjectableFinanceTools and BuildRuntimeCapabilityTools to construct base and extended toolsets, registering finance discover and injectable finance tools appropriately.
  • Register finance_tool_discover and new read-only finance tools in handlers.tools, while ensuring default toolset and injectable subset differ as intended.
  • Introduce get_chat_members and get_recent_active_members tools with strict chat_id scoping, history-based activity analysis, and comprehensive tests (including parameter exposure and behavior).
  • Mark finance_tool_discover and all new finance tools as SideEffectLevelNone and not requiring approval in toolmeta runtime_behavior, with tests.
internal/application/lark/handlers/tools.go
internal/application/lark/handlers/tools_test.go
internal/application/lark/handlers/member_tools.go
internal/application/lark/handlers/member_tools_test.go
internal/application/lark/toolmeta/runtime_behavior.go
internal/application/lark/toolmeta/runtime_behavior_test.go
Integrate finance capability toolset into application bootstrap and capability registry, and add an external integration test that validates end-to-end finance tool injection in reply-turn loops.
  • Set default chat capability tool provider to BuildRuntimeCapabilityTools and use it when building tool capabilities in runtime wiring.
  • Add an external test that wires capabilities from BuildRuntimeCapabilityTools, simulates a finance_tool_discover call on the first turn, and asserts that finance_market_data_get is injected into the second turn’s AdditionalTools.
  • Ensure capability registry uses capabilityTools for buildInitialChatCapabilityRegistry and for reply turn loops.
cmd/larkrobot/bootstrap.go
internal/application/lark/agentruntime/reply_turn.go
internal/application/lark/agentruntime/finance_tool_injection.go
internal/application/lark/agentruntime/finance_tool_injection_external_test.go
Tighten akshare client robustness and fix stock symbol short-name lookup to work with the Xueqiu-compatible endpoint.
  • Increase akshareapi Client defaultMaxAttempts from 3 to 5 to improve retry resilience.
  • Switch GetStockSymbolInfo to use EndpointStockIndividualSpotXq with a prefixed symbol, and prefer org_short_name_cn while remaining compatible with the old 股票简称 item.
internal/infrastructure/akshareapi/client.go
internal/infrastructure/aktool/aktool.go
Add documentation for finance tools unification and chat passive ratelimit v2, and tweak minor UI/config behavior.
  • Add a detailed plan document for unifying finance tools on akshare and for chat passive ratelimit v2 implementation with task checklists.
  • Ignore .worktrees in git, adjust agent streaming card title copy, and ensure gitignore update is recorded.
docs/superpowers/plans/2026-03-26-finance-tools-akshare-plan.md
docs/superpowers/plans/2026-03-26-chat-passive-ratelimit-v2.md
.gitignore
internal/infrastructure/lark_dal/larkmsg/streaming_agentic.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了 4 个问题,并给出了一些整体性的反馈:

  • 只读金融处理器(FinanceMarketDataFinanceNewsEconomyIndicator)在构造 FinanceProvider 时使用了空的 baseURL,但 FinanceProvider 最终依赖的 httpProvider 会使用这个 base URL。为了更安全,建议像现有 aktool 接线一样,把配置好的 akshare base URL 贯穿传入这些 handler,而不是硬编码为 ""
  • finance_tool_discover 中,tool_names 的输入 schema 被声明为一个泛型数组,但没有指定 items 的类型约束,而代码假设这里是字符串数组;建议在参数 schema 中显式将 items 声明为 string,以便让契约更清晰,并减少模型误用的风险。
给 AI Agent 的提示词
请根据下面的代码评审意见进行修改:

## 整体评论
- 只读金融处理器(`FinanceMarketData``FinanceNews``EconomyIndicator`)在构造 `FinanceProvider` 时使用了空的 baseURL,但 `FinanceProvider` 最终依赖的 `httpProvider` 会使用这个 base URL。为了更安全,建议像现有 aktool 接线一样,把配置好的 akshare base URL 贯穿传入这些 handler,而不是硬编码为 `""`-`finance_tool_discover` 中,`tool_names` 的输入 schema 被声明为一个泛型数组,但没有指定 `items` 的类型约束,而代码假设这里是字符串数组;建议在参数 schema 中显式将 `items` 声明为 `string`,以便让契约更清晰,并减少模型误用的风险。

## 单独评论

### 评论 1
<location path="internal/application/lark/handlers/finance_tools_test.go" line_range="89-44" />
<code_context>
+func TestFinanceMarketDataHandlerReturnsStructuredJSON(t *testing.T) {
</code_context>
<issue_to_address>
**suggestion (testing):** 为 FinanceNews 和 EconomyIndicator 处理器补充覆盖测试,包括错误路径

为了保持整个金融能力面的一致性,请同时:

-`FinanceNews``EconomyIndicator` 增加成功路径测试,使用 `httptest.Server` 模拟 `stock_news_em` / `stock_news_main_cx` 以及相关宏观接口。
- 为每个处理器增加一个错误路径测试(例如上游返回 502),并断言该处理器会向上透传错误,同时不会设置 result extra。

这样可以让三个金融处理器在行为和测试覆盖上保持一致。

建议实现:

```golang
func TestFinanceMarketDataHandlerReturnsStructuredJSON(t *testing.T) {
	t.Helper()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/public/spot_quotations_sge" {
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
		_ = json.NewEncoder(w).Encode([]map[string]any{
			{"品种": "Au99.99", "时间": "09:31:00", "现价": 578.32, "更新时间": "2026-03-20 09:31:00"},
		})
	}))

```

你需要在 `internal/application/lark/handlers/finance_tools_test.go` 中新增四个测试,遵循 `TestFinanceMarketDataHandlerReturnsStructuredJSON` 已有的风格和辅助方法。

下面的示例假设:
- 你已有与行情处理器类似的构造函数/辅助函数(例如 `newFinanceNewsHandler(baseURL string)` / `newEconomyIndicatorHandler(baseURL string)`,或者一个通用的金融工具工厂)。
- 每个处理器都会往 Lark 响应的 `Result.Extra` map 中写入数据(例如 `resp.Result.Extra["finance_news"]``resp.Result.Extra["economy_indicator"]`),并且错误透传要么通过 HTTP 状态码可见,要么通过结果中的 error 可见。

根据你实际的代码来调整名称和接线(处理器名称、extra key、辅助函数等):

```go
func TestFinanceNewsHandler_Success(t *testing.T) {
	t.Helper()

	// 模拟上游 stock_news_em / stock_news_main_cx API。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/stock_news_em":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "EM headline 1", "time": "2026-03-20 09:30:00"},
			})
		case "/api/stock_news_main_cx":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "Main CX headline 1", "time": "2026-03-20 09:31:00"},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	// TODO: 用你真实的 FinanceNews handler 构造方式替换这里。
	// 比如如果有接受 base URL 的构造函数:
	//   handler := newFinanceNewsHandler(srv.URL)
	handler := newFinanceNewsHandlerForTest(t, srv.URL) // 你定义的辅助函数,与行情 handler 的测试辅助函数保持一致

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	newsAny, ok := resp.Result.Extra["finance_news"]
	if !ok {
		t.Fatalf("expected finance_news extra to be set")
	}
	news, ok := newsAny.([]any)
	if !ok {
		t.Fatalf("finance_news extra has unexpected type %T", newsAny)
	}
	if len(news) == 0 {
		t.Fatalf("finance_news extra should not be empty")
	}
}

func TestFinanceNewsHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// 上游返回 502 来模拟 provider 失败。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newFinanceNewsHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	// 断言 handler 会透传上游错误。
	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["finance_news"]; ok {
				t.Fatalf("finance_news extra should not be set on upstream error")
			}
		}
	}
}

func TestEconomyIndicatorHandler_Success(t *testing.T) {
	t.Helper()

	// 模拟上游宏观接口。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/macro_gdp":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"year": 2025, "gdp": 123.45},
			})
		case "/api/macro_cpi":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"month": "2025-12", "cpi": 2.3},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	indicatorAny, ok := resp.Result.Extra["economy_indicator"]
	if !ok {
		t.Fatalf("expected economy_indicator extra to be set")
	}
	indicators, ok := indicatorAny.([]any)
	if !ok {
		t.Fatalf("economy_indicator extra has unexpected type %T", indicatorAny)
	}
	if len(indicators) == 0 {
		t.Fatalf("economy_indicator extra should not be empty")
	}
}

func TestEconomyIndicatorHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// 上游宏观接口失败。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["economy_indicator"]; ok {
				t.Fatalf("economy_indicator extra should not be set on upstream error")
			}
		}
	}
}
```

你还需要一些小的辅助函数,使之与当前行情处理器测试保持一致,例如:

```go
func newFinanceNewsHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	// 依照行情处理器在测试中的依赖注入方式来接线,
	// 但将 baseURL 用作上游接口的根路径。
	return NewFinanceNewsHandler(
		http.DefaultClient,
		baseURL,
		// ... 其他与生产构造保持一致的依赖 ...
	)
}

func newEconomyIndicatorHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	return NewEconomyIndicatorHandler(
		http.DefaultClient,
		baseURL,
		// ... 其他依赖 ...
	)
}
```

请根据你实际的 handler 和测试约定调整:
- `NewFinanceNewsHandler``NewEconomyIndicatorHandler`,
- 请求路径(`/finance/news``/finance/economy_indicator`),
- extra key(`"finance_news"``"economy_indicator"`),
- 以及上游路径(`/api/stock_news_em` 等)。

关键的行为要求是:
- 成功用例:确保 handler 能正确地将 JSON 结构化写入对应的 `Result.Extra` 条目中。
- 错误用例:确保 handler 会返回上游的错误状态码,并且不会设置对应的 `Result.Extra` 条目。
</issue_to_address>

### 评论 2
<location path="internal/application/lark/handlers/member_tools_test.go" line_range="61-39" />
<code_context>
+func TestRecentActiveMembersHandlerUsesCurrentChatScopeAndDedupsByRecency(t *testing.T) {
</code_context>
<issue_to_address>
**suggestion (testing):** 建议在 member 工具中增加关于 limit 归一化和大 lookback 值的测试

为了完整覆盖 `normalizeMembersLimit``normalizeRecentActiveTopK``normalizeLookbackMessages` 的边界逻辑,请增加以下测试:

- 使用非常大的 `TopK``LookbackMessages` 值(例如 1000)调用 `RecentActiveMembers.Handle`,并断言传入 `recentActiveMembersHistoryLoader``size` 被限制在 200 之内,同时返回的成员切片长度被限制在 50 之内。
- 使用 `Limit` > 200 调用 `ChatMembers.Handle`,并断言序列化结果中只包含 200 个成员。

这样可以锁定针对无界查询和 payload 的保护逻辑。

建议实现:

```golang
func TestRecentActiveMembersHandlerNormalizesLargeTopKAndLookback(t *testing.T) {
	old := recentActiveMembersHistoryLoader
	defer func() {
		recentActiveMembersHistoryLoader = old
	}()

	var (
		capturedChatID string
		capturedSize   int
	)

	recentActiveMembersHistoryLoader = func(ctx context.Context, chatID string, size int) (history.OpensearchMsgLogList, error) {
		capturedChatID = chatID
		capturedSize = size
		var empty history.OpensearchMsgLogList
		return empty, nil
	}

	args := RecentActiveMembersArgs{
		ChatID:           "chat_id",
		TopK:             1000,
		LookbackMessages: 1000,
	}

	err := RecentActiveMembers.Handle(context.Background(), nil, &xhandler.BaseMetaData{}, args)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if capturedChatID != "chat_id" {
		t.Fatalf("expected chat_id %q, got %q", "chat_id", capturedChatID)
	}

	if capturedSize != 200 {
		t.Fatalf("expected normalized history size 200, got %d", capturedSize)
	}
}

func TestRecentActiveMembersHandlerUsesCurrentChatScopeAndDedupsByRecency(t *testing.T) {

```

要完全落实这条评审意见,你还需要:
1. 为 `RecentActiveMembers.Handle` 编写一个对应测试,断言返回的成员切片长度被限制在 50:
   - 仍然复用 `recentActiveMembersHistoryLoader` 覆盖,但让它返回超过 50 条的模拟消息;
   - 按照本测试文件已有的响应写入模式来截获并解析 `RecentActiveMembers.Handle` 的响应(例如使用 `httptest.NewRecorder` + `json.Unmarshal` 到已有的响应结构体中);
   - 即便 loader 返回了超过 50 条消息并且 `TopK` 设置为一个很大的值(例如 1000),也要断言解析出的成员切片长度是 50。
2. 为 `ChatMembers.Handle` 新增一个测试来断言 limit 归一化:
   - 覆盖 `ChatMembers` 内部使用的 loader(例如本文件中的 `chatMembersLoader` 或等价变量),记录传入的 limit 值,并/或返回可控的成员集合;
   - 使用 `ChatMembersArgs{ChatID: "chat_id", Limit: 1000}` 调用 `ChatMembers.Handle`
   - 以与本文件其他 `ChatMembers` 测试相同的方式解析序列化响应;
   - 断言序列化结果中最多只有 200 个成员,并(可选地)断言传递给底层 loader 的 limit 也是 200。
如果你项目中 `RecentActiveMembersArgs` 的字段名以及 `ChatMembers` 所用 loader 的变量名不完全一致,请相应地调整。
</issue_to_address>

### 评论 3
<location path="internal/infrastructure/ark_dal/responses_manual_test.go" line_range="76-42" />
<code_context>
+	}
+}
+
+func TestBuildTurnRequestDedupesAdditionalToolsByName(t *testing.T) {
+	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
+		arktools.New[struct{}]().Add(
+			arktools.NewUnit[struct{}]().
+				Name("finance_market_data_get").
+				Desc("默认工具").
+				Params(arktools.NewParams("object")),
+		),
+	)
+
+	req, err := turn.buildTurnRequest(ResponseTurnRequest{
+		ModelID:      "ep-test",
+		SystemPrompt: "system",
+		UserPrompt:   "user",
+		AdditionalTools: []*responses.ResponsesTool{
+			testResponseFunctionTool("finance_market_data_get"),
+		},
+	})
+	if err != nil {
+		t.Fatalf("buildTurnRequest() error = %v", err)
+	}
+	if len(req.Tools) != 1 {
+		t.Fatalf("tool count = %d, want 1", len(req.Tools))
+	}
+}
+
+func testResponseFunctionTool(name string) *responses.ResponsesTool {
</code_context>
<issue_to_address>
**suggestion (testing):** 为非 function 工具(例如 web_search)以及 mergeResponseTools 中的 nil AdditionalTools 增加测试

当前测试覆盖了函数工具去重,但还没有覆盖 `mergeResponseTools` 中对 web search 工具和 nil 条目的特殊处理。

请再增加以下测试:
- 使用一个基础切片,其中包含一个函数工具和一个 web search 工具;再传入 `AdditionalTools`,其中重复 web search 工具并包含 `nil`;断言合并后的工具集合中只保留一个 web search 工具,且不包含 nil。
- 使用 `AdditionalTools``nil` 调用 `buildTurnRequest`,以确认行为与之前一致且不会 panic。

这样可以在未来增加更多工具类型时,更好地防止工具合并逻辑回归。

建议实现:

```golang
func TestBuildTurnRequestDedupesAdditionalToolsByName(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:      "ep-test",
		SystemPrompt: "system",
		UserPrompt:   "user",
		AdditionalTools: []*responses.ResponsesTool{
			testResponseFunctionTool("finance_market_data_get"),
		},
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() error = %v", err)
	}
	if len(req.Tools) != 1 {
		t.Fatalf("tool count = %d, want 1", len(req.Tools))
	}
}

func TestMergeResponseTools_WebSearchAndNil(t *testing.T) {
	t.Helper()

	base := []*responses.ResponsesTool{
		testResponseFunctionTool("fn_base"),
		testResponseWebSearchTool(),
	}

	additional := []*responses.ResponsesTool{
		testResponseWebSearchTool(), // duplicate web search tool
		nil,                         // explicit nil entry should be ignored
	}

	merged := mergeResponseTools(base, additional)

	if merged == nil {
		t.Fatalf("mergeResponseTools() returned nil slice")
	}

	if len(merged) != 2 {
		t.Fatalf("merged tool count = %d, want %d", len(merged), 2)
	}

	var (
		functionCount  int
		webSearchCount int
		nilCount       int
	)

	for i, tool := range merged {
		if tool == nil {
			nilCount++
			t.Fatalf("merged tool at index %d is nil; nil tools should be filtered out", i)
		}

		switch {
		case tool.GetToolFunction() != nil:
			functionCount++
		case tool.GetToolWebSearch() != nil:
			webSearchCount++
		}
	}

	if functionCount != 1 {
		t.Fatalf("function tool count = %d, want %d", functionCount, 1)
	}
	if webSearchCount != 1 {
		t.Fatalf("web search tool count = %d, want %d", webSearchCount, 1)
	}
	if nilCount != 0 {
		t.Fatalf("nil tool count = %d, want %d", nilCount, 0)
	}
}

func TestBuildTurnRequestHandlesNilAdditionalTools(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:        "ep-test",
		SystemPrompt:   "system",
		UserPrompt:     "user",
		AdditionalTools: nil, // explicitly nil, should not panic and should preserve base tools
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() with nil AdditionalTools error = %v", err)
	}

	if req == nil {
		t.Fatalf("buildTurnRequest() returned nil request")
	}

	if len(req.Tools) != 1 {
		t.Fatalf("tool count with nil AdditionalTools = %d, want %d", len(req.Tools), 1)
	}
}

func testResponseFunctionTool(name string) *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolFunction{
			ToolFunction: &responses.ToolFunction{
				Name:        name,
				Type:        responses.ToolType_function,
				Description: gptr.Of("dynamic tool"),
				Parameters:  &responses.Bytes{Value: []byte(`{"type":"object","properties":{}}`)},
			},
		},
	}
}

func testResponseWebSearchTool() *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolWebSearch{
			ToolWebSearch: &responses.ToolWebSearch{
				// Type: responses.ToolType_web_search,
				// Populate additional fields as required by the WebSearch tool definition.
			},
		},
	}
}

```

补充说明:

1. `testResponseWebSearchTool` 帮助函数假设在 `responses` 包中存在 `ResponsesTool_ToolWebSearch` 这个 oneof 包装类型以及 `ToolWebSearch` 消息,并且 `ResponsesTool` 上存在 `GetToolWebSearch()` 访问方法。请根据你实际生成的 protobuf API 调整类型和字段名(例如 `ResponsesTool_WebSearch``WebSearch` 等),并添加所有必需字段(比如 `Type: responses.ToolType_web_search` 或配置字段),以保证该工具在你的服务中是合法的。
2. 如果 `mergeResponseTools` 位于其他包中或者在另一个文件中是未导出的,请确保该测试文件和它在同一个包中(通常是 `ark_dal`),这样才能直接调用 `mergeResponseTools`。如果它在其他包中,要么将其导出,要么新增一个供测试使用的小的导出封装函数。
3. 如果 `buildTurnRequest``AdditionalTools == nil` 时以往的行为期望不同的工具数量(例如,当没有基础工具配置时期望工具数量为 0),请根据你代码库中的实际基线行为调整 `TestBuildTurnRequestHandlesNilAdditionalTools` 中对 `len(req.Tools)` 的断言。
</issue_to_address>

### 评论 4
<location path="docs/superpowers/plans/2026-03-26-finance-tools-akshare-plan.md" line_range="53" />
<code_context>
+
+### Important Behavioral Change Since Last Review
+- `toolmeta/runtime_behavior.go` 现在把 `gold_price_get``stock_zh_a_get` 标记为需要审批的 chat-write 工具。
+- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数”。
+- 所以新的金融 discover / API 工具层应该和现有展示型工具分层,而不是直接把旧工具重命名。
+
</code_context>
<issue_to_address>
**nitpick (typo):** 建议将“只读取数”改为更常见的“只读取数据”

“只读取数”读起来像是被截断的表达,在这份文档中略显不自然。建议改为“只读取数据”,可以更准确地表达只读能力,并与整体术语风格保持一致。

```suggestion
- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数据”。
```
</issue_to_address>

Sourcery 对开源项目免费 —— 如果你觉得这次评审有帮助,欢迎帮我们扩散 ✨
帮我变得更有用!请对每条评论点 👍 或 👎,我会根据你的反馈改进后续评审质量。
Original comment in English

Hey - I've found 4 issues, and left some high level feedback:

  • The read-only finance handlers (FinanceMarketData, FinanceNews, EconomyIndicator) construct their FinanceProvider with an empty baseURL, but FinanceProvider ultimately relies on httpProvider using that base URL, so it would be safer to thread the configured akshare base URL (similar to existing aktool wiring) into these handlers instead of hardcoding "".
  • In finance_tool_discover, the input schema for tool_names is declared as a generic array without an items type constraint, while the code assumes an array of strings; consider specifying items as string in the param schema to make the contract clearer and reduce model misuse.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The read-only finance handlers (`FinanceMarketData`, `FinanceNews`, `EconomyIndicator`) construct their `FinanceProvider` with an empty baseURL, but `FinanceProvider` ultimately relies on `httpProvider` using that base URL, so it would be safer to thread the configured akshare base URL (similar to existing aktool wiring) into these handlers instead of hardcoding `""`.
- In `finance_tool_discover`, the input schema for `tool_names` is declared as a generic array without an `items` type constraint, while the code assumes an array of strings; consider specifying `items` as `string` in the param schema to make the contract clearer and reduce model misuse.

## Individual Comments

### Comment 1
<location path="internal/application/lark/handlers/finance_tools_test.go" line_range="89-44" />
<code_context>
+func TestFinanceMarketDataHandlerReturnsStructuredJSON(t *testing.T) {
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for FinanceNews and EconomyIndicator handlers, including error paths

To keep the finance surface consistent, please also:

- Add success-path tests for `FinanceNews` and `EconomyIndicator` using `httptest.Server` to simulate `stock_news_em` / `stock_news_main_cx` and the macro endpoints.
- Add one error-path test per handler (e.g., upstream 502) asserting the handler propagates the error and does not set the result extra.

This will align behavior and coverage across all three finance handlers.

Suggested implementation:

```golang
func TestFinanceMarketDataHandlerReturnsStructuredJSON(t *testing.T) {
	t.Helper()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/public/spot_quotations_sge" {
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
		_ = json.NewEncoder(w).Encode([]map[string]any{
			{"品种": "Au99.99", "时间": "09:31:00", "现价": 578.32, "更新时间": "2026-03-20 09:31:00"},
		})
	}))

```

You’ll need to add four new tests to `internal/application/lark/handlers/finance_tools_test.go` following the same style and helpers used for `TestFinanceMarketDataHandlerReturnsStructuredJSON`.

The examples below assume:
- You have constructor/helpers similar to those used for the market data handler (e.g., something like `newFinanceNewsHandler(baseURL string)` / `newEconomyIndicatorHandler(baseURL string)` or a generic factory for finance tools).
- Each handler writes into some `Result.Extra` map in the Lark response (e.g. `resp.Result.Extra["finance_news"]`, `resp.Result.Extra["economy_indicator"]`), and error propagation is visible either as an HTTP status code or an error in the result.

Adapt the names/wiring to match your actual code (handlers, extra keys, helper functions):

```go
func TestFinanceNewsHandler_Success(t *testing.T) {
	t.Helper()

	// Simulate upstream stock_news_em / stock_news_main_cx API.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/stock_news_em":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "EM headline 1", "time": "2026-03-20 09:30:00"},
			})
		case "/api/stock_news_main_cx":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "Main CX headline 1", "time": "2026-03-20 09:31:00"},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	// TODO: Replace this with the real way you construct the FinanceNews handler.
	// For example, if there's a constructor taking the base URL:
	//   handler := newFinanceNewsHandler(srv.URL)
	handler := newFinanceNewsHandlerForTest(t, srv.URL) // helper you define mirroring the market-data one

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	newsAny, ok := resp.Result.Extra["finance_news"]
	if !ok {
		t.Fatalf("expected finance_news extra to be set")
	}
	news, ok := newsAny.([]any)
	if !ok {
		t.Fatalf("finance_news extra has unexpected type %T", newsAny)
	}
	if len(news) == 0 {
		t.Fatalf("finance_news extra should not be empty")
	}
}

func TestFinanceNewsHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// Upstream returns 502 to simulate a failing provider.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newFinanceNewsHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	// Assert that the handler propagates the upstream error.
	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["finance_news"]; ok {
				t.Fatalf("finance_news extra should not be set on upstream error")
			}
		}
	}
}

func TestEconomyIndicatorHandler_Success(t *testing.T) {
	t.Helper()

	// Simulate upstream macro endpoints.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/macro_gdp":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"year": 2025, "gdp": 123.45},
			})
		case "/api/macro_cpi":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"month": "2025-12", "cpi": 2.3},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	indicatorAny, ok := resp.Result.Extra["economy_indicator"]
	if !ok {
		t.Fatalf("expected economy_indicator extra to be set")
	}
	indicators, ok := indicatorAny.([]any)
	if !ok {
		t.Fatalf("economy_indicator extra has unexpected type %T", indicatorAny)
	}
	if len(indicators) == 0 {
		t.Fatalf("economy_indicator extra should not be empty")
	}
}

func TestEconomyIndicatorHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// Upstream macro endpoint failure.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["economy_indicator"]; ok {
				t.Fatalf("economy_indicator extra should not be set on upstream error")
			}
		}
	}
}
```

You will also need small helpers that parallel whatever you already do for the market data handler, for example:

```go
func newFinanceNewsHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	// Wire dependencies the same way as for the market data handler under test,
	// but using baseURL as the upstream endpoint root.
	return NewFinanceNewsHandler(
		http.DefaultClient,
		baseURL,
		// ... any other dependencies mirrored from production construction ...
	)
}

func newEconomyIndicatorHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	return NewEconomyIndicatorHandler(
		http.DefaultClient,
		baseURL,
		// ... any other dependencies ...
	)
}
```

Adjust:
- `NewFinanceNewsHandler`, `NewEconomyIndicatorHandler`,
- request paths (`/finance/news`, `/finance/economy_indicator`),
- extra keys (`"finance_news"`, `"economy_indicator"`),
- and upstream paths (`/api/stock_news_em`, etc.)

to match your actual handlers and existing test conventions. The key behavioral requirements are:
- Success tests: ensure the handler correctly structures JSON into the `Result.Extra` entry.
- Error tests: ensure the handler returns the upstream error status and does not set the corresponding `Result.Extra` entry.
</issue_to_address>

### Comment 2
<location path="internal/application/lark/handlers/member_tools_test.go" line_range="61-39" />
<code_context>
+func TestRecentActiveMembersHandlerUsesCurrentChatScopeAndDedupsByRecency(t *testing.T) {
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding tests for limit normalization and large lookback values in member tools

To fully exercise the boundary logic in `normalizeMembersLimit`, `normalizeRecentActiveTopK`, and `normalizeLookbackMessages`, please add tests that:

- Call `RecentActiveMembers.Handle` with very large `TopK` and `LookbackMessages` values (e.g., 1000) and assert that the `size` passed to `recentActiveMembersHistoryLoader` is capped at 200 and the returned slice length is capped at 50.
- Call `ChatMembers.Handle` with a `Limit` > 200 and assert that only 200 members are present in the serialized result.

This will lock in the safeguards against unbounded queries and payloads.

Suggested implementation:

```golang
func TestRecentActiveMembersHandlerNormalizesLargeTopKAndLookback(t *testing.T) {
	old := recentActiveMembersHistoryLoader
	defer func() {
		recentActiveMembersHistoryLoader = old
	}()

	var (
		capturedChatID string
		capturedSize   int
	)

	recentActiveMembersHistoryLoader = func(ctx context.Context, chatID string, size int) (history.OpensearchMsgLogList, error) {
		capturedChatID = chatID
		capturedSize = size
		var empty history.OpensearchMsgLogList
		return empty, nil
	}

	args := RecentActiveMembersArgs{
		ChatID:           "chat_id",
		TopK:             1000,
		LookbackMessages: 1000,
	}

	err := RecentActiveMembers.Handle(context.Background(), nil, &xhandler.BaseMetaData{}, args)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if capturedChatID != "chat_id" {
		t.Fatalf("expected chat_id %q, got %q", "chat_id", capturedChatID)
	}

	if capturedSize != 200 {
		t.Fatalf("expected normalized history size 200, got %d", capturedSize)
	}
}

func TestRecentActiveMembersHandlerUsesCurrentChatScopeAndDedupsByRecency(t *testing.T) {

```

To fully implement your review comment, you will also need:
1. A corresponding test for `RecentActiveMembers.Handle` that asserts the returned member slice is capped at 50. This will require:
   - Using the same `recentActiveMembersHistoryLoader` override but returning more than 50 synthetic messages.
   - Capturing and decoding the response from `RecentActiveMembers.Handle` using whatever response-writing pattern is already present in this test file (e.g., `httptest.NewRecorder` + `json.Unmarshal` into the existing response struct).
   - Asserting that the decoded members slice length is 50 even when more than 50 messages are returned from the loader and `TopK` is set to a large value (e.g., 1000).
2. A new test for `ChatMembers.Handle` that asserts limit normalization:
   - Override the loader used inside `ChatMembers` (e.g., `chatMembersLoader` or equivalent in this file) to record the requested limit and/or to return a controllable set of members.
   - Call `ChatMembers.Handle` with `ChatMembersArgs{ChatID: "chat_id", Limit: 1000}`.
   - Decode the serialized response using the same approach as other `ChatMembers` tests in this file.
   - Assert that at most 200 members are present in the serialized result, and (optionally) that the limit passed to the underlying loader is also 200.
Adjust the field names in `RecentActiveMembersArgs` and the loader variable name for `ChatMembers` to match the actual definitions in your codebase if they differ.
</issue_to_address>

### Comment 3
<location path="internal/infrastructure/ark_dal/responses_manual_test.go" line_range="76-42" />
<code_context>
+	}
+}
+
+func TestBuildTurnRequestDedupesAdditionalToolsByName(t *testing.T) {
+	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
+		arktools.New[struct{}]().Add(
+			arktools.NewUnit[struct{}]().
+				Name("finance_market_data_get").
+				Desc("默认工具").
+				Params(arktools.NewParams("object")),
+		),
+	)
+
+	req, err := turn.buildTurnRequest(ResponseTurnRequest{
+		ModelID:      "ep-test",
+		SystemPrompt: "system",
+		UserPrompt:   "user",
+		AdditionalTools: []*responses.ResponsesTool{
+			testResponseFunctionTool("finance_market_data_get"),
+		},
+	})
+	if err != nil {
+		t.Fatalf("buildTurnRequest() error = %v", err)
+	}
+	if len(req.Tools) != 1 {
+		t.Fatalf("tool count = %d, want 1", len(req.Tools))
+	}
+}
+
+func testResponseFunctionTool(name string) *responses.ResponsesTool {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test for non-function tools (e.g., web_search) and nil additional tools in mergeResponseTools

The current tests cover function tool deduplication but not the special handling for web search tools and nil entries in `mergeResponseTools`.

Please also add tests that:
- Use a base slice with a function tool and a web search tool plus `AdditionalTools` that repeats the web search tool and includes `nil`, and assert the merged tools contain a single web search tool and no nils.
- Call `buildTurnRequest` with `AdditionalTools` set to `nil` to confirm it matches previous behavior and does not panic.

This will better protect the tool-merging behavior from regressions as more tool types are added.

Suggested implementation:

```golang
func TestBuildTurnRequestDedupesAdditionalToolsByName(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:      "ep-test",
		SystemPrompt: "system",
		UserPrompt:   "user",
		AdditionalTools: []*responses.ResponsesTool{
			testResponseFunctionTool("finance_market_data_get"),
		},
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() error = %v", err)
	}
	if len(req.Tools) != 1 {
		t.Fatalf("tool count = %d, want 1", len(req.Tools))
	}
}

func TestMergeResponseTools_WebSearchAndNil(t *testing.T) {
	t.Helper()

	base := []*responses.ResponsesTool{
		testResponseFunctionTool("fn_base"),
		testResponseWebSearchTool(),
	}

	additional := []*responses.ResponsesTool{
		testResponseWebSearchTool(), // duplicate web search tool
		nil,                         // explicit nil entry should be ignored
	}

	merged := mergeResponseTools(base, additional)

	if merged == nil {
		t.Fatalf("mergeResponseTools() returned nil slice")
	}

	if len(merged) != 2 {
		t.Fatalf("merged tool count = %d, want %d", len(merged), 2)
	}

	var (
		functionCount  int
		webSearchCount int
		nilCount       int
	)

	for i, tool := range merged {
		if tool == nil {
			nilCount++
			t.Fatalf("merged tool at index %d is nil; nil tools should be filtered out", i)
		}

		switch {
		case tool.GetToolFunction() != nil:
			functionCount++
		case tool.GetToolWebSearch() != nil:
			webSearchCount++
		}
	}

	if functionCount != 1 {
		t.Fatalf("function tool count = %d, want %d", functionCount, 1)
	}
	if webSearchCount != 1 {
		t.Fatalf("web search tool count = %d, want %d", webSearchCount, 1)
	}
	if nilCount != 0 {
		t.Fatalf("nil tool count = %d, want %d", nilCount, 0)
	}
}

func TestBuildTurnRequestHandlesNilAdditionalTools(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:        "ep-test",
		SystemPrompt:   "system",
		UserPrompt:     "user",
		AdditionalTools: nil, // explicitly nil, should not panic and should preserve base tools
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() with nil AdditionalTools error = %v", err)
	}

	if req == nil {
		t.Fatalf("buildTurnRequest() returned nil request")
	}

	if len(req.Tools) != 1 {
		t.Fatalf("tool count with nil AdditionalTools = %d, want %d", len(req.Tools), 1)
	}
}

func testResponseFunctionTool(name string) *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolFunction{
			ToolFunction: &responses.ToolFunction{
				Name:        name,
				Type:        responses.ToolType_function,
				Description: gptr.Of("dynamic tool"),
				Parameters:  &responses.Bytes{Value: []byte(`{"type":"object","properties":{}}`)},
			},
		},
	}
}

func testResponseWebSearchTool() *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolWebSearch{
			ToolWebSearch: &responses.ToolWebSearch{
				// Type: responses.ToolType_web_search,
				// Populate additional fields as required by the WebSearch tool definition.
			},
		},
	}
}

```

1. The helper `testResponseWebSearchTool` assumes the existence of the `ResponsesTool_ToolWebSearch` oneof wrapper and a `ToolWebSearch` message on the `responses` package, plus a `GetToolWebSearch()` accessor on `ResponsesTool`. Adjust the type and field names to match your actual generated protobuf API (e.g., `ResponsesTool_WebSearch`, `WebSearch`, etc.), and add any required fields (such as `Type: responses.ToolType_web_search` or configuration fields) so the tool is valid for your service.
2. If `mergeResponseTools` lives in a different package or is unexported in another file, ensure this test file is in the same package (likely `ark_dal`) so it can call `mergeResponseTools` directly. If it is in another package, either export it or add a small exported wrapper for testing.
3. If the previous behavior for `buildTurnRequest` with `AdditionalTools == nil` expects a different tool count (e.g., zero tools when no base tools are configured), adjust the `len(req.Tools)` assertion in `TestBuildTurnRequestHandlesNilAdditionalTools` to match the expected baseline behavior in your codebase.
</issue_to_address>

### Comment 4
<location path="docs/superpowers/plans/2026-03-26-finance-tools-akshare-plan.md" line_range="53" />
<code_context>
+
+### Important Behavioral Change Since Last Review
+- `toolmeta/runtime_behavior.go` 现在把 `gold_price_get``stock_zh_a_get` 标记为需要审批的 chat-write 工具。
+- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数”。
+- 所以新的金融 discover / API 工具层应该和现有展示型工具分层,而不是直接把旧工具重命名。
+
</code_context>
<issue_to_address>
**nitpick (typo):** Consider changing “只读取数” to the more standard “只读取数据”.

“只读取数”像是被截断的表述,在这份文档里读起来有些不自然。建议改为“只读取数据”,以更准确表达只读能力并与整体术语风格保持一致。

```suggestion
- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数据”。
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

if len(result.Tools[0].Categories) == 0 || result.Tools[0].Categories[0] != "economy" {
t.Fatalf("categories = %+v, want include economy", result.Tools[0].Categories)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): 为 FinanceNews 和 EconomyIndicator 处理器补充覆盖测试,包括错误路径

为了保持整个金融能力面的一致性,请同时:

  • FinanceNewsEconomyIndicator 增加成功路径测试,使用 httptest.Server 模拟 stock_news_em / stock_news_main_cx 以及相关宏观接口。
  • 为每个处理器增加一个错误路径测试(例如上游返回 502),并断言该处理器会向上透传错误,同时不会设置 result extra。

这样可以让三个金融处理器在行为和测试覆盖上保持一致。

建议实现:

func TestFinanceMarketDataHandlerReturnsStructuredJSON(t *testing.T) {
	t.Helper()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/public/spot_quotations_sge" {
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
		_ = json.NewEncoder(w).Encode([]map[string]any{
			{"品种": "Au99.99", "时间": "09:31:00", "现价": 578.32, "更新时间": "2026-03-20 09:31:00"},
		})
	}))

你需要在 internal/application/lark/handlers/finance_tools_test.go 中新增四个测试,遵循 TestFinanceMarketDataHandlerReturnsStructuredJSON 已有的风格和辅助方法。

下面的示例假设:

  • 你已有与行情处理器类似的构造函数/辅助函数(例如 newFinanceNewsHandler(baseURL string) / newEconomyIndicatorHandler(baseURL string),或者一个通用的金融工具工厂)。
  • 每个处理器都会往 Lark 响应的 Result.Extra map 中写入数据(例如 resp.Result.Extra["finance_news"]resp.Result.Extra["economy_indicator"]),并且错误透传要么通过 HTTP 状态码可见,要么通过结果中的 error 可见。

根据你实际的代码来调整名称和接线(处理器名称、extra key、辅助函数等):

func TestFinanceNewsHandler_Success(t *testing.T) {
	t.Helper()

	// 模拟上游 stock_news_em / stock_news_main_cx API。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/stock_news_em":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "EM headline 1", "time": "2026-03-20 09:30:00"},
			})
		case "/api/stock_news_main_cx":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "Main CX headline 1", "time": "2026-03-20 09:31:00"},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	// TODO: 用你真实的 FinanceNews handler 构造方式替换这里。
	// 比如如果有接受 base URL 的构造函数:
	//   handler := newFinanceNewsHandler(srv.URL)
	handler := newFinanceNewsHandlerForTest(t, srv.URL) // 你定义的辅助函数,与行情 handler 的测试辅助函数保持一致

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	newsAny, ok := resp.Result.Extra["finance_news"]
	if !ok {
		t.Fatalf("expected finance_news extra to be set")
	}
	news, ok := newsAny.([]any)
	if !ok {
		t.Fatalf("finance_news extra has unexpected type %T", newsAny)
	}
	if len(news) == 0 {
		t.Fatalf("finance_news extra should not be empty")
	}
}

func TestFinanceNewsHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// 上游返回 502 来模拟 provider 失败。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newFinanceNewsHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	// 断言 handler 会透传上游错误。
	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["finance_news"]; ok {
				t.Fatalf("finance_news extra should not be set on upstream error")
			}
		}
	}
}

func TestEconomyIndicatorHandler_Success(t *testing.T) {
	t.Helper()

	// 模拟上游宏观接口。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/macro_gdp":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"year": 2025, "gdp": 123.45},
			})
		case "/api/macro_cpi":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"month": "2025-12", "cpi": 2.3},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	indicatorAny, ok := resp.Result.Extra["economy_indicator"]
	if !ok {
		t.Fatalf("expected economy_indicator extra to be set")
	}
	indicators, ok := indicatorAny.([]any)
	if !ok {
		t.Fatalf("economy_indicator extra has unexpected type %T", indicatorAny)
	}
	if len(indicators) == 0 {
		t.Fatalf("economy_indicator extra should not be empty")
	}
}

func TestEconomyIndicatorHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// 上游宏观接口失败。
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["economy_indicator"]; ok {
				t.Fatalf("economy_indicator extra should not be set on upstream error")
			}
		}
	}
}

你还需要一些小的辅助函数,使之与当前行情处理器测试保持一致,例如:

func newFinanceNewsHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	// 依照行情处理器在测试中的依赖注入方式来接线,
	// 但将 baseURL 用作上游接口的根路径。
	return NewFinanceNewsHandler(
		http.DefaultClient,
		baseURL,
		// ... 其他与生产构造保持一致的依赖 ...
	)
}

func newEconomyIndicatorHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	return NewEconomyIndicatorHandler(
		http.DefaultClient,
		baseURL,
		// ... 其他依赖 ...
	)
}

请根据你实际的 handler 和测试约定调整:

  • NewFinanceNewsHandlerNewEconomyIndicatorHandler,
  • 请求路径(/finance/news/finance/economy_indicator),
  • extra key("finance_news""economy_indicator"),
  • 以及上游路径(/api/stock_news_em 等)。

关键的行为要求是:

  • 成功用例:确保 handler 能正确地将 JSON 结构化写入对应的 Result.Extra 条目中。
  • 错误用例:确保 handler 会返回上游的错误状态码,并且不会设置对应的 Result.Extra 条目。
Original comment in English

suggestion (testing): Add coverage for FinanceNews and EconomyIndicator handlers, including error paths

To keep the finance surface consistent, please also:

  • Add success-path tests for FinanceNews and EconomyIndicator using httptest.Server to simulate stock_news_em / stock_news_main_cx and the macro endpoints.
  • Add one error-path test per handler (e.g., upstream 502) asserting the handler propagates the error and does not set the result extra.

This will align behavior and coverage across all three finance handlers.

Suggested implementation:

func TestFinanceMarketDataHandlerReturnsStructuredJSON(t *testing.T) {
	t.Helper()

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/api/public/spot_quotations_sge" {
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
		_ = json.NewEncoder(w).Encode([]map[string]any{
			{"品种": "Au99.99", "时间": "09:31:00", "现价": 578.32, "更新时间": "2026-03-20 09:31:00"},
		})
	}))

You’ll need to add four new tests to internal/application/lark/handlers/finance_tools_test.go following the same style and helpers used for TestFinanceMarketDataHandlerReturnsStructuredJSON.

The examples below assume:

  • You have constructor/helpers similar to those used for the market data handler (e.g., something like newFinanceNewsHandler(baseURL string) / newEconomyIndicatorHandler(baseURL string) or a generic factory for finance tools).
  • Each handler writes into some Result.Extra map in the Lark response (e.g. resp.Result.Extra["finance_news"], resp.Result.Extra["economy_indicator"]), and error propagation is visible either as an HTTP status code or an error in the result.

Adapt the names/wiring to match your actual code (handlers, extra keys, helper functions):

func TestFinanceNewsHandler_Success(t *testing.T) {
	t.Helper()

	// Simulate upstream stock_news_em / stock_news_main_cx API.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/stock_news_em":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "EM headline 1", "time": "2026-03-20 09:30:00"},
			})
		case "/api/stock_news_main_cx":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"title": "Main CX headline 1", "time": "2026-03-20 09:31:00"},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	// TODO: Replace this with the real way you construct the FinanceNews handler.
	// For example, if there's a constructor taking the base URL:
	//   handler := newFinanceNewsHandler(srv.URL)
	handler := newFinanceNewsHandlerForTest(t, srv.URL) // helper you define mirroring the market-data one

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	newsAny, ok := resp.Result.Extra["finance_news"]
	if !ok {
		t.Fatalf("expected finance_news extra to be set")
	}
	news, ok := newsAny.([]any)
	if !ok {
		t.Fatalf("finance_news extra has unexpected type %T", newsAny)
	}
	if len(news) == 0 {
		t.Fatalf("finance_news extra should not be empty")
	}
}

func TestFinanceNewsHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// Upstream returns 502 to simulate a failing provider.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newFinanceNewsHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/news", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	// Assert that the handler propagates the upstream error.
	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["finance_news"]; ok {
				t.Fatalf("finance_news extra should not be set on upstream error")
			}
		}
	}
}

func TestEconomyIndicatorHandler_Success(t *testing.T) {
	t.Helper()

	// Simulate upstream macro endpoints.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/api/macro_gdp":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"year": 2025, "gdp": 123.45},
			})
		case "/api/macro_cpi":
			_ = json.NewEncoder(w).Encode([]map[string]any{
				{"month": "2025-12", "cpi": 2.3},
			})
		default:
			t.Fatalf("unexpected path: %s", r.URL.Path)
		}
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusOK)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}

	indicatorAny, ok := resp.Result.Extra["economy_indicator"]
	if !ok {
		t.Fatalf("expected economy_indicator extra to be set")
	}
	indicators, ok := indicatorAny.([]any)
	if !ok {
		t.Fatalf("economy_indicator extra has unexpected type %T", indicatorAny)
	}
	if len(indicators) == 0 {
		t.Fatalf("economy_indicator extra should not be empty")
	}
}

func TestEconomyIndicatorHandler_UpstreamError(t *testing.T) {
	t.Helper()

	// Upstream macro endpoint failure.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}))
	defer srv.Close()

	handler := newEconomyIndicatorHandlerForTest(t, srv.URL)

	req := httptest.NewRequest(http.MethodPost, "/finance/economy_indicator", nil)
	rr := httptest.NewRecorder()

	handler.ServeHTTP(rr, req)

	if rr.Code != http.StatusBadGateway {
		t.Fatalf("unexpected status code: got %d, want %d", rr.Code, http.StatusBadGateway)
	}

	var resp struct {
		Result struct {
			Extra map[string]any `json:"extra"`
		} `json:"result"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &resp); err == nil {
		if resp.Result.Extra != nil {
			if _, ok := resp.Result.Extra["economy_indicator"]; ok {
				t.Fatalf("economy_indicator extra should not be set on upstream error")
			}
		}
	}
}

You will also need small helpers that parallel whatever you already do for the market data handler, for example:

func newFinanceNewsHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	// Wire dependencies the same way as for the market data handler under test,
	// but using baseURL as the upstream endpoint root.
	return NewFinanceNewsHandler(
		http.DefaultClient,
		baseURL,
		// ... any other dependencies mirrored from production construction ...
	)
}

func newEconomyIndicatorHandlerForTest(t *testing.T, baseURL string) http.Handler {
	t.Helper()
	return NewEconomyIndicatorHandler(
		http.DefaultClient,
		baseURL,
		// ... any other dependencies ...
	)
}

Adjust:

  • NewFinanceNewsHandler, NewEconomyIndicatorHandler,
  • request paths (/finance/news, /finance/economy_indicator),
  • extra keys ("finance_news", "economy_indicator"),
  • and upstream paths (/api/stock_news_em, etc.)

to match your actual handlers and existing test conventions. The key behavioral requirements are:

  • Success tests: ensure the handler correctly structures JSON into the Result.Extra entry.
  • Error tests: ensure the handler returns the upstream error status and does not set the corresponding Result.Extra entry.

if result, ok := meta.GetExtra("chat_members_result"); !ok || !strings.Contains(result, `"open_id":"ou_a"`) {
t.Fatalf("chat_members_result extra missing expected payload: %q", result)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): 建议在 member 工具中增加关于 limit 归一化和大 lookback 值的测试

为了完整覆盖 normalizeMembersLimitnormalizeRecentActiveTopKnormalizeLookbackMessages 的边界逻辑,请增加以下测试:

  • 使用非常大的 TopKLookbackMessages 值(例如 1000)调用 RecentActiveMembers.Handle,并断言传入 recentActiveMembersHistoryLoadersize 被限制在 200 之内,同时返回的成员切片长度被限制在 50 之内。
  • 使用 Limit > 200 调用 ChatMembers.Handle,并断言序列化结果中只包含 200 个成员。

这样可以锁定针对无界查询和 payload 的保护逻辑。

建议实现:

func TestRecentActiveMembersHandlerNormalizesLargeTopKAndLookback(t *testing.T) {
	old := recentActiveMembersHistoryLoader
	defer func() {
		recentActiveMembersHistoryLoader = old
	}()

	var (
		capturedChatID string
		capturedSize   int
	)

	recentActiveMembersHistoryLoader = func(ctx context.Context, chatID string, size int) (history.OpensearchMsgLogList, error) {
		capturedChatID = chatID
		capturedSize = size
		var empty history.OpensearchMsgLogList
		return empty, nil
	}

	args := RecentActiveMembersArgs{
		ChatID:           "chat_id",
		TopK:             1000,
		LookbackMessages: 1000,
	}

	err := RecentActiveMembers.Handle(context.Background(), nil, &xhandler.BaseMetaData{}, args)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if capturedChatID != "chat_id" {
		t.Fatalf("expected chat_id %q, got %q", "chat_id", capturedChatID)
	}

	if capturedSize != 200 {
		t.Fatalf("expected normalized history size 200, got %d", capturedSize)
	}
}

func TestRecentActiveMembersHandlerUsesCurrentChatScopeAndDedupsByRecency(t *testing.T) {

要完全落实这条评审意见,你还需要:

  1. RecentActiveMembers.Handle 编写一个对应测试,断言返回的成员切片长度被限制在 50:
    • 仍然复用 recentActiveMembersHistoryLoader 覆盖,但让它返回超过 50 条的模拟消息;
    • 按照本测试文件已有的响应写入模式来截获并解析 RecentActiveMembers.Handle 的响应(例如使用 httptest.NewRecorder + json.Unmarshal 到已有的响应结构体中);
    • 即便 loader 返回了超过 50 条消息并且 TopK 设置为一个很大的值(例如 1000),也要断言解析出的成员切片长度是 50。
  2. ChatMembers.Handle 新增一个测试来断言 limit 归一化:
    • 覆盖 ChatMembers 内部使用的 loader(例如本文件中的 chatMembersLoader 或等价变量),记录传入的 limit 值,并/或返回可控的成员集合;
    • 使用 ChatMembersArgs{ChatID: "chat_id", Limit: 1000} 调用 ChatMembers.Handle
    • 以与本文件其他 ChatMembers 测试相同的方式解析序列化响应;
    • 断言序列化结果中最多只有 200 个成员,并(可选地)断言传递给底层 loader 的 limit 也是 200。
      如果你项目中 RecentActiveMembersArgs 的字段名以及 ChatMembers 所用 loader 的变量名不完全一致,请相应地调整。
Original comment in English

suggestion (testing): Consider adding tests for limit normalization and large lookback values in member tools

To fully exercise the boundary logic in normalizeMembersLimit, normalizeRecentActiveTopK, and normalizeLookbackMessages, please add tests that:

  • Call RecentActiveMembers.Handle with very large TopK and LookbackMessages values (e.g., 1000) and assert that the size passed to recentActiveMembersHistoryLoader is capped at 200 and the returned slice length is capped at 50.
  • Call ChatMembers.Handle with a Limit > 200 and assert that only 200 members are present in the serialized result.

This will lock in the safeguards against unbounded queries and payloads.

Suggested implementation:

func TestRecentActiveMembersHandlerNormalizesLargeTopKAndLookback(t *testing.T) {
	old := recentActiveMembersHistoryLoader
	defer func() {
		recentActiveMembersHistoryLoader = old
	}()

	var (
		capturedChatID string
		capturedSize   int
	)

	recentActiveMembersHistoryLoader = func(ctx context.Context, chatID string, size int) (history.OpensearchMsgLogList, error) {
		capturedChatID = chatID
		capturedSize = size
		var empty history.OpensearchMsgLogList
		return empty, nil
	}

	args := RecentActiveMembersArgs{
		ChatID:           "chat_id",
		TopK:             1000,
		LookbackMessages: 1000,
	}

	err := RecentActiveMembers.Handle(context.Background(), nil, &xhandler.BaseMetaData{}, args)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if capturedChatID != "chat_id" {
		t.Fatalf("expected chat_id %q, got %q", "chat_id", capturedChatID)
	}

	if capturedSize != 200 {
		t.Fatalf("expected normalized history size 200, got %d", capturedSize)
	}
}

func TestRecentActiveMembersHandlerUsesCurrentChatScopeAndDedupsByRecency(t *testing.T) {

To fully implement your review comment, you will also need:

  1. A corresponding test for RecentActiveMembers.Handle that asserts the returned member slice is capped at 50. This will require:
    • Using the same recentActiveMembersHistoryLoader override but returning more than 50 synthetic messages.
    • Capturing and decoding the response from RecentActiveMembers.Handle using whatever response-writing pattern is already present in this test file (e.g., httptest.NewRecorder + json.Unmarshal into the existing response struct).
    • Asserting that the decoded members slice length is 50 even when more than 50 messages are returned from the loader and TopK is set to a large value (e.g., 1000).
  2. A new test for ChatMembers.Handle that asserts limit normalization:
    • Override the loader used inside ChatMembers (e.g., chatMembersLoader or equivalent in this file) to record the requested limit and/or to return a controllable set of members.
    • Call ChatMembers.Handle with ChatMembersArgs{ChatID: "chat_id", Limit: 1000}.
    • Decode the serialized response using the same approach as other ChatMembers tests in this file.
    • Assert that at most 200 members are present in the serialized result, and (optionally) that the limit passed to the underlying loader is also 200.
      Adjust the field names in RecentActiveMembersArgs and the loader variable name for ChatMembers to match the actual definitions in your codebase if they differ.

@@ -38,3 +40,74 @@ func TestBuildTurnRequestDefaultsReasoningEffortToMedium(t *testing.T) {
t.Fatalf("reasoning effort = %+v, want %v", req.GetReasoning(), responses.ReasoningEffort_medium)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): 为非 function 工具(例如 web_search)以及 mergeResponseTools 中的 nil AdditionalTools 增加测试

当前测试覆盖了函数工具去重,但还没有覆盖 mergeResponseTools 中对 web search 工具和 nil 条目的特殊处理。

请再增加以下测试:

  • 使用一个基础切片,其中包含一个函数工具和一个 web search 工具;再传入 AdditionalTools,其中重复 web search 工具并包含 nil;断言合并后的工具集合中只保留一个 web search 工具,且不包含 nil。
  • 使用 AdditionalToolsnil 调用 buildTurnRequest,以确认行为与之前一致且不会 panic。

这样可以在未来增加更多工具类型时,更好地防止工具合并逻辑回归。

建议实现:

func TestBuildTurnRequestDedupesAdditionalToolsByName(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:      "ep-test",
		SystemPrompt: "system",
		UserPrompt:   "user",
		AdditionalTools: []*responses.ResponsesTool{
			testResponseFunctionTool("finance_market_data_get"),
		},
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() error = %v", err)
	}
	if len(req.Tools) != 1 {
		t.Fatalf("tool count = %d, want 1", len(req.Tools))
	}
}

func TestMergeResponseTools_WebSearchAndNil(t *testing.T) {
	t.Helper()

	base := []*responses.ResponsesTool{
		testResponseFunctionTool("fn_base"),
		testResponseWebSearchTool(),
	}

	additional := []*responses.ResponsesTool{
		testResponseWebSearchTool(), // duplicate web search tool
		nil,                         // explicit nil entry should be ignored
	}

	merged := mergeResponseTools(base, additional)

	if merged == nil {
		t.Fatalf("mergeResponseTools() returned nil slice")
	}

	if len(merged) != 2 {
		t.Fatalf("merged tool count = %d, want %d", len(merged), 2)
	}

	var (
		functionCount  int
		webSearchCount int
		nilCount       int
	)

	for i, tool := range merged {
		if tool == nil {
			nilCount++
			t.Fatalf("merged tool at index %d is nil; nil tools should be filtered out", i)
		}

		switch {
		case tool.GetToolFunction() != nil:
			functionCount++
		case tool.GetToolWebSearch() != nil:
			webSearchCount++
		}
	}

	if functionCount != 1 {
		t.Fatalf("function tool count = %d, want %d", functionCount, 1)
	}
	if webSearchCount != 1 {
		t.Fatalf("web search tool count = %d, want %d", webSearchCount, 1)
	}
	if nilCount != 0 {
		t.Fatalf("nil tool count = %d, want %d", nilCount, 0)
	}
}

func TestBuildTurnRequestHandlesNilAdditionalTools(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:        "ep-test",
		SystemPrompt:   "system",
		UserPrompt:     "user",
		AdditionalTools: nil, // explicitly nil, should not panic and should preserve base tools
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() with nil AdditionalTools error = %v", err)
	}

	if req == nil {
		t.Fatalf("buildTurnRequest() returned nil request")
	}

	if len(req.Tools) != 1 {
		t.Fatalf("tool count with nil AdditionalTools = %d, want %d", len(req.Tools), 1)
	}
}

func testResponseFunctionTool(name string) *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolFunction{
			ToolFunction: &responses.ToolFunction{
				Name:        name,
				Type:        responses.ToolType_function,
				Description: gptr.Of("dynamic tool"),
				Parameters:  &responses.Bytes{Value: []byte(`{"type":"object","properties":{}}`)},
			},
		},
	}
}

func testResponseWebSearchTool() *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolWebSearch{
			ToolWebSearch: &responses.ToolWebSearch{
				// Type: responses.ToolType_web_search,
				// Populate additional fields as required by the WebSearch tool definition.
			},
		},
	}
}

补充说明:

  1. testResponseWebSearchTool 帮助函数假设在 responses 包中存在 ResponsesTool_ToolWebSearch 这个 oneof 包装类型以及 ToolWebSearch 消息,并且 ResponsesTool 上存在 GetToolWebSearch() 访问方法。请根据你实际生成的 protobuf API 调整类型和字段名(例如 ResponsesTool_WebSearchWebSearch 等),并添加所有必需字段(比如 Type: responses.ToolType_web_search 或配置字段),以保证该工具在你的服务中是合法的。
  2. 如果 mergeResponseTools 位于其他包中或者在另一个文件中是未导出的,请确保该测试文件和它在同一个包中(通常是 ark_dal),这样才能直接调用 mergeResponseTools。如果它在其他包中,要么将其导出,要么新增一个供测试使用的小的导出封装函数。
  3. 如果 buildTurnRequestAdditionalTools == nil 时以往的行为期望不同的工具数量(例如,当没有基础工具配置时期望工具数量为 0),请根据你代码库中的实际基线行为调整 TestBuildTurnRequestHandlesNilAdditionalTools 中对 len(req.Tools) 的断言。
Original comment in English

suggestion (testing): Add a test for non-function tools (e.g., web_search) and nil additional tools in mergeResponseTools

The current tests cover function tool deduplication but not the special handling for web search tools and nil entries in mergeResponseTools.

Please also add tests that:

  • Use a base slice with a function tool and a web search tool plus AdditionalTools that repeats the web search tool and includes nil, and assert the merged tools contain a single web search tool and no nils.
  • Call buildTurnRequest with AdditionalTools set to nil to confirm it matches previous behavior and does not panic.

This will better protect the tool-merging behavior from regressions as more tool types are added.

Suggested implementation:

func TestBuildTurnRequestDedupesAdditionalToolsByName(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:      "ep-test",
		SystemPrompt: "system",
		UserPrompt:   "user",
		AdditionalTools: []*responses.ResponsesTool{
			testResponseFunctionTool("finance_market_data_get"),
		},
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() error = %v", err)
	}
	if len(req.Tools) != 1 {
		t.Fatalf("tool count = %d, want 1", len(req.Tools))
	}
}

func TestMergeResponseTools_WebSearchAndNil(t *testing.T) {
	t.Helper()

	base := []*responses.ResponsesTool{
		testResponseFunctionTool("fn_base"),
		testResponseWebSearchTool(),
	}

	additional := []*responses.ResponsesTool{
		testResponseWebSearchTool(), // duplicate web search tool
		nil,                         // explicit nil entry should be ignored
	}

	merged := mergeResponseTools(base, additional)

	if merged == nil {
		t.Fatalf("mergeResponseTools() returned nil slice")
	}

	if len(merged) != 2 {
		t.Fatalf("merged tool count = %d, want %d", len(merged), 2)
	}

	var (
		functionCount  int
		webSearchCount int
		nilCount       int
	)

	for i, tool := range merged {
		if tool == nil {
			nilCount++
			t.Fatalf("merged tool at index %d is nil; nil tools should be filtered out", i)
		}

		switch {
		case tool.GetToolFunction() != nil:
			functionCount++
		case tool.GetToolWebSearch() != nil:
			webSearchCount++
		}
	}

	if functionCount != 1 {
		t.Fatalf("function tool count = %d, want %d", functionCount, 1)
	}
	if webSearchCount != 1 {
		t.Fatalf("web search tool count = %d, want %d", webSearchCount, 1)
	}
	if nilCount != 0 {
		t.Fatalf("nil tool count = %d, want %d", nilCount, 0)
	}
}

func TestBuildTurnRequestHandlesNilAdditionalTools(t *testing.T) {
	turn := New[struct{}]("oc_chat", "ou_actor", nil).WithTools(
		arktools.New[struct{}]().Add(
			arktools.NewUnit[struct{}]().
				Name("finance_market_data_get").
				Desc("默认工具").
				Params(arktools.NewParams("object")),
		),
	)

	req, err := turn.buildTurnRequest(ResponseTurnRequest{
		ModelID:        "ep-test",
		SystemPrompt:   "system",
		UserPrompt:     "user",
		AdditionalTools: nil, // explicitly nil, should not panic and should preserve base tools
	})
	if err != nil {
		t.Fatalf("buildTurnRequest() with nil AdditionalTools error = %v", err)
	}

	if req == nil {
		t.Fatalf("buildTurnRequest() returned nil request")
	}

	if len(req.Tools) != 1 {
		t.Fatalf("tool count with nil AdditionalTools = %d, want %d", len(req.Tools), 1)
	}
}

func testResponseFunctionTool(name string) *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolFunction{
			ToolFunction: &responses.ToolFunction{
				Name:        name,
				Type:        responses.ToolType_function,
				Description: gptr.Of("dynamic tool"),
				Parameters:  &responses.Bytes{Value: []byte(`{"type":"object","properties":{}}`)},
			},
		},
	}
}

func testResponseWebSearchTool() *responses.ResponsesTool {
	return &responses.ResponsesTool{
		Union: &responses.ResponsesTool_ToolWebSearch{
			ToolWebSearch: &responses.ToolWebSearch{
				// Type: responses.ToolType_web_search,
				// Populate additional fields as required by the WebSearch tool definition.
			},
		},
	}
}
  1. The helper testResponseWebSearchTool assumes the existence of the ResponsesTool_ToolWebSearch oneof wrapper and a ToolWebSearch message on the responses package, plus a GetToolWebSearch() accessor on ResponsesTool. Adjust the type and field names to match your actual generated protobuf API (e.g., ResponsesTool_WebSearch, WebSearch, etc.), and add any required fields (such as Type: responses.ToolType_web_search or configuration fields) so the tool is valid for your service.
  2. If mergeResponseTools lives in a different package or is unexported in another file, ensure this test file is in the same package (likely ark_dal) so it can call mergeResponseTools directly. If it is in another package, either export it or add a small exported wrapper for testing.
  3. If the previous behavior for buildTurnRequest with AdditionalTools == nil expects a different tool count (e.g., zero tools when no base tools are configured), adjust the len(req.Tools) assertion in TestBuildTurnRequestHandlesNilAdditionalTools to match the expected baseline behavior in your codebase.


### Important Behavioral Change Since Last Review
- `toolmeta/runtime_behavior.go` 现在把 `gold_price_get` 和 `stock_zh_a_get` 标记为需要审批的 chat-write 工具。
- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数”。
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (typo): 建议将“只读取数”改为更常见的“只读取数据”

“只读取数”读起来像是被截断的表达,在这份文档中略显不自然。建议改为“只读取数据”,可以更准确地表达只读能力,并与整体术语风格保持一致。

Suggested change
- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数”。
- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数据”。
Original comment in English

nitpick (typo): Consider changing “只读取数” to the more standard “只读取数据”.

“只读取数”像是被截断的表述,在这份文档里读起来有些不自然。建议改为“只读取数据”,以更准确表达只读能力并与整体术语风格保持一致。

Suggested change
- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数”。
- 因为它们会发送卡片,当前 runtime prompt 语义上也会把它们当“执行动作”而不是“只读取数据”。

@kevinmatthe kevinmatthe merged commit cdb67bc into master Mar 26, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant