mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Improve NOFXi agent strategy creation flow
This commit is contained in:
@@ -66,7 +66,7 @@ func DefaultConfig() *Config {
|
||||
EnableBriefs: true,
|
||||
EnableNews: true,
|
||||
EnableSentinel: true,
|
||||
AllowTradeExecution: false,
|
||||
AllowTradeExecution: true,
|
||||
BriefTimes: []int{8, 20},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ Rules:
|
||||
- For those lightweight social messages, choose EXPLAIN_KNOWLEDGE and reply naturally, or let the task stay suspended.
|
||||
- Use NEW_TASK only when there is no active task, or the user clearly switches goals/domains.
|
||||
- Use EXPLAIN_KNOWLEDGE for concept/range/help questions; do not change state. When answering, use ONLY the options/values listed in the active session's missing_required_fields. Never invent field values or provider names.
|
||||
- For diagnosis, create, update, delete, start, stop, activation, duplication, or historical-performance analysis tasks, never reply only with a future promise such as "I'll do it now", "please wait", "diagnosis is running", or "I'll tell you later". If the next step is execution, choose the corresponding skill/planned execution. If execution is impossible, say exactly what information or data is missing.
|
||||
- Use CANCEL_TASK for "cancel", "stop", "forget it", "never mind", "算了", "取消".
|
||||
- Domain guard: if the user says "模型", "AI 模型", or "model" and asks to create or configure one, you must route to model_management, not exchange_management.
|
||||
- Domain guard: for model_management, the field "provider" means the AI model vendor such as OpenAI, DeepSeek, Claude, Gemini, Qwen, Kimi, Grok, Minimax, claw402, blockrun-base, or blockrun-sol. It never means an exchange like Binance, OKX, Bybit, CFD, forex, or metals.
|
||||
@@ -230,6 +231,9 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us
|
||||
if reply == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
|
||||
reply = guarded
|
||||
}
|
||||
emitBrainReply(onEvent, reply)
|
||||
a.recordSkillInteraction(userID, text, reply)
|
||||
return reply, true, nil
|
||||
@@ -310,6 +314,9 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
|
||||
if reply == "" {
|
||||
reply = a.askForMissingFields(lang, session)
|
||||
}
|
||||
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
|
||||
reply = guarded
|
||||
}
|
||||
if len(missingRequiredFields(session)) == 0 && actionNeedsConfirmation(session.SkillName, session.ActionName) {
|
||||
session.LegacyPhase = "await_confirmation"
|
||||
session.CollectedFields["phase"] = "await_confirmation"
|
||||
@@ -360,6 +367,17 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
|
||||
return reply, true, nil
|
||||
}
|
||||
|
||||
if shouldTrustDeterministicSkillReply(outcome) {
|
||||
answer := strings.TrimSpace(outcome.UserMessage)
|
||||
if answer == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
a.clearActiveSkillSession(userID)
|
||||
emitBrainReply(onEvent, answer)
|
||||
a.recordSkillInteraction(userID, text, answer)
|
||||
return answer, true, nil
|
||||
}
|
||||
|
||||
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
|
||||
if err != nil {
|
||||
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
|
||||
@@ -408,8 +426,10 @@ Return JSON only.
|
||||
Rules:
|
||||
- Think from the current user message, previous assistant proposal, and active history.
|
||||
- If concrete strategy settings can be determined, write them into extracted_data.config_patch as a StrategyConfig-shaped JSON patch.
|
||||
- If the previous assistant already asked the user to confirm a concrete creation proposal and the current user confirms it, set extracted_data.awaiting_final_confirmation=true too.
|
||||
- If the user is asking you to design settings but has not confirmed creation yet, use route ask_user, provide a concise final confirmation reply, and include the designed config in extracted_data.config_patch plus extracted_data.awaiting_final_confirmation=true.
|
||||
- If the previous assistant already asked the user to confirm a concrete creation proposal in chat and the current user confirms it, set extracted_data.awaiting_final_confirmation=true too.
|
||||
- For strategy creation, after the initial strategy type is known, do not ask field-by-field. Propose one complete draft using user-provided values plus safe defaults for anything still unspecified.
|
||||
- If the user is asking you to design settings but has not confirmed creation yet, use route ask_user, provide a concise chat confirmation reply, and include the designed config in extracted_data.config_patch plus extracted_data.awaiting_final_confirmation=true.
|
||||
- Strategy creation is chat-executable. Do not tell the user to click a web/app button, open a page, or manually create it elsewhere.
|
||||
- Do not claim the strategy was created. This step only repairs state or asks for more information.
|
||||
- If there is not enough information to determine a config, ask one natural follow-up question.
|
||||
|
||||
@@ -491,7 +511,7 @@ func guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkill
|
||||
if session.SkillName != "strategy_management" || session.ActionName != "create" {
|
||||
return "", false
|
||||
}
|
||||
if activeFieldBool(session.CollectedFields["awaiting_final_confirmation"]) {
|
||||
if activeFieldBool(session.CollectedFields["awaiting_final_confirmation"]) && strategyCreateHasPriorConfirmationPrompt(session) {
|
||||
return "", false
|
||||
}
|
||||
legacy := activeToLegacySkillSession(session)
|
||||
@@ -505,6 +525,26 @@ func guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkill
|
||||
return formatStrategyCreateFinalConfirmation(lang, legacy, cfg), true
|
||||
}
|
||||
|
||||
func strategyCreateHasPriorConfirmationPrompt(session ActiveSkillSession) bool {
|
||||
for i := len(session.LocalHistory) - 1; i >= 0; i-- {
|
||||
msg := session.LocalHistory[i]
|
||||
if msg.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(content)
|
||||
return strings.Contains(content, "确认创建") ||
|
||||
strings.Contains(content, "确认后我再创建") ||
|
||||
strings.Contains(content, "配置整理好了") ||
|
||||
strings.Contains(lower, "confirm") ||
|
||||
strings.Contains(lower, "create it")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func activeFieldBool(v any) bool {
|
||||
switch typed := v.(type) {
|
||||
case bool:
|
||||
@@ -529,6 +569,29 @@ func guardUnexecutedActiveTaskCompletion(lang string, session ActiveSkillSession
|
||||
return "It has not actually been executed yet. The previous step only prepared or confirmed the draft; I need to run the structured tool before claiming completion.", true
|
||||
}
|
||||
|
||||
func guardUnsupportedAsyncPromise(lang, reply string) (string, bool) {
|
||||
lower := strings.ToLower(strings.TrimSpace(reply))
|
||||
if lower == "" {
|
||||
return "", false
|
||||
}
|
||||
promiseSignals := []string{
|
||||
"请稍等", "稍等片刻", "再稍等", "马上", "稍后", "立刻告诉", "数据一出来", "一两分钟",
|
||||
"还在进行", "正在进行", "正在为", "正在帮", "一直在帮", "诊断中", "分析中",
|
||||
"please wait", "give me a moment", "still running", "i'll let you know", "i will let you know",
|
||||
}
|
||||
taskSignals := []string{
|
||||
"诊断", "分析", "历史交易", "历史表现", "亏损原因", "创建", "修改", "删除", "启动", "停止",
|
||||
"diagnos", "analyz", "history", "performance", "loss", "create", "update", "delete", "start", "stop",
|
||||
}
|
||||
if !containsAny(lower, promiseSignals) || !containsAny(lower, taskSignals) {
|
||||
return "", false
|
||||
}
|
||||
if lang == "zh" {
|
||||
return "我需要纠正一下:我没有后台异步任务在运行,也不会稍后自动推送结果。诊断/创建/修改/启动这类任务必须在当前回复里实际执行并给出真实结果;如果还不能执行,我应该直接说明缺少哪个对象、时间范围或数据。", true
|
||||
}
|
||||
return "I need to correct that: there is no background task running, and I will not automatically push a later result. Diagnosis/create/update/start tasks must actually execute and return a real result in the current response; if execution is not possible, I should state which target, range, or data is missing.", true
|
||||
}
|
||||
|
||||
func isMutatingActiveTask(session ActiveSkillSession) bool {
|
||||
if strings.TrimSpace(session.SkillName) == "" {
|
||||
return false
|
||||
@@ -601,12 +664,17 @@ Rules:
|
||||
- Use contextual memory from the active task history and current references.
|
||||
- Prefer "execute_skill" when the user has already given enough information to act.
|
||||
- Prefer "ask_user" only when something truly necessary is still missing.
|
||||
- For strategy_management:create, the only normal first fork is strategy type: AI strategy or grid strategy. After that, do not collect fields one by one; produce a complete recommended draft from user-provided values plus safe defaults, then ask the user to confirm or change any item.
|
||||
- For strategy_management:create/update_config: every turn, reason about whether any config fields can now be determined from the user's message and conversation history. If yes, write them into extracted_data.config_patch.
|
||||
- For strategy_management:create: when the user asks you to design/recommend settings, think as the strategy designer, produce a concrete recommended config in your reply, and also put the same structured config into extracted_data.config_patch. Do not ask the user to fill fields you can reasonably choose for them.
|
||||
- For strategy_management:create: once the structured config is sufficient to create, ask for one final confirmation and set extracted_data.awaiting_final_confirmation=true. Do not execute create in that same turn.
|
||||
- For strategy_management:create: choose execute_skill only when awaiting_final_confirmation is already true and the current user message confirms the final summary. If the user changes a number, update config_patch and ask for final confirmation again.
|
||||
- For strategy_management:create: clearly distinguish user-provided fields from your recommended/defaulted fields. Never say a value is "already filled", "user provided", or "already configured" unless it appears in Current collected fields or the current user message. For values you choose, say "我建议/我先按安全默认值".
|
||||
- For strategy_management:create grid_trading: never infer "current BTC/ETH/SOL price" or explicit upper/lower grid bounds from memory. If no fresh market tool observation is present, recommend ATR auto bounds instead and set grid_config.use_atr_bounds=true with upper_price/lower_price omitted or 0. Only recommend numeric upper_price/lower_price as "based on current price" when the price was actually fetched in this task.
|
||||
- For strategy_management:create: once the structured config is sufficient to create, ask for one chat confirmation reply (for example, "回复“确认创建”") and set extracted_data.awaiting_final_confirmation=true. Do not execute create in that same turn.
|
||||
- For strategy_management:create: choose execute_skill only when awaiting_final_confirmation is already true and the current user message confirms the chat summary. If the user changes a number, update config_patch and ask for chat confirmation again.
|
||||
- For strategy_management:create: the confirmation happens in chat. Never tell the user to click a web/app button, find a page button, or manually create it elsewhere.
|
||||
- For strategy_management:create: if the previous assistant reply said the strategy was not actually created yet and that the next step is to call the structured create tool, then a user request to continue/proceed means execute the current skill when the structured config is ready. Do not answer with another promise such as "I will create it now"; choose execute_skill.
|
||||
- For any mutating task, a reply that only promises future execution ("now I will create/update/start it", "result soon") is not a valid finish_task or ask_user outcome. If execution is the next step, choose execute_skill.
|
||||
- For diagnosis, create, update, delete, start, stop, query/history, and performance-analysis tasks, never answer with only "马上处理 / 请稍等 / 诊断中 / I'll tell you later". NOFXi has no background chat job that will later push an answer. Choose execute_skill/planned_agent when enough information exists; otherwise ask for the missing target/range/data.
|
||||
- Never choose finish_task for an unfinished mutating active task by claiming it was created/updated/deleted/started/stopped. Only a real skill/tool execution outcome can support that claim.
|
||||
- If the user says they do not understand the current form, choices, or required information, choose "ask_user" and explain the current pending question in plain language before asking the next easiest question. Cover the relevant concepts from the previous assistant reply; do not collapse the answer to only the first missing field.
|
||||
- For beginner/confusion replies, give a safe recommended path when the domain supports one, but do not execute or create anything unless the user confirms after the explanation.
|
||||
@@ -679,6 +747,20 @@ func (a *Agent) executeActiveSkillSession(storeUserID string, userID int64, lang
|
||||
return outcome, ActiveSkillSession{}, false, true
|
||||
}
|
||||
|
||||
func shouldTrustDeterministicSkillReply(outcome skillOutcome) bool {
|
||||
if outcome.Status != skillOutcomeSuccess || !outcome.GoalAchieved {
|
||||
return false
|
||||
}
|
||||
switch outcome.Skill {
|
||||
case "strategy_management", "trader_management", "model_management", "exchange_management":
|
||||
switch outcome.Action {
|
||||
case "create", "update", "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model", "update_status", "update_endpoint", "update_config", "update_prompt", "delete", "start", "stop", "activate", "duplicate":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) askForMissingFields(lang string, session ActiveSkillSession) string {
|
||||
missing := missingRequiredFieldsForBrain(session)
|
||||
if len(missing) == 0 {
|
||||
@@ -758,6 +840,15 @@ func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
|
||||
legacy.Fields[k] = str
|
||||
}
|
||||
}
|
||||
if s.SkillName == "strategy_management" && s.ActionName == "create" {
|
||||
draft := buildStrategyDraftFromActiveSession(s)
|
||||
if legacy.Fields["name"] == "" && strings.TrimSpace(draft.Name) != "" {
|
||||
legacy.Fields["name"] = strings.TrimSpace(draft.Name)
|
||||
}
|
||||
if draftRaw := marshalStrategyDraft(draft); draftRaw != "{}" {
|
||||
legacy.Fields[strategyCreateDraftIntentField] = draftRaw
|
||||
}
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,9 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri
|
||||
if decision.TopicIntent == "instant_reply" && a.hasAnyActiveContext(userID) {
|
||||
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
|
||||
}
|
||||
if guarded, blocked := guardUnsupportedAsyncPromise(lang, decision.ReplyToUser); blocked {
|
||||
decision.ReplyToUser = guarded
|
||||
}
|
||||
emitBrainReply(onEvent, decision.ReplyToUser)
|
||||
a.recordSkillInteraction(userID, text, decision.ReplyToUser)
|
||||
a.runPostResponseMaintenanceAsync(userID)
|
||||
|
||||
@@ -349,7 +349,8 @@ const strategyCreateDraftConfigField = "strategy_create_draft_config"
|
||||
const strategyCreateConfigPatchField = "config_patch"
|
||||
|
||||
func applyStrategyCreateIntentToConfig(cfg *store.StrategyConfig, text, lang string) []string {
|
||||
return nil
|
||||
draft := applyStrategyDraftText(strategyDraft{}, text)
|
||||
return applyStrategyDraftToConfig(cfg, draft)
|
||||
}
|
||||
|
||||
func marshalStrategyCreateDraft(cfg store.StrategyConfig) string {
|
||||
@@ -396,6 +397,10 @@ func strategyCreateConfigFromSession(session skillSession, lang string) (store.S
|
||||
}
|
||||
cfg = merged
|
||||
}
|
||||
if draftRaw := strings.TrimSpace(fieldValue(session, strategyCreateDraftIntentField)); draftRaw != "" {
|
||||
applyStrategyDraftToConfig(&cfg, unmarshalStrategyDraft(draftRaw))
|
||||
}
|
||||
applyStrategyCreateTypeDefaults(&cfg)
|
||||
beforeClamp := cfg
|
||||
cfg.ClampLimits()
|
||||
if strings.TrimSpace(cfg.StrategyType) == "" {
|
||||
@@ -408,6 +413,76 @@ func strategyCreateConfigFromSession(session skillSession, lang string) (store.S
|
||||
return cfg, configMap, store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language), nil
|
||||
}
|
||||
|
||||
func resolveStrategyCreateName(session *skillSession, text string) string {
|
||||
if session == nil {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimSpace(fieldValue(*session, "name"))
|
||||
if name == "" {
|
||||
if draft := unmarshalStrategyDraft(fieldValue(*session, strategyCreateDraftIntentField)); strings.TrimSpace(draft.Name) != "" {
|
||||
name = strings.TrimSpace(draft.Name)
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
if inferred := inferStandaloneStrategyName(text); inferred != "" {
|
||||
name = inferred
|
||||
}
|
||||
}
|
||||
if name != "" {
|
||||
setField(session, "name", name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func applyStrategyCreateTypeDefaults(cfg *store.StrategyConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
switch strings.TrimSpace(cfg.StrategyType) {
|
||||
case "grid_trading":
|
||||
defaultGrid := store.DefaultGridStrategyConfig()
|
||||
if cfg.GridConfig == nil {
|
||||
cfg.GridConfig = &defaultGrid
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(cfg.GridConfig.Symbol) == "" {
|
||||
cfg.GridConfig.Symbol = defaultGrid.Symbol
|
||||
}
|
||||
if cfg.GridConfig.GridCount <= 0 {
|
||||
cfg.GridConfig.GridCount = defaultGrid.GridCount
|
||||
}
|
||||
if cfg.GridConfig.TotalInvestment <= 0 {
|
||||
cfg.GridConfig.TotalInvestment = defaultGrid.TotalInvestment
|
||||
}
|
||||
if cfg.GridConfig.Leverage <= 0 {
|
||||
cfg.GridConfig.Leverage = defaultGrid.Leverage
|
||||
}
|
||||
if cfg.GridConfig.ATRMultiplier <= 0 {
|
||||
cfg.GridConfig.ATRMultiplier = defaultGrid.ATRMultiplier
|
||||
}
|
||||
if strings.TrimSpace(cfg.GridConfig.Distribution) == "" {
|
||||
cfg.GridConfig.Distribution = defaultGrid.Distribution
|
||||
}
|
||||
if cfg.GridConfig.MaxDrawdownPct <= 0 {
|
||||
cfg.GridConfig.MaxDrawdownPct = defaultGrid.MaxDrawdownPct
|
||||
}
|
||||
if cfg.GridConfig.StopLossPct <= 0 {
|
||||
cfg.GridConfig.StopLossPct = defaultGrid.StopLossPct
|
||||
}
|
||||
if cfg.GridConfig.DailyLossLimitPct <= 0 {
|
||||
cfg.GridConfig.DailyLossLimitPct = defaultGrid.DailyLossLimitPct
|
||||
}
|
||||
if cfg.GridConfig.DirectionBiasRatio <= 0 {
|
||||
cfg.GridConfig.DirectionBiasRatio = defaultGrid.DirectionBiasRatio
|
||||
}
|
||||
if cfg.GridConfig.UpperPrice <= 0 && cfg.GridConfig.LowerPrice <= 0 {
|
||||
cfg.GridConfig.UseATRBounds = true
|
||||
}
|
||||
case "":
|
||||
cfg.StrategyType = "ai_trading"
|
||||
}
|
||||
}
|
||||
|
||||
func removeLockedStrategyCreateFields(configMap map[string]any) {
|
||||
if configMap == nil {
|
||||
return
|
||||
@@ -424,7 +499,12 @@ func strategyCreateConfirmationReply(text string) bool {
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return isYesReply(text) || lower == "确认创建" || lower == "创建吧" || lower == "就按这个创建" || lower == "按这个创建" || lower == "确认应用" || lower == "应用" || lower == "就按这个应用"
|
||||
for _, exact := range []string{"确认创建", "创建吧", "就按这个创建", "按这个创建", "确认应用", "就按这个应用"} {
|
||||
if lower == exact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func strategyCreateDefaultConfigReply(text string) bool {
|
||||
@@ -442,6 +522,9 @@ func explicitStrategyCreateType(session skillSession) string {
|
||||
if value := strings.TrimSpace(fieldValue(session, "strategy_type")); value != "" {
|
||||
return value
|
||||
}
|
||||
if draft := unmarshalStrategyDraft(fieldValue(session, strategyCreateDraftIntentField)); draft.StrategyKind != "" || len(draft.Symbols) > 0 || draft.Timeframe != "" || draft.Leverage > 0 {
|
||||
return "ai_trading"
|
||||
}
|
||||
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
|
||||
if patchRaw == "" {
|
||||
return ""
|
||||
@@ -460,19 +543,16 @@ func explicitStrategyCreateType(session skillSession) string {
|
||||
}
|
||||
|
||||
func strategyCreateConfigReady(session skillSession, cfg store.StrategyConfig, text string) (bool, string) {
|
||||
if strategyCreateDefaultConfigReply(text) {
|
||||
return true, ""
|
||||
}
|
||||
strategyType := explicitStrategyCreateType(session)
|
||||
if !strategyCreateHasExplicitConfigBeyondType(session) {
|
||||
if strategyType == "" {
|
||||
return false, "strategy_type"
|
||||
}
|
||||
return false, strategyType
|
||||
}
|
||||
if strategyType == "" {
|
||||
return false, "strategy_type"
|
||||
}
|
||||
if strategyCreateDefaultConfigReply(text) || strategyCreateConfirmationReply(text) || strategyCreateFinalConfirmationReady(session) {
|
||||
return true, ""
|
||||
}
|
||||
if !strategyCreateHasExplicitConfigBeyondType(session) {
|
||||
return false, strategyType
|
||||
}
|
||||
switch strategyType {
|
||||
case "grid_trading":
|
||||
grid := cfg.GridConfig
|
||||
@@ -496,6 +576,10 @@ func strategyCreateConfigReady(session skillSession, cfg store.StrategyConfig, t
|
||||
}
|
||||
}
|
||||
|
||||
func strategyCreateFinalConfirmationReady(session skillSession) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(fieldValue(session, "awaiting_final_confirmation")), "true")
|
||||
}
|
||||
|
||||
func strategyCreateHasExplicitConfigBeyondType(session skillSession) bool {
|
||||
for _, key := range manualStrategyEditableFieldKeys() {
|
||||
switch key {
|
||||
@@ -506,6 +590,9 @@ func strategyCreateHasExplicitConfigBeyondType(session skillSession) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if draft := unmarshalStrategyDraft(fieldValue(session, strategyCreateDraftIntentField)); len(draft.Symbols) > 0 || draft.Timeframe != "" || draft.Leverage > 0 || draft.CoinSourceIntent != "" {
|
||||
return true
|
||||
}
|
||||
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
|
||||
if patchRaw == "" {
|
||||
return false
|
||||
@@ -526,35 +613,18 @@ func formatStrategyCreateConfigNeeded(lang, strategyType string) string {
|
||||
if lang == "zh" {
|
||||
switch strategyType {
|
||||
case "grid_trading":
|
||||
return strings.Join([]string{
|
||||
"好的,先不创建空模板。网格策略需要先把核心配置补齐,之后我再调用 create 落库。",
|
||||
"需要确认这些配置:",
|
||||
"- 交易对:BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT",
|
||||
"- 网格数量、总投入、杠杆",
|
||||
"- 价格区间:用 ATR 动态边界,或手动给上边界/下边界",
|
||||
"- 网格分布:uniform、gaussian、pyramid",
|
||||
"- 风控:最大回撤、止损、每日亏损限制、是否只挂 maker 单、是否启用方向偏置",
|
||||
"你可以一次性告诉我这些参数;如果想先用默认值,也可以明确说“用默认配置创建”。",
|
||||
}, "\n")
|
||||
return "我先按一套安全默认网格参数整理完整草稿,不逐项问你。你可以直接改任何一项,或者确认后我创建。"
|
||||
case "ai_trading":
|
||||
return strings.Join([]string{
|
||||
"好的,先不创建空模板。AI 策略需要先把核心配置补齐,之后我再调用 create 落库。",
|
||||
"需要确认这些配置:",
|
||||
"- 选币来源:static、ai500、oi_top、oi_low",
|
||||
"- K 线主周期和多周期",
|
||||
"- 风控:杠杆、最小置信度、最小盈亏比",
|
||||
"- 提示词方向:角色定义、交易频率、入场标准、决策流程",
|
||||
"你可以一次性告诉我这些参数;如果想先用默认值,也可以明确说“用默认配置创建”。",
|
||||
}, "\n")
|
||||
return "我先按一套安全默认 AI 策略参数整理完整草稿,不逐项问你。你可以直接改任何一项,或者确认后我创建。"
|
||||
default:
|
||||
return "先选择策略类型:grid_trading(网格策略)或 ai_trading(AI 策略)。类型确认后我会继续收集对应配置,配置好后再创建。"
|
||||
}
|
||||
}
|
||||
switch strategyType {
|
||||
case "grid_trading":
|
||||
return "I will not create an empty template yet. For a grid strategy, please provide symbol, grid count, total investment, leverage, boundary mode/prices, distribution, and risk settings. Say “use defaults” if you want the remaining fields defaulted before creation."
|
||||
return "I prepared a complete safe default grid draft instead of asking field by field. You can change any field or confirm to create it."
|
||||
case "ai_trading":
|
||||
return "I will not create an empty template yet. For an AI strategy, please provide coin source, timeframes, risk settings, and prompt direction. Say “use defaults” if you want the remaining fields defaulted before creation."
|
||||
return "I prepared a complete safe default AI draft instead of asking field by field. You can change any field or confirm to create it."
|
||||
default:
|
||||
return "Choose the strategy type first: grid_trading or ai_trading. I will collect the matching config before creating it."
|
||||
}
|
||||
@@ -956,7 +1026,7 @@ func formatTraderCreateDraftSummary(lang string, session skillSession) string {
|
||||
}
|
||||
|
||||
func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||||
name := fieldValue(session, "name")
|
||||
name := resolveStrategyCreateName(&session, text)
|
||||
if actionRequiresSlot("strategy_management", "create", "name") && strings.TrimSpace(name) == "" {
|
||||
setSkillDAGStep(&session, "resolve_name")
|
||||
a.saveSkillSession(userID, session)
|
||||
@@ -984,15 +1054,22 @@ func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, la
|
||||
setSkillDAGStep(&session, "await_create_confirmation")
|
||||
session.Phase = "draft_create"
|
||||
|
||||
if strategyCreateConfirmationReply(text) {
|
||||
if strategyCreateConfirmationReply(text) || strategyCreateFinalConfirmationReady(session) {
|
||||
if ready, missingKind := strategyCreateConfigReady(session, cfg, text); !ready {
|
||||
if missingKind != "strategy_type" {
|
||||
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
|
||||
setField(&session, "awaiting_final_confirmation", "true")
|
||||
a.saveSkillSession(userID, session)
|
||||
return formatStrategyCreateFinalConfirmation(lang, session, cfg)
|
||||
}
|
||||
a.saveSkillSession(userID, session)
|
||||
return formatStrategyCreateConfigNeeded(lang, missingKind)
|
||||
}
|
||||
args := map[string]any{
|
||||
"action": "create",
|
||||
"name": name,
|
||||
"lang": defaultIfEmpty(lang, "zh"),
|
||||
"action": "create",
|
||||
"name": name,
|
||||
"lang": defaultIfEmpty(lang, "zh"),
|
||||
"confirmed": true,
|
||||
}
|
||||
rawCfg, _ := json.Marshal(cfg)
|
||||
var configMap map[string]any
|
||||
@@ -1009,10 +1086,11 @@ func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, la
|
||||
return "That create request did not go through: " + errMsg
|
||||
}
|
||||
a.clearSkillSession(userID)
|
||||
a.rememberReferencesFromToolResult(userID, "manage_strategy", resp)
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("已按当前草稿创建策略“%s”。后续如果还想继续细化参数,直接告诉我就行。", name)
|
||||
return formatCreatedStrategyReply(lang, name, cfg, warnings)
|
||||
}
|
||||
return fmt.Sprintf("Created strategy %q from the current draft.", name)
|
||||
return formatCreatedStrategyReply(lang, name, cfg, warnings)
|
||||
}
|
||||
|
||||
a.saveSkillSession(userID, session)
|
||||
@@ -1869,7 +1947,7 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
|
||||
}
|
||||
return "Cancelled the current strategy creation flow."
|
||||
}
|
||||
name := fieldValue(session, "name")
|
||||
name := resolveStrategyCreateName(&session, text)
|
||||
hasDescriptiveDraftIntent := session.Phase == "draft_create"
|
||||
if hasDescriptiveDraftIntent {
|
||||
return a.continueStrategyCreateDraft(storeUserID, userID, lang, text, session)
|
||||
@@ -1899,6 +1977,11 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
|
||||
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
|
||||
setSkillDAGStep(&session, "collect_config")
|
||||
session.Phase = "draft_create"
|
||||
if missingKind != "strategy_type" {
|
||||
setField(&session, "awaiting_final_confirmation", "true")
|
||||
a.saveSkillSession(userID, session)
|
||||
return formatStrategyCreateFinalConfirmation(lang, session, cfg)
|
||||
}
|
||||
a.saveSkillSession(userID, session)
|
||||
return formatStrategyCreateConfigNeeded(lang, missingKind)
|
||||
}
|
||||
@@ -1909,6 +1992,7 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
|
||||
"name": name,
|
||||
"lang": defaultIfEmpty(lang, "zh"),
|
||||
"allow_clamped_update": true,
|
||||
"confirmed": true,
|
||||
}
|
||||
if len(configMap) > 0 {
|
||||
args["config"] = configMap
|
||||
@@ -1924,18 +2008,77 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
|
||||
}
|
||||
a.clearSkillSession(userID)
|
||||
a.rememberReferencesFromToolResult(userID, "manage_strategy", resp)
|
||||
return formatCreatedStrategyReply(lang, name, cfg, warnings)
|
||||
}
|
||||
|
||||
func formatCreatedStrategyReply(lang, name string, cfg store.StrategyConfig, warnings []string) string {
|
||||
name = defaultIfEmpty(strings.TrimSpace(name), "未命名策略")
|
||||
if lang != "zh" {
|
||||
name = defaultIfEmpty(strings.TrimSpace(name), "unnamed strategy")
|
||||
}
|
||||
_ = warnings
|
||||
if lang == "zh" {
|
||||
reply := fmt.Sprintf("已创建策略“%s”,并已按你的需求生成配置。", name)
|
||||
if len(warnings) > 0 {
|
||||
reply += "\n有些值超出安全范围,系统已自动收敛:\n- " + strings.Join(warnings, "\n- ")
|
||||
lines := []string{fmt.Sprintf("已创建策略“%s”。实际保存配置如下:", name)}
|
||||
if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil {
|
||||
grid := cfg.GridConfig
|
||||
lines = append(lines,
|
||||
"- 类型:网格策略",
|
||||
fmt.Sprintf("- 交易对:%s", defaultIfEmpty(grid.Symbol, "未设置")),
|
||||
fmt.Sprintf("- 网格数量:%d", grid.GridCount),
|
||||
fmt.Sprintf("- 总投入:%.2f USDT", grid.TotalInvestment),
|
||||
fmt.Sprintf("- 杠杆:%d倍", grid.Leverage),
|
||||
fmt.Sprintf("- 分布方式:%s", defaultIfEmpty(grid.Distribution, "未设置")),
|
||||
)
|
||||
if grid.UseATRBounds {
|
||||
lines = append(lines, fmt.Sprintf("- 价格范围:ATR 自动计算(倍数 %.2f)", grid.ATRMultiplier))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- 价格范围:%.2f ~ %.2f USDT", grid.LowerPrice, grid.UpperPrice))
|
||||
}
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 最大回撤:%.2f%%", grid.MaxDrawdownPct),
|
||||
fmt.Sprintf("- 止损:%.2f%%", grid.StopLossPct),
|
||||
fmt.Sprintf("- 日亏损限制:%.2f%%", grid.DailyLossLimitPct),
|
||||
fmt.Sprintf("- 只挂 Maker:%t", grid.UseMakerOnly),
|
||||
)
|
||||
} else {
|
||||
lines = append(lines,
|
||||
"- 类型:AI 策略",
|
||||
fmt.Sprintf("- 选币来源:%s", defaultIfEmpty(cfg.CoinSource.SourceType, "未设置")),
|
||||
fmt.Sprintf("- 静态币种:%s", strings.Join(cfg.CoinSource.StaticCoins, ", ")),
|
||||
fmt.Sprintf("- 主周期:%s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "未设置")),
|
||||
fmt.Sprintf("- BTC/ETH 最大杠杆:%d倍", cfg.RiskControl.BTCETHMaxLeverage),
|
||||
fmt.Sprintf("- 山寨币最大杠杆:%d倍", cfg.RiskControl.AltcoinMaxLeverage),
|
||||
fmt.Sprintf("- 最小置信度:%d", cfg.RiskControl.MinConfidence),
|
||||
fmt.Sprintf("- 最小盈亏比:%.2f", cfg.RiskControl.MinRiskRewardRatio),
|
||||
)
|
||||
}
|
||||
return reply
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
reply := fmt.Sprintf("Created strategy %q with a config generated from your requirements.", name)
|
||||
if len(warnings) > 0 {
|
||||
reply += "\nSome values were clamped to product safety limits:\n- " + strings.Join(warnings, "\n- ")
|
||||
|
||||
lines := []string{fmt.Sprintf("Created strategy %q with this saved config:", name)}
|
||||
if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil {
|
||||
grid := cfg.GridConfig
|
||||
lines = append(lines,
|
||||
"- Type: grid strategy",
|
||||
fmt.Sprintf("- Symbol: %s", defaultIfEmpty(grid.Symbol, "unset")),
|
||||
fmt.Sprintf("- Grid count: %d", grid.GridCount),
|
||||
fmt.Sprintf("- Total investment: %.2f USDT", grid.TotalInvestment),
|
||||
fmt.Sprintf("- Leverage: %dx", grid.Leverage),
|
||||
fmt.Sprintf("- Distribution: %s", defaultIfEmpty(grid.Distribution, "unset")),
|
||||
)
|
||||
if grid.UseATRBounds {
|
||||
lines = append(lines, fmt.Sprintf("- Price range: ATR auto bounds (multiplier %.2f)", grid.ATRMultiplier))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- Price range: %.2f - %.2f USDT", grid.LowerPrice, grid.UpperPrice))
|
||||
}
|
||||
} else {
|
||||
lines = append(lines,
|
||||
"- Type: AI strategy",
|
||||
fmt.Sprintf("- Coin source: %s", defaultIfEmpty(cfg.CoinSource.SourceType, "unset")),
|
||||
fmt.Sprintf("- Primary timeframe: %s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "unset")),
|
||||
)
|
||||
}
|
||||
return reply
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) {
|
||||
|
||||
@@ -375,6 +375,7 @@
|
||||
"grid_trading 的 symbol 只能从页面下拉选项 BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT 中选择。",
|
||||
"grid_trading 的 grid_count 范围为 5~50,total_investment 最小 100,leverage 范围 1~5,atr_multiplier 范围 1~5。",
|
||||
"grid_trading 的 max_drawdown_pct 范围为 5~50,stop_loss_pct 范围为 1~20,daily_loss_limit_pct 范围为 1~30,direction_bias_ratio 范围为 0.55~0.90。",
|
||||
"grid_trading 推荐价格区间时,不能凭记忆猜“当前 BTC/ETH/SOL 价格”。没有实时行情工具结果时,应优先推荐 use_atr_bounds=true 的 ATR 自动边界;只有本轮已经获取过实时价格,才可以给基于当前价格的 upper_price/lower_price 数值。",
|
||||
"AI 策略的 static_coins 最多 10 个,selected_timeframes 最多 4 个,primary_count 范围为 10~30。",
|
||||
"排行榜 duration 只能用页面选项;ranking_limit 只能用 5、10、15、20。",
|
||||
"btceth_max_leverage 和 altcoin_max_leverage 范围均为 1~20,超出时自动收敛并告知用户。",
|
||||
@@ -382,6 +383,8 @@
|
||||
"grid_trading 类型时,lower_price 必须小于 upper_price,否则提示用户修正。",
|
||||
"策略模板不能直接启动运行,只有绑定了该策略的交易员才能启动。",
|
||||
"策略模板创建成功后应出现在策略列表/策略页。",
|
||||
"本 skill 的“页面/手动页面”表述只用于说明字段范围和 UI 约束,不表示用户必须去页面操作;创建、更新、删除、激活、复制等动作都应通过 Agent 工具在聊天中执行。",
|
||||
"创建策略的确认发生在聊天里:当配置已整理好,应让用户回复“确认创建”;用户确认后必须调用创建工具,不要要求用户点击网页或 APP 里的按钮。",
|
||||
"创建策略模板时不要要求用户先绑定或添加交易所账户,也不要要求绑定 AI 模型;交易所和模型只属于 trader 创建、部署或启动流程。",
|
||||
"策略是模板/规则,不保存交易所 API、模型 provider、钱包余额或扫描间隔;这些属于交易员、模型或交易所配置。",
|
||||
"如果用户只是创建或配置策略模板,不要把它升级成 trader 创建流程。",
|
||||
@@ -401,7 +404,10 @@
|
||||
"goal": "创建一个可供 trader 绑定使用的策略模板。",
|
||||
"dynamic_rules": [
|
||||
"若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。",
|
||||
"若用户明确要求新建策略,至少先收齐名称;其他配置可继续追问或按默认值协助补齐。",
|
||||
"若用户明确要求新建策略,至少先收齐名称;如果用户尚未说明策略类型,只问一次 AI 策略还是网格策略。",
|
||||
"策略类型确定后,不要一个字段一个字段追问配置;应把用户已给出的信息合并进去,剩余字段用安全默认值补齐,直接给一份完整草稿让用户确认或修改。",
|
||||
"如果配置已经足够且用户在聊天里确认创建,应直接调用 Agent 的创建工具;不要告诉用户去页面点击“确认”或“创建策略”按钮。",
|
||||
"当用户说“全部由你定”“你帮我决定”时,可以给出安全默认/推荐参数,但必须说清楚这些是 Agent 建议值;不要说成“你已经填好/你已经配置好”。",
|
||||
"创建策略模板本身不需要交易所账户或 AI 模型;不要在 create 策略时询问用户是否已有交易所账户。",
|
||||
"如果用户同时提到模型 provider、交易所账户或扫描间隔,应把这些信息留给 trader 创建流程,不要写入策略配置。",
|
||||
"只有当用户明确要求运行、部署、实盘、创建交易员或绑定到交易员时,才进入 trader 流程并收集交易所/模型。",
|
||||
|
||||
242
agent/strategy_draft.go
Normal file
242
agent/strategy_draft.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
const strategyCreateDraftIntentField = "strategy_draft"
|
||||
|
||||
var compactCoinPairRE = regexp.MustCompile(`(?i)\b([A-Z0-9]{2,10})\s*(?:和|与|/|,|,|、|\+)\s*([A-Z0-9]{2,10})\b`)
|
||||
|
||||
type strategyDraft struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
StrategyKind string `json:"strategy_kind,omitempty"`
|
||||
CoinSourceIntent string `json:"coin_source_intent,omitempty"`
|
||||
Symbols []string `json:"symbols,omitempty"`
|
||||
Timeframe string `json:"timeframe,omitempty"`
|
||||
Leverage int `json:"leverage,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeStrategyDraft(d strategyDraft) strategyDraft {
|
||||
d.Name = strings.TrimSpace(d.Name)
|
||||
d.StrategyKind = strings.TrimSpace(d.StrategyKind)
|
||||
d.CoinSourceIntent = strings.TrimSpace(d.CoinSourceIntent)
|
||||
d.Timeframe = strings.ToLower(strings.TrimSpace(d.Timeframe))
|
||||
if d.Leverage < 0 {
|
||||
d.Leverage = 0
|
||||
}
|
||||
if len(d.Symbols) > 0 {
|
||||
normalized := make([]string, 0, len(d.Symbols))
|
||||
for _, symbol := range d.Symbols {
|
||||
symbol = normalizeCoinSymbol(symbol)
|
||||
if symbol != "" {
|
||||
normalized = append(normalized, symbol)
|
||||
}
|
||||
}
|
||||
d.Symbols = cleanStringList(normalized)
|
||||
}
|
||||
if len(d.Symbols) > 0 && d.CoinSourceIntent == "" {
|
||||
d.CoinSourceIntent = "static"
|
||||
}
|
||||
if d.CoinSourceIntent == "static" && len(d.Symbols) == 0 {
|
||||
d.CoinSourceIntent = ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func marshalStrategyDraft(d strategyDraft) string {
|
||||
d = normalizeStrategyDraft(d)
|
||||
raw, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func unmarshalStrategyDraft(raw string) strategyDraft {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return strategyDraft{}
|
||||
}
|
||||
var d strategyDraft
|
||||
if err := json.Unmarshal([]byte(raw), &d); err != nil {
|
||||
return strategyDraft{}
|
||||
}
|
||||
return normalizeStrategyDraft(d)
|
||||
}
|
||||
|
||||
func buildStrategyDraftFromActiveSession(session ActiveSkillSession) strategyDraft {
|
||||
d := strategyDraft{}
|
||||
if value, ok := session.CollectedFields[strategyCreateDraftIntentField]; ok {
|
||||
d = unmarshalStrategyDraft(activeFieldString(value))
|
||||
}
|
||||
if value, ok := session.CollectedFields["name"]; ok {
|
||||
d.Name = activeFieldString(value)
|
||||
}
|
||||
d = applyStrategyDraftText(d, session.Goal)
|
||||
for i, msg := range session.LocalHistory {
|
||||
if msg.Role != "user" {
|
||||
continue
|
||||
}
|
||||
d = applyStrategyDraftText(d, msg.Content)
|
||||
if d.Name == "" && i > 0 && activeHistoryMessageAsksStrategyName(session.LocalHistory[i-1].Content) {
|
||||
d.Name = inferStandaloneStrategyName(msg.Content)
|
||||
}
|
||||
}
|
||||
return normalizeStrategyDraft(d)
|
||||
}
|
||||
|
||||
func applyStrategyDraftText(d strategyDraft, text string) strategyDraft {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return normalizeStrategyDraft(d)
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
if containsAny(lower, []string{"趋势", "trend"}) {
|
||||
d.StrategyKind = "trend"
|
||||
}
|
||||
if d.Name == "" {
|
||||
if value := extractDelimitedSegmentAfterKeywords(text, []string{"取名为", "取名叫", "命名为", "名称叫", "名字叫", "名为", "叫做", "取名", "名称", "名字是", "called"}); value != "" {
|
||||
d.Name = value
|
||||
}
|
||||
}
|
||||
if containsAny(lower, []string{"ai500"}) {
|
||||
d.CoinSourceIntent = "ai500"
|
||||
}
|
||||
if symbols := extractStrategyDraftSymbols(text); len(symbols) > 0 {
|
||||
d.Symbols = symbols
|
||||
d.CoinSourceIntent = "static"
|
||||
}
|
||||
if timeframes := extractTimeframes(text); len(timeframes) > 0 {
|
||||
d.Timeframe = timeframes[0]
|
||||
}
|
||||
if leverage, ok := extractLabeledInt(text, []string{"杠杆", "leverage"}); ok && leverage > 0 {
|
||||
d.Leverage = leverage
|
||||
} else if leverage := extractCompactLeverage(text); leverage > 0 {
|
||||
d.Leverage = leverage
|
||||
}
|
||||
return normalizeStrategyDraft(d)
|
||||
}
|
||||
|
||||
func activeHistoryMessageAsksStrategyName(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
return containsAny(lower, []string{"策略名", "名称", "名字", "叫什么", "name"})
|
||||
}
|
||||
|
||||
func inferStandaloneStrategyName(text string) string {
|
||||
value := strings.TrimSpace(text)
|
||||
if value == "" || len([]rune(value)) > 50 {
|
||||
return ""
|
||||
}
|
||||
if strategyCreateConfirmationReply(value) || strategyCreateDefaultConfigReply(value) || isCancelSkillReply(value) {
|
||||
return ""
|
||||
}
|
||||
if parseStrategyTypeValue(value) != "" {
|
||||
return ""
|
||||
}
|
||||
if containsAny(strings.ToLower(value), []string{"创建", "grid_trading", "ai_trading"}) {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func extractStrategyDraftSymbols(text string) []string {
|
||||
upper := strings.ToUpper(text)
|
||||
candidates := []string{
|
||||
"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK",
|
||||
"PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF",
|
||||
"NEAR", "ATOM", "MATIC", "INJ", "AAVE", "UNI", "LDO", "MKR", "CRV",
|
||||
}
|
||||
found := make([]string, 0, 4)
|
||||
for _, match := range compactCoinPairRE.FindAllStringSubmatch(upper, -1) {
|
||||
if len(match) >= 3 {
|
||||
found = append(found, match[1], match[2])
|
||||
}
|
||||
}
|
||||
for _, symbol := range candidates {
|
||||
if strings.Contains(upper, symbol+"USDT") || strings.Contains(upper, symbol+"USD") || strings.Contains(upper, symbol) {
|
||||
found = append(found, symbol)
|
||||
}
|
||||
}
|
||||
return cleanStringList(symbolsToUSDT(found))
|
||||
}
|
||||
|
||||
func symbolsToUSDT(symbols []string) []string {
|
||||
out := make([]string, 0, len(symbols))
|
||||
for _, symbol := range symbols {
|
||||
symbol = normalizeCoinSymbol(symbol)
|
||||
if symbol != "" {
|
||||
out = append(out, symbol)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractCompactLeverage(text string) int {
|
||||
lower := strings.ToLower(text)
|
||||
for _, marker := range []string{"x", "倍"} {
|
||||
idx := strings.Index(lower, marker)
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
prefix := lower[:idx]
|
||||
matches := firstIntegerPattern.FindAllString(prefix, -1)
|
||||
if len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(matches[len(matches)-1])
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func applyStrategyDraftToConfig(cfg *store.StrategyConfig, draft strategyDraft) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
draft = normalizeStrategyDraft(draft)
|
||||
changed := make([]string, 0, 4)
|
||||
if draft.StrategyKind != "" {
|
||||
cfg.StrategyType = "ai_trading"
|
||||
changed = append(changed, "strategy_kind")
|
||||
}
|
||||
switch draft.CoinSourceIntent {
|
||||
case "static":
|
||||
if len(draft.Symbols) > 0 {
|
||||
cfg.CoinSource.SourceType = "static"
|
||||
cfg.CoinSource.StaticCoins = append([]string(nil), draft.Symbols...)
|
||||
cfg.CoinSource.UseAI500 = false
|
||||
cfg.CoinSource.UseOITop = false
|
||||
cfg.CoinSource.UseOILow = false
|
||||
changed = append(changed, "symbols")
|
||||
}
|
||||
case "ai500":
|
||||
cfg.CoinSource.SourceType = "ai500"
|
||||
cfg.CoinSource.UseAI500 = true
|
||||
if cfg.CoinSource.AI500Limit <= 0 {
|
||||
cfg.CoinSource.AI500Limit = 3
|
||||
}
|
||||
changed = append(changed, "coin_source")
|
||||
}
|
||||
if draft.Timeframe != "" {
|
||||
cfg.Indicators.Klines.PrimaryTimeframe = draft.Timeframe
|
||||
if len(cfg.Indicators.Klines.SelectedTimeframes) == 0 {
|
||||
cfg.Indicators.Klines.SelectedTimeframes = []string{draft.Timeframe}
|
||||
} else if !containsString(cfg.Indicators.Klines.SelectedTimeframes, draft.Timeframe) {
|
||||
cfg.Indicators.Klines.SelectedTimeframes = append([]string{draft.Timeframe}, cfg.Indicators.Klines.SelectedTimeframes...)
|
||||
}
|
||||
changed = append(changed, "timeframe")
|
||||
}
|
||||
if draft.Leverage > 0 {
|
||||
cfg.RiskControl.BTCETHMaxLeverage = draft.Leverage
|
||||
cfg.RiskControl.AltcoinMaxLeverage = draft.Leverage
|
||||
changed = append(changed, "leverage")
|
||||
}
|
||||
return cleanStringList(changed)
|
||||
}
|
||||
@@ -1893,6 +1893,7 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
||||
IsPublic *bool `json:"is_public"`
|
||||
ConfigVisible *bool `json:"config_visible"`
|
||||
AllowClamped bool `json:"allow_clamped_update"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
@@ -1919,6 +1920,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
||||
if name == "" {
|
||||
return `{"error":"name is required for create"}`
|
||||
}
|
||||
if !args.Confirmed {
|
||||
return `{"error":"strategy create requires explicit chat confirmation before execution. Present the strategy config summary to the user and ask them to reply 确认创建; do not claim the strategy was created.","requires_confirmation":true}`
|
||||
}
|
||||
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
|
||||
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
|
||||
}
|
||||
|
||||
@@ -621,6 +621,57 @@ func TestStrategyCreateUsesConfigPatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateDraftPreservesTwoTurnNaturalLanguageRequirements(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-create-draft-two-turn.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||
|
||||
active := ActiveSkillSession{
|
||||
SessionID: "as_test",
|
||||
UserID: 1,
|
||||
SkillName: "strategy_management",
|
||||
ActionName: "create",
|
||||
Goal: "真的去创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍",
|
||||
CollectedFields: map[string]any{
|
||||
"name": "BTCETH_15m_趋势",
|
||||
},
|
||||
LocalHistory: []chatMessage{
|
||||
{Role: "user", Content: "真的去创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍"},
|
||||
{Role: "assistant", Content: "现在只差一个名称。"},
|
||||
{Role: "user", Content: "BTCETH_15m_趋势"},
|
||||
},
|
||||
}
|
||||
session := activeToLegacySkillSession(active)
|
||||
reply := a.handleStrategyCreateSkill("default", 1, "zh", "BTCETH_15m_趋势", session)
|
||||
if !strings.Contains(reply, "已创建策略") {
|
||||
t.Fatalf("expected created reply, got: %s", reply)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List("default")
|
||||
if err != nil {
|
||||
t.Fatalf("list strategies: %v", err)
|
||||
}
|
||||
if len(strategies) != 1 {
|
||||
t.Fatalf("expected one strategy, got %d", len(strategies))
|
||||
}
|
||||
var cfg store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(strategies[0].Config), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
if cfg.CoinSource.SourceType != "static" || cfg.CoinSource.UseAI500 || strings.Join(cfg.CoinSource.StaticCoins, ",") != "BTCUSDT,ETHUSDT" {
|
||||
t.Fatalf("expected static BTC/ETH and AI500 disabled, got %+v", cfg.CoinSource)
|
||||
}
|
||||
if cfg.Indicators.Klines.PrimaryTimeframe != "15m" {
|
||||
t.Fatalf("expected primary timeframe 15m, got %s", cfg.Indicators.Klines.PrimaryTimeframe)
|
||||
}
|
||||
if cfg.RiskControl.BTCETHMaxLeverage != 5 || cfg.RiskControl.AltcoinMaxLeverage != 5 {
|
||||
t.Fatalf("expected leverage 5x, got btceth=%d alt=%d", cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateAsksTypeBeforeUsingDefaultTemplateType(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-create-ask-type.db")
|
||||
st, err := store.New(dbPath)
|
||||
@@ -652,7 +703,59 @@ func TestStrategyCreateAsksTypeBeforeUsingDefaultTemplateType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateWaitsForGridConfigBeforeCreate(t *testing.T) {
|
||||
func TestStrategyCreateConfirmationStillRequiresType(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-create-confirm-no-type.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||
|
||||
session := skillSession{
|
||||
Name: "strategy_management",
|
||||
Action: "create",
|
||||
Fields: map[string]string{
|
||||
"name": "我的策略",
|
||||
},
|
||||
}
|
||||
|
||||
reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session)
|
||||
if !strings.Contains(reply, "先选择策略类型") {
|
||||
t.Fatalf("expected type question before create, got: %s", reply)
|
||||
}
|
||||
strategies, err := st.Strategy().List("default")
|
||||
if err != nil {
|
||||
t.Fatalf("list strategies: %v", err)
|
||||
}
|
||||
for _, strategy := range strategies {
|
||||
if strategy.Name == "我的策略" {
|
||||
t.Fatalf("strategy should not be created before type is known")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateStandaloneNameCanContainStrategyWord(t *testing.T) {
|
||||
active := ActiveSkillSession{
|
||||
SessionID: "as_test",
|
||||
UserID: 1,
|
||||
SkillName: "strategy_management",
|
||||
ActionName: "create",
|
||||
Goal: "创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍",
|
||||
CollectedFields: map[string]any{},
|
||||
LocalHistory: []chatMessage{
|
||||
{Role: "user", Content: "创建一个趋势策略,交易BTC和ETH,15m,杠杆 5 倍"},
|
||||
{Role: "assistant", Content: "现在只差一个名称。"},
|
||||
{Role: "user", Content: "趋势策略A"},
|
||||
},
|
||||
}
|
||||
|
||||
session := activeToLegacySkillSession(active)
|
||||
if got := fieldValue(session, "name"); got != "趋势策略A" {
|
||||
t.Fatalf("expected standalone strategy name to be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateProposesGridDefaultsBeforeCreate(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-draft.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
@@ -670,8 +773,8 @@ func TestStrategyCreateWaitsForGridConfigBeforeCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
reply := a.handleStrategyCreateSkill("default", 1, "zh", "grid_trading", session)
|
||||
if !strings.Contains(reply, "先不创建空模板") || !strings.Contains(reply, "交易对") {
|
||||
t.Fatalf("expected grid config collection prompt, got: %s", reply)
|
||||
if !strings.Contains(reply, "配置整理好了") || !strings.Contains(reply, "BTCUSDT") || !strings.Contains(reply, "ATR 动态范围") {
|
||||
t.Fatalf("expected grid default confirmation draft, got: %s", reply)
|
||||
}
|
||||
strategies, err := st.Strategy().List("default")
|
||||
if err != nil {
|
||||
@@ -684,6 +787,63 @@ func TestStrategyCreateWaitsForGridConfigBeforeCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateConfirmationFillsMissingGridDefaults(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-confirm-defaults.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||
|
||||
session := skillSession{
|
||||
Name: "strategy_management",
|
||||
Action: "create",
|
||||
Fields: map[string]string{
|
||||
"name": "餐巾纸",
|
||||
"strategy_type": "grid_trading",
|
||||
"symbol": "BTCUSDT",
|
||||
"awaiting_final_confirmation": "true",
|
||||
},
|
||||
}
|
||||
|
||||
reply := a.handleStrategyCreateSkill("default", 1, "zh", "好的,就这样", session)
|
||||
if !strings.Contains(reply, "已创建策略") {
|
||||
t.Fatalf("expected strategy to be created after confirmation with defaults, got: %s", reply)
|
||||
}
|
||||
for _, want := range []string{"总投入:1000.00 USDT", "网格数量:10", "杠杆:5倍", "价格范围:ATR 自动计算"} {
|
||||
if !strings.Contains(reply, want) {
|
||||
t.Fatalf("expected create reply to report actual defaulted value %q, got: %s", want, reply)
|
||||
}
|
||||
}
|
||||
for _, unexpected := range []string{"系统已自动收敛", "最大持仓数", "最低置信度"} {
|
||||
if strings.Contains(reply, unexpected) {
|
||||
t.Fatalf("create reply should not expose internal clamp detail %q, got: %s", unexpected, reply)
|
||||
}
|
||||
}
|
||||
strategies, err := st.Strategy().List("default")
|
||||
if err != nil {
|
||||
t.Fatalf("list strategies: %v", err)
|
||||
}
|
||||
var cfg store.StrategyConfig
|
||||
for _, strategy := range strategies {
|
||||
if strategy.Name == "餐巾纸" {
|
||||
if err := json.Unmarshal([]byte(strategy.Config), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.GridConfig == nil {
|
||||
t.Fatalf("expected grid config")
|
||||
}
|
||||
if cfg.GridConfig.Symbol != "BTCUSDT" || cfg.GridConfig.GridCount <= 0 || cfg.GridConfig.TotalInvestment <= 0 || cfg.GridConfig.Leverage <= 0 {
|
||||
t.Fatalf("expected explicit symbol and defaulted grid fields, got %+v", cfg.GridConfig)
|
||||
}
|
||||
if !cfg.GridConfig.UseATRBounds || cfg.GridConfig.Distribution == "" || cfg.GridConfig.MaxDrawdownPct <= 0 {
|
||||
t.Fatalf("expected defaulted optional grid fields, got %+v", cfg.GridConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateGridDraftSummaryDoesNotMentionAIFields(t *testing.T) {
|
||||
reply := formatStrategyCreateDraftSummary("zh", "我的网格策略", "grid_trading", nil, nil)
|
||||
for _, unexpected := range []string{"选币来源", "最大持仓", "置信度", "盈亏比", "多周期"} {
|
||||
@@ -761,6 +921,10 @@ func TestStrategyCreateReadyConfigRequiresFinalConfirmation(t *testing.T) {
|
||||
}
|
||||
|
||||
session.CollectedFields["awaiting_final_confirmation"] = true
|
||||
if _, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session); !blocked {
|
||||
t.Fatalf("same-turn awaiting flag without prior assistant confirmation should still be blocked")
|
||||
}
|
||||
session.LocalHistory = append(session.LocalHistory, chatMessage{Role: "assistant", Content: reply})
|
||||
if _, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session); blocked {
|
||||
t.Fatalf("already-confirmable session should not be blocked")
|
||||
}
|
||||
@@ -823,6 +987,34 @@ func TestStrategyCreateCreatesGridAfterConfigPatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManageStrategyToolCreateRequiresConfirmation(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-tool-create-confirmation.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||
|
||||
resp := a.toolManageStrategy("default", `{"action":"create","name":"未确认网格","lang":"zh","config":{"strategy_type":"grid_trading","grid_config":{"symbol":"BTCUSDT","total_investment":200,"use_atr_bounds":true}}}`)
|
||||
if !strings.Contains(resp, "requires_confirmation") {
|
||||
t.Fatalf("expected tool create to require confirmation, got: %s", resp)
|
||||
}
|
||||
strategies, err := st.Strategy().List("default")
|
||||
if err != nil {
|
||||
t.Fatalf("list strategies: %v", err)
|
||||
}
|
||||
for _, strategy := range strategies {
|
||||
if strategy.Name == "未确认网格" {
|
||||
t.Fatalf("unconfirmed tool call should not create strategy")
|
||||
}
|
||||
}
|
||||
|
||||
resp = a.toolManageStrategy("default", `{"action":"create","name":"已确认网格","lang":"zh","confirmed":true,"allow_clamped_update":true,"config":{"strategy_type":"grid_trading","grid_config":{"symbol":"BTCUSDT","total_investment":200,"use_atr_bounds":true}}}`)
|
||||
if strings.Contains(resp, `"error"`) {
|
||||
t.Fatalf("expected confirmed create to succeed, got: %s", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateGridPatchInfersStrategyType(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-infers-type.db")
|
||||
st, err := store.New(dbPath)
|
||||
@@ -873,6 +1065,65 @@ func TestStrategyCreateGridPatchInfersStrategyType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateGridPatchKeepsBackendGridDefaults(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-defaults.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||
|
||||
patch := map[string]any{
|
||||
"strategy_type": "grid_trading",
|
||||
"grid_config": map[string]any{
|
||||
"symbol": "ETHUSDT",
|
||||
"grid_count": 20,
|
||||
"total_investment": 500,
|
||||
"leverage": 3,
|
||||
},
|
||||
}
|
||||
rawPatch, _ := json.Marshal(patch)
|
||||
session := skillSession{
|
||||
Name: "strategy_management",
|
||||
Action: "create",
|
||||
Fields: map[string]string{
|
||||
"name": "餐巾纸",
|
||||
strategyCreateConfigPatchField: string(rawPatch),
|
||||
},
|
||||
}
|
||||
|
||||
reply := a.handleStrategyCreateSkill("default", 1, "zh", "确认创建", session)
|
||||
if !strings.Contains(reply, "已创建策略") {
|
||||
t.Fatalf("expected create reply, got: %s", reply)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List("default")
|
||||
if err != nil {
|
||||
t.Fatalf("list strategies: %v", err)
|
||||
}
|
||||
var cfg store.StrategyConfig
|
||||
for _, strategy := range strategies {
|
||||
if strategy.Name == "餐巾纸" {
|
||||
if err := json.Unmarshal([]byte(strategy.Config), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.GridConfig == nil {
|
||||
t.Fatalf("expected grid config")
|
||||
}
|
||||
if !cfg.GridConfig.UseATRBounds || cfg.GridConfig.ATRMultiplier != 2 || cfg.GridConfig.Distribution != "gaussian" {
|
||||
t.Fatalf("expected grid defaults for bounds/distribution, got %+v", cfg.GridConfig)
|
||||
}
|
||||
if cfg.GridConfig.MaxDrawdownPct != 15 || cfg.GridConfig.StopLossPct != 5 || cfg.GridConfig.DailyLossLimitPct != 10 || !cfg.GridConfig.UseMakerOnly {
|
||||
t.Fatalf("expected grid risk defaults, got %+v", cfg.GridConfig)
|
||||
}
|
||||
if cfg.GridConfig.DirectionBiasRatio != 0.7 {
|
||||
t.Fatalf("expected direction bias default, got %+v", cfg.GridConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMFlowExtractionFiltersFieldsToAllowedSchema(t *testing.T) {
|
||||
result := llmFlowExtractionResult{
|
||||
Intent: "continue",
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestExecuteUnifiedTurnDecisionContinueActiveDoesNotHandOffToPlanner(t *test
|
||||
if !handled {
|
||||
t.Fatal("expected active session continuation to be handled")
|
||||
}
|
||||
if !strings.Contains(answer, "先不创建空模板") || strings.Contains(answer, "交易机器人") || strings.Contains(answer, "AI模型和交易所") {
|
||||
if !strings.Contains(answer, "配置整理好了") || !strings.Contains(answer, "BTCUSDT") || strings.Contains(answer, "交易机器人") || strings.Contains(answer, "AI模型和交易所") {
|
||||
t.Fatalf("expected strategy session to continue without planner/trader handoff, got: %s", answer)
|
||||
}
|
||||
if _, ok := a.getActiveSkillSession(userID); !ok {
|
||||
@@ -198,6 +198,28 @@ func TestGuardUnexecutedActiveTaskCompletionBlocksCreationClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardUnsupportedAsyncPromiseBlocksFakeDiagnosisProgress(t *testing.T) {
|
||||
reply, blocked := guardUnsupportedAsyncPromise("zh", "诊断还在进行中,请再稍等一下。我马上分析完“小小”的历史交易记录,找到亏损原因后会立刻告诉您。")
|
||||
if !blocked {
|
||||
t.Fatal("expected fake async diagnosis progress to be blocked")
|
||||
}
|
||||
for _, want := range []string{"没有后台异步任务", "当前回复"} {
|
||||
if !strings.Contains(reply, want) {
|
||||
t.Fatalf("expected guarded reply to contain %q, got: %s", want, reply)
|
||||
}
|
||||
}
|
||||
|
||||
_, blocked = guardUnsupportedAsyncPromise("zh", "我需要策略名称和历史记录范围,才能开始诊断。")
|
||||
if blocked {
|
||||
t.Fatal("missing-info diagnosis reply should not be blocked")
|
||||
}
|
||||
|
||||
_, blocked = guardUnsupportedAsyncPromise("zh", "好的,参数已确认,正在为您创建“餐巾纸”网格策略。")
|
||||
if !blocked {
|
||||
t.Fatal("expected fake async strategy create progress to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) {
|
||||
a := New(nil, nil, DefaultConfig(), nil)
|
||||
systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(42, "zh", "不是交易员,是策略")
|
||||
|
||||
Reference in New Issue
Block a user