mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Refine agent strategy routing and config handling
This commit is contained in:
@@ -559,9 +559,10 @@ func (a *Agent) buildSystemPrompt(lang string) string {
|
||||
### 配置、策略与交易员管理规则
|
||||
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
|
||||
- **策略模板本身是独立资源,不默认依赖交易所或 AI 模型**
|
||||
- **策略模板创建成功后应立即出现在策略列表/策略页**
|
||||
- **策略模板不能直接启动或运行;只有交易员有运行态。**
|
||||
- 如果用户说“启动策略 / 运行策略”,要明确说明:应先把策略绑定到交易员,再启动交易员
|
||||
- 只有当用户要求“运行策略 / 创建交易员 / 把策略部署到账户”时,才需要进一步关联交易所、模型或 trader
|
||||
- 用户没问运行/部署/创建交易员时,不要主动延伸到交易员、模型或交易所绑定
|
||||
- 当用户要求配置交易所、绑定 API Key、修改交易所账户时,优先使用 manage_exchange_config
|
||||
- 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时,优先使用 manage_model_config
|
||||
- 当用户要求创建、修改、删除、启动、停止交易员时,优先使用 manage_trader
|
||||
@@ -646,9 +647,10 @@ You can call these tools to take action:
|
||||
### Configuration, Strategy, and Trader Rules
|
||||
- When the user wants to create, edit, delete, activate, or duplicate a strategy template, prefer get_strategies / manage_strategy
|
||||
- **A strategy template is an independent asset and does not require exchange or model bindings by default**
|
||||
- **After creation, a strategy template should immediately appear in the strategy list/page**
|
||||
- **A strategy template cannot be started or run directly; only traders have runtime state**
|
||||
- If the user says "start the strategy" or "run this strategy", explain that the strategy must be attached to a trader first, then the trader can be started
|
||||
- Only ask for exchange/model/trader details when the user wants to run, deploy, or attach a strategy to a trader
|
||||
- Do not proactively bring up traders, models, or exchange bindings unless the user asks to run, deploy, or create a trader
|
||||
- When the user wants to bind or edit an exchange account, prefer manage_exchange_config
|
||||
- When the user wants to bind or edit an AI model, prefer manage_model_config
|
||||
- When the user wants to create, edit, delete, start, or stop a trader, prefer manage_trader
|
||||
|
||||
@@ -52,3 +52,51 @@ func TestAIServiceFailureHighlightsUpstreamEmptyOutputRateLimit(t *testing.T) {
|
||||
t.Fatalf("upstream empty output should not use the generic balance/auth/timeout guidance: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedPlanFallbackDoesNotExposeFinalSummaryFailure(t *testing.T) {
|
||||
msg := formatCompletedPlanFallback("zh", []PlanStep{
|
||||
{
|
||||
Type: planStepTypeTool,
|
||||
Status: planStepStatusCompleted,
|
||||
Title: "创建名为 eeg 的策略",
|
||||
},
|
||||
})
|
||||
if msg == "" {
|
||||
t.Fatalf("expected fallback message")
|
||||
}
|
||||
for _, bad := range []string{"失败", "AI", "稍后"} {
|
||||
if strings.Contains(msg, bad) {
|
||||
t.Fatalf("fallback should not expose final summary failure %q: %s", bad, msg)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") {
|
||||
t.Fatalf("fallback should summarize completed work, got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicCompletedPlanResponseSkipsLLMForSimpleConfirmation(t *testing.T) {
|
||||
state := ExecutionState{
|
||||
Steps: []PlanStep{
|
||||
{
|
||||
ID: "create_strategy",
|
||||
Type: planStepTypeTool,
|
||||
Status: planStepStatusCompleted,
|
||||
Title: "创建名为 eeg 的策略",
|
||||
},
|
||||
{
|
||||
ID: "respond",
|
||||
Type: planStepTypeRespond,
|
||||
Status: planStepStatusRunning,
|
||||
Title: "策略创建成功",
|
||||
Instruction: "确认策略创建成功",
|
||||
},
|
||||
},
|
||||
}
|
||||
msg := deterministicCompletedPlanResponse("zh", state, state.Steps[1])
|
||||
if msg == "" {
|
||||
t.Fatalf("expected deterministic response")
|
||||
}
|
||||
if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") {
|
||||
t.Fatalf("unexpected deterministic response: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,16 +260,6 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us
|
||||
}
|
||||
|
||||
func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession, onEvent func(event, data string)) (string, bool, error) {
|
||||
if answer, ok := a.answerSkillSessionExplanation(storeUserID, lang, activeToLegacySkillSession(session), text); ok {
|
||||
session = appendActiveSessionLocalHistory(session, "user", text)
|
||||
session = appendActiveSessionLocalHistory(session, "assistant", answer)
|
||||
setActiveSessionPendingHint(&session, answer)
|
||||
a.saveActiveSkillSession(session)
|
||||
emitBrainReply(onEvent, answer)
|
||||
a.recordSkillInteraction(userID, text, answer)
|
||||
return answer, true, nil
|
||||
}
|
||||
|
||||
session = appendActiveSessionLocalHistory(session, "user", text)
|
||||
clearActiveSessionPendingHint(&session)
|
||||
|
||||
@@ -286,7 +276,6 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
|
||||
stepDecision.Route = "execute_skill"
|
||||
}
|
||||
}
|
||||
|
||||
switch stepDecision.Route {
|
||||
case "cancel_task":
|
||||
a.clearActiveSkillSession(userID)
|
||||
@@ -299,8 +288,16 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
|
||||
return reply, true, nil
|
||||
|
||||
case "finish_task":
|
||||
a.clearActiveSkillSession(userID)
|
||||
reply := strings.TrimSpace(stepDecision.Reply)
|
||||
if guarded, blocked := guardUnexecutedActiveTaskCompletion(lang, session, reply); blocked {
|
||||
session = appendActiveSessionLocalHistory(session, "assistant", guarded)
|
||||
setActiveSessionPendingHint(&session, guarded)
|
||||
a.saveActiveSkillSession(session)
|
||||
emitBrainReply(onEvent, guarded)
|
||||
a.recordSkillInteraction(userID, text, guarded)
|
||||
return guarded, true, nil
|
||||
}
|
||||
a.clearActiveSkillSession(userID)
|
||||
if reply == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -325,6 +322,27 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
|
||||
return reply, true, nil
|
||||
|
||||
case "execute_skill":
|
||||
var repairReply string
|
||||
var canExecute bool
|
||||
session, repairReply, canExecute = a.ensureStrategyCreateExecutableState(ctx, lang, text, session)
|
||||
if !canExecute {
|
||||
repairReply = defaultIfEmpty(repairReply, a.askForMissingFields(lang, session))
|
||||
session = appendActiveSessionLocalHistory(session, "assistant", repairReply)
|
||||
setActiveSessionPendingHint(&session, repairReply)
|
||||
a.saveActiveSkillSession(session)
|
||||
emitBrainReply(onEvent, repairReply)
|
||||
a.recordSkillInteraction(userID, text, repairReply)
|
||||
return repairReply, true, nil
|
||||
}
|
||||
if guarded, blocked := guardStrategyCreateBeforeFinalConfirmation(lang, session); blocked {
|
||||
session.CollectedFields["awaiting_final_confirmation"] = true
|
||||
session = appendActiveSessionLocalHistory(session, "assistant", guarded)
|
||||
setActiveSessionPendingHint(&session, guarded)
|
||||
a.saveActiveSkillSession(session)
|
||||
emitBrainReply(onEvent, guarded)
|
||||
a.recordSkillInteraction(userID, text, guarded)
|
||||
return guarded, true, nil
|
||||
}
|
||||
outcome, nextSession, pending, ok := a.executeActiveSkillSession(storeUserID, userID, lang, text, session)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
@@ -366,6 +384,174 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) ensureStrategyCreateExecutableState(ctx context.Context, lang, text string, session ActiveSkillSession) (ActiveSkillSession, string, bool) {
|
||||
if session.SkillName != "strategy_management" || session.ActionName != "create" {
|
||||
return session, "", true
|
||||
}
|
||||
if strategyCreateSessionReady(lang, session) {
|
||||
return session, "", true
|
||||
}
|
||||
if a.aiClient == nil {
|
||||
return session, "", true
|
||||
}
|
||||
|
||||
legacy := activeToLegacySkillSession(session)
|
||||
collectedJSON, _ := json.Marshal(session.CollectedFields)
|
||||
fieldSpecsJSON, _ := json.Marshal(allowedFieldSpecsForSkillSession(legacy, lang))
|
||||
history := formatActiveSessionLocalHistory(session.LocalHistory)
|
||||
if history == "" {
|
||||
history = "(empty)"
|
||||
}
|
||||
systemPrompt := prependNOFXiAdvisorPreamble(`You repair structured state for one active NOFXi strategy creation task.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
Return shape:
|
||||
{"route":"ready|ask_user","reply":"","extracted_data":{}}`)
|
||||
userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nCurrent collected fields JSON:\n%s\n\nAllowed field spec JSON:\n%s\n\nActive task history:\n%s", lang, text, string(collectedJSON), string(fieldSpecsJSON), history)
|
||||
|
||||
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
|
||||
defer cancel()
|
||||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: stageCtx,
|
||||
})
|
||||
if err != nil {
|
||||
return session, "", false
|
||||
}
|
||||
decision, ok := parseStrategyCreateStateRepairDecision(raw)
|
||||
if !ok {
|
||||
return session, "", false
|
||||
}
|
||||
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
|
||||
mergeExtractedData(&session, decision.ExtractedData)
|
||||
if decision.Route == "ask_user" {
|
||||
return session, strings.TrimSpace(decision.Reply), false
|
||||
}
|
||||
if strategyCreateSessionReady(lang, session) {
|
||||
return session, strings.TrimSpace(decision.Reply), true
|
||||
}
|
||||
return session, strings.TrimSpace(decision.Reply), false
|
||||
}
|
||||
|
||||
type strategyCreateStateRepairDecision struct {
|
||||
Route string `json:"route"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
ExtractedData map[string]any `json:"extracted_data,omitempty"`
|
||||
}
|
||||
|
||||
func parseStrategyCreateStateRepairDecision(raw string) (strategyCreateStateRepairDecision, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
var d strategyCreateStateRepairDecision
|
||||
if err := json.Unmarshal([]byte(raw), &d); err != nil {
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start < 0 || end <= start {
|
||||
return strategyCreateStateRepairDecision{}, false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil {
|
||||
return strategyCreateStateRepairDecision{}, false
|
||||
}
|
||||
}
|
||||
d.Route = strings.ToLower(strings.TrimSpace(d.Route))
|
||||
d.Reply = strings.TrimSpace(d.Reply)
|
||||
switch d.Route {
|
||||
case "ready", "ask_user":
|
||||
return d, true
|
||||
default:
|
||||
return strategyCreateStateRepairDecision{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func strategyCreateSessionReady(lang string, session ActiveSkillSession) bool {
|
||||
legacy := activeToLegacySkillSession(session)
|
||||
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ready, _ := strategyCreateConfigReady(legacy, cfg, "")
|
||||
return ready
|
||||
}
|
||||
|
||||
func guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkillSession) (string, bool) {
|
||||
if session.SkillName != "strategy_management" || session.ActionName != "create" {
|
||||
return "", false
|
||||
}
|
||||
if activeFieldBool(session.CollectedFields["awaiting_final_confirmation"]) {
|
||||
return "", false
|
||||
}
|
||||
legacy := activeToLegacySkillSession(session)
|
||||
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if ready, _ := strategyCreateConfigReady(legacy, cfg, ""); !ready {
|
||||
return "", false
|
||||
}
|
||||
return formatStrategyCreateFinalConfirmation(lang, legacy, cfg), true
|
||||
}
|
||||
|
||||
func activeFieldBool(v any) bool {
|
||||
switch typed := v.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(typed), "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func guardUnexecutedActiveTaskCompletion(lang string, session ActiveSkillSession, reply string) (string, bool) {
|
||||
if !isMutatingActiveTask(session) || !looksLikeCompletionClaim(reply) {
|
||||
return "", false
|
||||
}
|
||||
if lang == "zh" {
|
||||
if session.SkillName == "strategy_management" {
|
||||
return "还没有真正创建到策略列表里。刚才只是整理/确认配置方案;需要继续的话,我会先用结构化配置调用策略创建工具,再基于真实结果回复。", true
|
||||
}
|
||||
return "还没有真正执行完成。刚才只是继续当前配置流程;需要实际执行时,我会调用对应工具后再基于真实结果回复。", true
|
||||
}
|
||||
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 isMutatingActiveTask(session ActiveSkillSession) bool {
|
||||
if strings.TrimSpace(session.SkillName) == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(session.ActionName) {
|
||||
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
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeCompletionClaim(reply string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(reply))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{
|
||||
"已创建", "创建好了", "创建好", "已经创建", "已更新", "更新好了", "已修改", "已删除", "已启动", "已停止", "已激活", "已复制", "已经完成", "已完成",
|
||||
"created", "has been created", "updated", "deleted", "started", "stopped", "activated", "duplicated", "completed",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) planActiveSessionStep(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (activeSessionStepDecision, bool) {
|
||||
if a.aiClient == nil {
|
||||
return activeSessionStepDecision{}, false
|
||||
@@ -415,12 +601,21 @@ 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/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.
|
||||
- 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.
|
||||
- If the current message is only a greeting, thanks, acknowledgement, or small talk and does not add task information, do NOT continue task execution. Choose "ask_user" only if you need to gently restate what is pending; otherwise choose "finish_task" with a short social reply.
|
||||
- Ask naturally. Do not say raw slot names like target_ref unless the user explicitly asks for internal details.
|
||||
- If the user clearly means a bulk destructive operation like "删除所有策略", "全部删除策略", "all strategies", set extracted_data to {"bulk_scope":"all"} and choose "execute_skill". Do not ask for target_ref.
|
||||
- If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it.
|
||||
- Current references are context for reasoning only. Do not copy a current reference into target_ref_id/target_ref_name unless the user explicitly refers to that object by name/id or clearly says "this/current/that previous one". If the target is not clear, ask instead of executing.
|
||||
- For trader bindings, exchange/model/strategy must resolve to an ID from Relevant disclosed resources before execution. Never invent a resource name or use a generic venue type like Binance/OKX as the bound exchange unless it appears as an actual disclosed resource.
|
||||
- For strategy_management:create, do not ask for exchange accounts or model bindings. Strategy templates are independent drafts/configs; exchange/model are only needed when creating, deploying, or starting a trader.
|
||||
- Strategy templates should be visible in the strategy list/page after creation. Do not bring up trader/model/exchange binding unless the user asks to run or deploy.
|
||||
- For strategy_management:create or strategy_management:update_config, when the user describes strategy intent, output config_patch as a partial StrategyConfig JSON object instead of leaving the default template unchanged. Example: "BTC趋势做空" should set coin_source to static BTCUSDT and add prompt/risk/entry rules for BTC trend-following short bias.
|
||||
- If there are multiple targets and the user did not disambiguate, ask a natural question with the available names.
|
||||
- If the current user message answers a missing field directly, extract it and continue.
|
||||
@@ -623,7 +818,15 @@ func (a *Agent) buildActiveSessionResources(storeUserID string, session skillSes
|
||||
case "model_management":
|
||||
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadEnabledModelOptions(storeUserID))
|
||||
case "strategy_management":
|
||||
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadStrategyOptions(storeUserID))
|
||||
resources := a.buildSimpleEntityConversationResources(storeUserID, session, a.loadStrategyOptions(storeUserID))
|
||||
if strategyType := explicitStrategyCreateType(session); strategyType != "" {
|
||||
resources["current_strategy_type"] = strategyType
|
||||
resources["current_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType)
|
||||
} else if strategyType, ok := a.strategyTypeForTarget(storeUserID, session.TargetRef); ok {
|
||||
resources["target_strategy_type"] = strategyType
|
||||
resources["target_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType)
|
||||
}
|
||||
return resources
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -246,55 +246,6 @@ func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolManageStrategyReportsChangedAndRejectedFields(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-change-summary.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":"高频-短线ETH","config":{"coin_source":{"source_type":"static","static_coins":["ETHUSDT"]},"indicators":{"klines":{"primary_timeframe":"1m","selected_timeframes":["1m","3m"]}},"order_execution_speed":"fast"}}`)
|
||||
if strings.Contains(resp, `"error"`) {
|
||||
t.Fatalf("expected create to succeed with rejected unknown fields, got: %s", resp)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"created_strategy_id"`,
|
||||
`"changed_fields"`,
|
||||
`coin_source.source_type`,
|
||||
`indicators.klines.primary_timeframe`,
|
||||
`"rejected_fields"`,
|
||||
`order_execution_speed (not in current strategy config)`,
|
||||
`"unchanged_defaults"`,
|
||||
} {
|
||||
if !strings.Contains(resp, want) {
|
||||
t.Fatalf("expected response to contain %q, got: %s", want, resp)
|
||||
}
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List("default")
|
||||
if err != nil {
|
||||
t.Fatalf("list strategies: %v", err)
|
||||
}
|
||||
var created *store.Strategy
|
||||
for _, strategy := range strategies {
|
||||
if strategy.Name == "高频-短线ETH" {
|
||||
created = strategy
|
||||
break
|
||||
}
|
||||
}
|
||||
if created == nil {
|
||||
t.Fatalf("expected strategy to be created")
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil {
|
||||
t.Fatalf("parse config: %v", err)
|
||||
}
|
||||
if _, ok := cfg["order_execution_speed"]; ok {
|
||||
t.Fatalf("unknown field should not be persisted: %s", created.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
|
||||
st, err := store.New(dbPath)
|
||||
@@ -423,6 +374,63 @@ func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyVisibleFieldSummaryUsesTargetStrategyType(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-type-field-summary.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
cfg := store.GetDefaultStrategyConfig("zh")
|
||||
cfg.StrategyType = "grid_trading"
|
||||
cfg.GridConfig = &store.GridStrategyConfig{
|
||||
Symbol: "ETHUSDT",
|
||||
GridCount: 12,
|
||||
TotalInvestment: 1000,
|
||||
Leverage: 3,
|
||||
UseATRBounds: true,
|
||||
ATRMultiplier: 2,
|
||||
Distribution: "gaussian",
|
||||
}
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal strategy config: %v", err)
|
||||
}
|
||||
strategy := &store.Strategy{
|
||||
ID: "strategy-grid-fields",
|
||||
UserID: "default",
|
||||
Name: "我的第一个网格策略",
|
||||
Description: "",
|
||||
IsPublic: false,
|
||||
ConfigVisible: true,
|
||||
Config: string(raw),
|
||||
}
|
||||
if err := st.Strategy().Create(strategy); err != nil {
|
||||
t.Fatalf("create strategy: %v", err)
|
||||
}
|
||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||
|
||||
session := skillSession{
|
||||
Name: "strategy_management",
|
||||
Action: "update_config",
|
||||
TargetRef: &EntityReference{
|
||||
ID: strategy.ID,
|
||||
Name: strategy.Name,
|
||||
},
|
||||
}
|
||||
resources := a.buildActiveSessionResources("default", session)
|
||||
if got := resources["target_strategy_type"]; got != "grid_trading" {
|
||||
t.Fatalf("expected grid strategy type in resources, got: %#v", got)
|
||||
}
|
||||
fields, ok := resources["target_editable_fields"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected editable field list in resources, got: %#v", resources["target_editable_fields"])
|
||||
}
|
||||
joined := strings.Join(fields, ",")
|
||||
if !strings.Contains(joined, "symbol") || strings.Contains(joined, "source_type") {
|
||||
t.Fatalf("expected grid-only editable fields in resources, got: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillVisibleFieldSummaryForTraderMatchesManualPanelFields(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "trader-field-summary.db")
|
||||
st, err := store.New(dbPath)
|
||||
|
||||
@@ -252,7 +252,19 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
|
||||
add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false)
|
||||
case "strategy_management":
|
||||
if session.Action == "create" || session.Action == "update_config" {
|
||||
add(&out, "config_patch", "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use this for strategy requirements such as target coins, trend style, short/long bias, indicators, risk, timeframes, and prompt sections.", false)
|
||||
configPatchDescription := "Partial StrategyConfig JSON patch inferred from the user's strategy intent."
|
||||
switch explicitStrategyCreateType(session) {
|
||||
case "grid_trading":
|
||||
configPatchDescription += " Current strategy_type is grid_trading: use only grid_config and publish/common fields; do not use coin source, indicators, timeframes, confidence, or prompt-section fields."
|
||||
case "ai_trading":
|
||||
configPatchDescription += " Current strategy_type is ai_trading: use coin source, indicators, risk, timeframes, and prompt sections; do not use grid_config fields."
|
||||
default:
|
||||
configPatchDescription += " Include strategy_type first when the user chooses AI or grid; after strategy_type is known, use only fields for that type."
|
||||
}
|
||||
add(&out, "config_patch", configPatchDescription, false)
|
||||
}
|
||||
if session.Action == "create" {
|
||||
add(&out, "awaiting_final_confirmation", "Set true only after you have produced a final user-facing creation summary from the current structured config and are waiting for the user's final confirmation before executing create.", false)
|
||||
}
|
||||
if session.Action == "update_prompt" {
|
||||
add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false)
|
||||
@@ -263,7 +275,11 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
|
||||
add(&out, "config_value", strategyConfigFieldDisplayName("config_value", lang), false)
|
||||
}
|
||||
add(&out, "name", slotDisplayName("name", lang), true)
|
||||
for _, key := range manualStrategyEditableFieldKeys() {
|
||||
keys := manualStrategyEditableFieldKeys()
|
||||
if strategyType := explicitStrategyCreateType(session); strategyType != "" {
|
||||
keys = manualStrategyEditableFieldKeysForType(strategyType)
|
||||
}
|
||||
for _, key := range keys {
|
||||
add(&out, key, strategyConfigFieldDisplayName(key, lang), false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,6 @@ import (
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
type llmSkillRouteDecision struct {
|
||||
Intent string `json:"intent,omitempty"`
|
||||
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
|
||||
ContextSwitch bool `json:"context_switch,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
type unifiedTurnDecision struct {
|
||||
TopicIntent string `json:"topic_intent,omitempty"`
|
||||
BusinessAction string `json:"business_action,omitempty"`
|
||||
@@ -43,50 +36,6 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
|
||||
return answer, handled, execErr
|
||||
}
|
||||
}
|
||||
|
||||
decision, ok, err := a.routeTurnWithLLM(ctx, userID, lang, text)
|
||||
if err != nil || !ok {
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
|
||||
switch decision.Intent {
|
||||
case "continue", "continue_active":
|
||||
if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) {
|
||||
return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
case "cancel":
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
a.clearActiveSkillSession(userID)
|
||||
a.clearAnyActiveContext(userID)
|
||||
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
case "resume_snapshot":
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) {
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
return "", false, nil
|
||||
case "instant_reply":
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
|
||||
}
|
||||
if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, onEvent); ok {
|
||||
return answer, true, nil
|
||||
}
|
||||
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
return answer, true, err
|
||||
}
|
||||
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, decision.TargetSnapshotID) {
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
|
||||
@@ -272,6 +221,8 @@ context_mode values:
|
||||
|
||||
Rules:
|
||||
- This router decides what context downstream LLMs will see. Be conservative with stale references.
|
||||
- Treat topic_intent as the primary decision. If the user is naturally responding to the active flow, choose topic_intent="continue_active", business_action="continue_skill", context_mode="use_current"; do not hand off a continuing active flow to planned_agent.
|
||||
- When an active flow has a previous assistant question, proposal, or confirmation request, reason about what the user's message refers to in that context before deciding it is a new task.
|
||||
- If the user clearly switches domain/entity, set topic_intent="start_new" and context_mode="fresh_context".
|
||||
- If the user says "不是交易员,是策略" or similar corrections, use fresh_context.
|
||||
- If the user answers the previous assistant question, choose continue_active.
|
||||
@@ -336,6 +287,14 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri
|
||||
if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) {
|
||||
return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
if activeSession, hasActive := a.getActiveSkillSession(userID); hasActive {
|
||||
decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang)
|
||||
mergeExtractedData(&activeSession, decision.ExtractedData)
|
||||
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
|
||||
}
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
switch decision.BusinessAction {
|
||||
@@ -428,146 +387,6 @@ func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string
|
||||
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
|
||||
}
|
||||
|
||||
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var decision llmSkillRouteDecision
|
||||
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
|
||||
return normalizeLLMSkillRouteDecision(decision), nil
|
||||
}
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start >= 0 && end > start {
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
|
||||
return normalizeLLMSkillRouteDecision(decision), nil
|
||||
}
|
||||
}
|
||||
return llmSkillRouteDecision{}, fmt.Errorf("invalid llm skill route json")
|
||||
}
|
||||
|
||||
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
|
||||
decision.Intent = strings.TrimSpace(strings.ToLower(decision.Intent))
|
||||
decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID)
|
||||
if decision.Confidence < 0 {
|
||||
decision.Confidence = 0
|
||||
}
|
||||
if decision.Confidence > 1 {
|
||||
decision.Confidence = 1
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func (a *Agent) routeTurnWithLLM(ctx context.Context, userID int64, lang, text string) (llmSkillRouteDecision, bool, error) {
|
||||
systemPrompt, userPrompt := a.buildTopLevelRouterPrompt(userID, lang, text)
|
||||
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: stageCtx,
|
||||
})
|
||||
if err != nil {
|
||||
return llmSkillRouteDecision{}, false, err
|
||||
}
|
||||
decision, err := parseLLMSkillRouteDecision(raw)
|
||||
if err != nil {
|
||||
return llmSkillRouteDecision{}, false, err
|
||||
}
|
||||
return decision, true, nil
|
||||
}
|
||||
|
||||
func (a *Agent) buildTopLevelRouterPrompt(userID int64, lang, text string) (string, string) {
|
||||
activeSkill := a.getSkillSession(userID)
|
||||
activeTask, hasActiveTask := a.getActiveSkillSession(userID)
|
||||
activeWorkflow := a.getWorkflowSession(userID)
|
||||
activeExec := a.getExecutionState(userID)
|
||||
pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID)
|
||||
previousAssistantReply := a.currentPendingHintText(userID)
|
||||
snapshots := a.SnapshotManager(userID).List()
|
||||
snapshotJSON, _ := json.Marshal(snapshots)
|
||||
|
||||
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
|
||||
recentConversation := a.buildRecentConversationContext(userID, text)
|
||||
if strings.TrimSpace(recentConversation) == "" {
|
||||
recentConversation = "(empty)"
|
||||
}
|
||||
|
||||
activeFlowSummary := buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal)
|
||||
if strings.TrimSpace(activeFlowSummary) == "" {
|
||||
activeFlowSummary = "none"
|
||||
}
|
||||
|
||||
systemPrompt := prependNOFXiAdvisorPreamble(`You are the lightweight topic router for NOFXi.
|
||||
Return JSON only.
|
||||
|
||||
Your only job is to decide whether the current user turn continues the current topic/state, starts a new topic, resumes a suspended topic, cancels the current topic, or is a direct conversational reply.
|
||||
Do not perform business intent recognition. Do not choose skills, actions, tasks, or fields. The central brain will do that after you return.
|
||||
|
||||
Valid intents:
|
||||
- "continue_active": the user is still working on the current active flow
|
||||
- "start_new": the user is starting or switching to a new task
|
||||
- "resume_snapshot": the user wants to resume one suspended snapshot
|
||||
- "cancel": the user wants to cancel the current active flow
|
||||
- "instant_reply": the user is greeting, chatting, thanking, or asking for a direct explanation without changing task state
|
||||
|
||||
Rules:
|
||||
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question.
|
||||
- If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks.
|
||||
- If the user is clearly answering the previous question, prefer "continue_active".
|
||||
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
|
||||
- If the user explicitly refers to a suspended task like "刚才那个", "恢复刚才那个", choose "resume_snapshot" and fill target_snapshot_id.
|
||||
- If the user is only greeting, thanking, social chatting, or asking a concept question without changing task state, choose "instant_reply".
|
||||
- Set context_switch=true when the user is opening a new topic/task and prior current references or suspended snapshots should not be used to fill business fields. Set context_switch=false when the user intentionally relies on previous context.
|
||||
- Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON.
|
||||
|
||||
Return JSON with this exact shape:
|
||||
{"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","context_switch":false,"confidence":0.0}`)
|
||||
|
||||
if strings.TrimSpace(activeSkill.Name) != "" || hasActiveTask || hasPendingProposal {
|
||||
systemPrompt = prependNOFXiAdvisorPreamble(`You are the one-pass topic gateway for NOFXi.
|
||||
Return JSON only.
|
||||
|
||||
Your only job is topic-state routing: continuing the active flow, switching to a new topic, resuming a suspended snapshot, cancelling, or giving a direct conversational reply.
|
||||
Do not perform business intent recognition. Do not choose skills, actions, tasks, or fields. The central brain will do that after you return.
|
||||
|
||||
Rules:
|
||||
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question.
|
||||
- If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks.
|
||||
- Prefer "continue_active" when the user is plausibly answering the current active flow.
|
||||
- If the user asks a read-only management query while an active flow is open, output intent "start_new"; the central brain will choose the query tool.
|
||||
- If the user starts a multi-step, multi-domain, batch, or condition-based management request while an active flow is open, output intent "start_new"; the central brain will decompose it.
|
||||
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
|
||||
- Examples of forced switch: "不是交易员,是策略", "不是这个", "换个任务", "I mean the strategy, not the trader".
|
||||
- If the user refers to a suspended task and one snapshot clearly matches, use "resume_snapshot".
|
||||
- If the user cancels the current task, use "cancel".
|
||||
- If the user only greets, thanks, chats, or asks for explanation without changing state, use "instant_reply".
|
||||
- Short greetings or acknowledgements like "你好", "hi", "hello", "谢谢", "收到", "好的" should default to "instant_reply" unless they clearly contain task data.
|
||||
- Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON.
|
||||
|
||||
Return JSON with this exact shape:
|
||||
{"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","context_switch":false,"confidence":0.0}`)
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n",
|
||||
lang,
|
||||
text,
|
||||
defaultIfEmpty(previousAssistantReply, "(empty)"),
|
||||
currentRefs,
|
||||
activeFlowSummary,
|
||||
defaultIfEmpty(string(snapshotJSON), "[]"),
|
||||
recentConversation,
|
||||
)
|
||||
|
||||
return systemPrompt, userPrompt
|
||||
}
|
||||
|
||||
func buildTopLevelActiveFlowSummary(lang string, skill skillSession, activeTask ActiveSkillSession, hasActiveTask bool, workflow WorkflowSession, state ExecutionState, pendingProposal PendingProposalSession, hasPendingProposal bool) string {
|
||||
lines := make([]string, 0, 8)
|
||||
if hasActiveTask {
|
||||
|
||||
@@ -939,9 +939,6 @@ func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, us
|
||||
}
|
||||
}
|
||||
if session := a.getSkillSession(userID); strings.TrimSpace(session.Name) != "" {
|
||||
if answer, ok := a.answerSkillSessionExplanation(storeUserID, lang, session, text); ok {
|
||||
return answer, true, nil
|
||||
}
|
||||
decision, _ := a.resolveSkillSessionTurn(ctx, userID, lang, text, session)
|
||||
switch decision.Intent {
|
||||
case "cancel":
|
||||
@@ -1252,9 +1249,9 @@ func (a *Agent) replyToActiveFlowInstantReply(ctx context.Context, userID int64,
|
||||
}
|
||||
}
|
||||
if lang == "zh" {
|
||||
return a.maybeAppendResumePrompt(userID, lang, text, "在,有什么我帮你看的?")
|
||||
return a.maybeAppendResumePrompt(userID, lang, text, "刚才的流程我先保留着。要继续的话,直接说“继续”。")
|
||||
}
|
||||
return a.maybeAppendResumePrompt(userID, lang, text, "I'm here. What would you like me to help you with?")
|
||||
return a.maybeAppendResumePrompt(userID, lang, text, "I kept the previous flow available. Say “continue” when you want to resume it.")
|
||||
}
|
||||
|
||||
func (a *Agent) handoffFromActiveFlow(ctx context.Context, storeUserID string, userID int64, lang, text, targetSnapshotID string, onEvent func(event, data string)) (string, bool, error) {
|
||||
@@ -1427,9 +1424,6 @@ func (a *Agent) classifySkillSessionInput(ctx context.Context, userID int64, lan
|
||||
if isExplicitFlowAbort(text) {
|
||||
return "cancel"
|
||||
}
|
||||
if shouldContinueSkillSessionByExpectedSlot(session, text) {
|
||||
return "continue"
|
||||
}
|
||||
if strings.TrimSpace(session.Name) == "trader_management" && strings.TrimSpace(session.Action) == "create" {
|
||||
if detectReadFastPath(text) == nil {
|
||||
switch detectMentionedSkillDomain(text) {
|
||||
@@ -1537,9 +1531,6 @@ func shouldUseLLMSkillSessionClassifier(session skillSession, text string) bool
|
||||
if isExplicitFlowAbort(text) || isYesReply(text) || isNoReply(text) {
|
||||
return false
|
||||
}
|
||||
if shouldContinueSkillSessionByExpectedSlot(session, text) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1598,200 +1589,6 @@ func shouldInterruptSkillSessionByExplicitDomainMention(session skillSession, te
|
||||
return looksLikeNewTopLevelIntent(text)
|
||||
}
|
||||
|
||||
func looksLikeExplanationQuestion(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{
|
||||
"怎么", "什么意思", "为什么", "格式", "要求", "示例", "例子", "怎么填", "怎么写", "是啥", "是什么", "有哪些", "有什么", "可选", "选项",
|
||||
"how", "what", "why", "format", "requirement", "example", "examples", "what is", "how should", "which options", "available",
|
||||
})
|
||||
}
|
||||
|
||||
func explanationFieldForSession(session skillSession, text string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
switch strings.TrimSpace(session.Name) {
|
||||
case "model_management":
|
||||
if containsAny(lower, []string{"provider", "提供商", "模型类型", "模型有哪些类型"}) {
|
||||
return "provider"
|
||||
}
|
||||
if containsAny(lower, []string{"api key", "apikey", "api_key", "密钥"}) {
|
||||
return "api_key"
|
||||
}
|
||||
if containsAny(lower, []string{"model name", "模型名称", "模型名"}) {
|
||||
return "custom_model_name"
|
||||
}
|
||||
if containsAny(lower, []string{"url", "endpoint", "地址", "接口"}) {
|
||||
return "custom_api_url"
|
||||
}
|
||||
case "exchange_management":
|
||||
if containsAny(lower, []string{"交易所类型", "交易所有哪些类型", "支持哪些交易所", "哪些交易所", "exchange type", "exchange types", "which exchanges", "supported exchanges"}) {
|
||||
return "exchange_type"
|
||||
}
|
||||
if containsAny(lower, []string{"api key", "apikey", "api_key"}) {
|
||||
return "api_key"
|
||||
}
|
||||
if containsAny(lower, []string{"secret", "secret key", "secret_key", "密钥"}) {
|
||||
return "secret_key"
|
||||
}
|
||||
if containsAny(lower, []string{"passphrase", "密码短语"}) {
|
||||
return "passphrase"
|
||||
}
|
||||
case "trader_management":
|
||||
if containsAny(lower, []string{"扫描间隔", "scan interval", "scan frequency"}) {
|
||||
return "scan_interval_minutes"
|
||||
}
|
||||
case "strategy_management":
|
||||
if containsAny(lower, []string{"杠杆", "leverage"}) {
|
||||
return "leverage"
|
||||
}
|
||||
}
|
||||
if currentStep, ok := currentSkillDAGStep(session); ok {
|
||||
switch currentStep.ID {
|
||||
case "resolve_provider":
|
||||
return "provider"
|
||||
case "resolve_exchange_type":
|
||||
return "exchange_type"
|
||||
case "resolve_name", "collect_name":
|
||||
return "name"
|
||||
case "collect_custom_api_url":
|
||||
return "custom_api_url"
|
||||
case "collect_custom_model_name":
|
||||
return "custom_model_name"
|
||||
case "collect_enabled":
|
||||
return "enabled"
|
||||
case "collect_field_value":
|
||||
return fieldValue(session, "update_field")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Agent) answerSkillSessionExplanation(storeUserID, lang string, session skillSession, text string) (string, bool) {
|
||||
if !looksLikeExplanationQuestion(text) {
|
||||
return "", false
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if containsAny(lower, []string{"选项", "有哪些", "有什么", "可选", "available options", "which options"}) {
|
||||
if session.Name == "model_management" {
|
||||
provider := strings.TrimSpace(fieldValue(session, "provider"))
|
||||
if provider == "" {
|
||||
return modelProviderChoicePrompt(lang), true
|
||||
}
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("当前 provider 是 %s。这里要填的是这个 provider 实际支持的模型名称,例如 OpenAI 常见是 `gpt-5`、`gpt-5-mini`,DeepSeek 常见是 `deepseek-chat`。你也可以直接告诉我“用默认推荐模型”。", provider), true
|
||||
}
|
||||
return fmt.Sprintf("The current provider is %s. This field expects a real runtime model ID for that provider, for example `gpt-5` or `gpt-5-mini` for OpenAI, or `deepseek-chat` for DeepSeek. You can also tell me to use the default recommended model.", provider), true
|
||||
}
|
||||
if summary := a.skillVisibleOptionSummary(storeUserID, lang, session.Name, session.Action); summary != "" {
|
||||
return summary, true
|
||||
}
|
||||
}
|
||||
if detectSkillQuestionField(session.Name, text, skillSession{Name: session.Name}) == "" &&
|
||||
containsAny(lower, []string{"字段", "界面", "可配", "能配", "what fields", "which fields", "available fields"}) {
|
||||
if summary := a.skillVisibleFieldSummary(storeUserID, lang, session.Name, session.Action); summary != "" {
|
||||
return summary, true
|
||||
}
|
||||
}
|
||||
field := explanationFieldForSession(session, text)
|
||||
if lang == "zh" {
|
||||
switch field {
|
||||
case "api_key":
|
||||
if session.Name == "model_management" {
|
||||
provider := strings.TrimSpace(fieldValue(session, "provider"))
|
||||
if provider != "" {
|
||||
if spec, ok := modelProviderSpecByID(provider); ok && spec.UsesWalletCredential {
|
||||
return modelProviderCredentialGuidance(lang, provider), true
|
||||
}
|
||||
}
|
||||
return "API Key 一般是你在模型提供商后台生成的密钥。以 OpenAI 为例,通常以 `sk-` 开头,后面跟一长串字母数字。不要带引号外的说明文字,也不要把别的字段一起贴进来。你直接把完整 API Key 发我就行,我会继续当前模型草稿。", true
|
||||
}
|
||||
return "API Key 是交易所/服务商发给你的访问密钥,一般是一长串字母数字。你直接把完整值发我就行,不用附带说明文字。", true
|
||||
case "secret_key":
|
||||
return "Secret 是和 API Key 配套的密钥,通常也是一长串字符串。直接把完整 Secret 发我就行,不要和 API Key 填反。", true
|
||||
case "passphrase":
|
||||
return "Passphrase 是部分交易所额外要求的密码短语。像 OKX 这类账户除了 API Key 和 Secret 之外,还需要这个字段。直接把完整 Passphrase 发我就行。", true
|
||||
case "custom_model_name":
|
||||
return "模型名称填提供商实际可调用的模型 ID 就行,比如 OpenAI 常见是 `gpt-5`、`gpt-5-mini` 这类。你直接告诉我要用哪个模型名,我继续当前草稿。", true
|
||||
case "custom_api_url":
|
||||
return "接口地址填这个 provider 对应的 Base URL。OpenAI 常见是 `https://api.openai.com/v1`。如果你用官方地址,也可以直接告诉我“用默认地址”。", true
|
||||
case "provider":
|
||||
if options := a.modelSkillOptionSummary(lang); options != "" {
|
||||
return options + "。你直接说其中一个就行。", true
|
||||
}
|
||||
return "这里要填模型提供商,比如 OpenAI、DeepSeek、Claude、Gemini、Qwen、Kimi、Grok、Minimax。你直接说其中一个就行。", true
|
||||
case "exchange_type":
|
||||
if options := a.exchangeSkillOptionSummary(lang); options != "" {
|
||||
return options + "。你直接说要接哪个交易所就行。", true
|
||||
}
|
||||
return "这里要填交易所类型,比如 OKX、Binance、Bybit、Gate、KuCoin、Hyperliquid。你直接说要接哪个交易所就行。", true
|
||||
case "name":
|
||||
return "这里要填你想给这个对象起的名字,方便后面识别和管理。你直接说“叫 XXX”就行。", true
|
||||
case "enabled":
|
||||
return "启用状态就是创建后是否立即生效。你可以说“启用/开启”或“禁用/先不开启”。", true
|
||||
case "scan_interval_minutes":
|
||||
return "扫描间隔是交易员多久扫描一次市场,单位是分钟。你直接给我一个数字就行,比如 `5`、`15`。", true
|
||||
case "leverage":
|
||||
return "杠杆就是策略允许使用的倍数。你可以直接给我数字,但系统会按安全上限做约束。", true
|
||||
case "source_type":
|
||||
return "选币来源就是策略最初从哪里拿候选币。手动面板里常见可选项有:`static`(你自己指定静态币池)、`ai500`、`oi_top`、`oi_low`。如果你要自己指定币,就继续告诉我“静态币用 BTC、ETH”;如果你想排除某些币,也可以直接补“排除币不要 SOL、DOGE”。", true
|
||||
case "max_positions":
|
||||
return "最大持仓就是同一时间最多允许开几个仓位。你直接给我整数就行,比如 `3`、`5`、`10`。", true
|
||||
case "min_confidence":
|
||||
return "最小置信度是策略允许开仓的最低信心门槛,通常填 `0-100` 的整数。数值越高,开单会更谨慎。", true
|
||||
case "min_risk_reward_ratio":
|
||||
return "最小盈亏比是每笔交易至少要满足的风险收益比,比如 `1.5`、`2.0`。数值越高,策略会更挑机会。", true
|
||||
case "primary_timeframe":
|
||||
return "主周期是策略主要参考的 K 线周期,比如 `1m`、`5m`、`15m`、`1h`。如果你偏高频,一般会更短;偏稳一些就会更长。", true
|
||||
case "selected_timeframes":
|
||||
return "多周期时间框架就是除了主周期之外,还一起参考哪些周期。常见填法像 `1m,5m,15m` 或 `5m,15m,1h`,直接按逗号列给我就行。", true
|
||||
}
|
||||
if summary := a.skillVisibleFieldSummary(storeUserID, lang, session.Name, session.Action); summary != "" {
|
||||
return summary, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
switch field {
|
||||
case "api_key":
|
||||
return "The API key is the secret token issued by the provider. For OpenAI it usually starts with `sk-` followed by a long string. Just send the full API key and I'll keep the current draft going.", true
|
||||
case "secret_key":
|
||||
return "The secret key is the credential paired with your API key. Send the full secret value directly, and make sure it isn't swapped with the API key.", true
|
||||
case "passphrase":
|
||||
return "The passphrase is an extra credential required by some exchanges such as OKX. Send the full passphrase value and I'll continue the current draft.", true
|
||||
case "custom_model_name":
|
||||
return "The model name should be the real runtime model ID exposed by the provider, such as `gpt-5` or `gpt-5-mini`. Tell me which one you want and I'll continue the draft.", true
|
||||
case "custom_api_url":
|
||||
return "The API URL should be the provider's base endpoint. For OpenAI, a common value is `https://api.openai.com/v1`. You can also tell me to use the default endpoint.", true
|
||||
case "provider":
|
||||
if options := a.modelSkillOptionSummary(lang); options != "" {
|
||||
return options + ". You can reply with any one of them.", true
|
||||
}
|
||||
return "The provider should be one of the supported vendors like OpenAI, DeepSeek, Claude, Gemini, Qwen, Kimi, Grok, or Minimax.", true
|
||||
case "exchange_type":
|
||||
if options := a.exchangeSkillOptionSummary(lang); options != "" {
|
||||
return options + ". You can reply with the one you want to connect.", true
|
||||
}
|
||||
return "The exchange type should be the venue you want to connect, such as OKX, Binance, Bybit, Gate, KuCoin, or Hyperliquid.", true
|
||||
case "name":
|
||||
return "This field is just the display name for the object. You can answer with something like 'call it X'.", true
|
||||
case "enabled":
|
||||
return "Enabled means whether the config should be active right away. You can answer with enable/disable.", true
|
||||
case "scan_interval_minutes":
|
||||
return "The scan interval is the number of minutes between trader scans. Just send a number such as `5` or `15`.", true
|
||||
case "leverage":
|
||||
return "Leverage is the multiplier the strategy is allowed to use. You can send a number, and the system will still enforce safety limits.", true
|
||||
}
|
||||
if summary := a.skillVisibleFieldSummary(storeUserID, lang, session.Name, session.Action); summary != "" {
|
||||
return summary, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func shouldContinueSkillSessionByExpectedSlot(session skillSession, text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) classifySkillSessionIntentWithLLM(ctx context.Context, userID int64, lang string, session skillSession, text string) string {
|
||||
if a == nil || a.aiClient == nil {
|
||||
return ""
|
||||
@@ -3268,6 +3065,23 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6
|
||||
}
|
||||
return question, nil
|
||||
case planStepTypeRespond:
|
||||
if finalText := deterministicCompletedPlanResponse(lang, *state, *step); finalText != "" {
|
||||
step.Status = planStepStatusCompleted
|
||||
step.OutputSummary = finalText
|
||||
state.Status = executionStatusCompleted
|
||||
state.Waiting = nil
|
||||
state.FinalAnswer = finalText
|
||||
state.CurrentStepID = ""
|
||||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := a.saveExecutionState(*state); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if onEvent != nil {
|
||||
onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang))
|
||||
emitStreamText(onEvent, finalText)
|
||||
}
|
||||
return finalText, nil
|
||||
}
|
||||
respondStartedAt := time.Now()
|
||||
finalText, err := a.generateFinalPlanResponse(ctx, userID, lang, *state, step.Instruction)
|
||||
a.logPlannerTiming(state.SessionID, userID, "respond_step", respondStartedAt, err)
|
||||
@@ -3305,6 +3119,48 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6
|
||||
return "", fmt.Errorf("plan execution exceeded iteration limit")
|
||||
}
|
||||
|
||||
func deterministicCompletedPlanResponse(lang string, state ExecutionState, respondStep PlanStep) string {
|
||||
if !isCompletionOnlyRespondStep(respondStep) {
|
||||
return ""
|
||||
}
|
||||
completed := make([]PlanStep, 0, len(state.Steps))
|
||||
for _, step := range state.Steps {
|
||||
if step.ID == respondStep.ID {
|
||||
continue
|
||||
}
|
||||
if step.Status == planStepStatusCompleted && step.Type == planStepTypeTool {
|
||||
completed = append(completed, step)
|
||||
continue
|
||||
}
|
||||
if step.Status == planStepStatusCompleted && step.Type == planStepTypeReason {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if len(completed) == 0 {
|
||||
return ""
|
||||
}
|
||||
return formatCompletedPlanFallback(lang, completed)
|
||||
}
|
||||
|
||||
func isCompletionOnlyRespondStep(step PlanStep) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(step.Title + " " + step.Instruction))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(text, []string{
|
||||
"成功",
|
||||
"完成",
|
||||
"确认",
|
||||
"created",
|
||||
"updated",
|
||||
"deleted",
|
||||
"activated",
|
||||
"duplicated",
|
||||
"completed",
|
||||
"confirm",
|
||||
})
|
||||
}
|
||||
|
||||
type fetchedToolRecord struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
ToolArgsJSON string `json:"tool_args_json"`
|
||||
@@ -3833,18 +3689,16 @@ func formatCompletedPlanFallback(lang string, steps []PlanStep) string {
|
||||
return ""
|
||||
}
|
||||
if lang == "zh" {
|
||||
lines := []string{"当前 AI 在最后生成总结时失败了,但我已经完成这些步骤:"}
|
||||
lines := []string{"已完成:"}
|
||||
for _, label := range labels {
|
||||
lines = append(lines, "- "+label)
|
||||
}
|
||||
lines = append(lines, "你现在可以先去页面确认结果;如果需要,我稍后可以继续补一版确认说明。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
lines := []string{"The AI failed while generating the final summary, but I already completed these steps:"}
|
||||
lines := []string{"Completed:"}
|
||||
for _, label := range labels {
|
||||
lines = append(lines, "- "+label)
|
||||
}
|
||||
lines = append(lines, "You can verify the result in the UI now, and I can provide a follow-up confirmation summary later.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,11 @@ func buildSkillDomainPrimer(lang, skillName string) string {
|
||||
"- source_type 是选币来源,不是交易所,也不是模型。",
|
||||
"- strategy_type 选项:ai_trading、grid_trading。",
|
||||
"- source_type 选项:static、ai500、oi_top、oi_low、mixed。",
|
||||
"- grid_trading 页面交易对选项:BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。",
|
||||
"- grid_trading 页面范围:grid_count 5~50、total_investment 最小 100、leverage 1~5、atr_multiplier 1~5、max_drawdown_pct 5~50、stop_loss_pct 1~20、daily_loss_limit_pct 1~30、direction_bias_ratio 0.55~0.90。",
|
||||
"- ai_trading 页面范围:static_coins 最多 10 个、selected_timeframes 最多 4 个、primary_count 10~30、min_confidence 50~100、min_risk_reward_ratio 1~10。",
|
||||
"- 排行榜页面选项:duration 为 1h/4h/24h(价格榜还支持 1h,4h,24h),limit 为 5/10/15/20。",
|
||||
"- max_positions、仓位价值占比、max_margin_usage、min_position_size 在策略页属于 System enforced / 非普通手动编辑项。",
|
||||
"- 关键字段:" + strings.Join(fields, "、"),
|
||||
}, "\n")
|
||||
}
|
||||
@@ -123,6 +128,11 @@ func buildSkillDomainPrimer(lang, skillName string) string {
|
||||
"- source_type means the coin source, not an exchange or model.",
|
||||
"- strategy_type options: ai_trading, grid_trading.",
|
||||
"- source_type options: static, ai500, oi_top, oi_low, mixed.",
|
||||
"- grid_trading symbol dropdown: BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, XRPUSDT, DOGEUSDT.",
|
||||
"- grid_trading page ranges: grid_count 5-50, total_investment >=100, leverage 1-5, atr_multiplier 1-5, max_drawdown_pct 5-50, stop_loss_pct 1-20, daily_loss_limit_pct 1-30, direction_bias_ratio 0.55-0.90.",
|
||||
"- ai_trading page ranges: static_coins at most 10, selected_timeframes at most 4, primary_count 10-30, min_confidence 50-100, min_risk_reward_ratio 1-10.",
|
||||
"- Ranking page options: duration 1h/4h/24h (price ranking also supports 1h,4h,24h), limit 5/10/15/20.",
|
||||
"- max_positions, position value ratios, max_margin_usage, and min_position_size are System enforced / not ordinary manual fields on the strategy page.",
|
||||
"- Key fields: " + strings.Join(fields, ", "),
|
||||
}, "\n")
|
||||
default:
|
||||
|
||||
@@ -2836,52 +2836,12 @@ func (a *Agent) persistStrategyConfigUpdate(storeUserID string, userID int64, la
|
||||
enMsg += "\n\nAdjusted to stay within safe limits:\n- " + strings.Join(warnings, "\n- ")
|
||||
}
|
||||
}
|
||||
if summary := parseStrategyToolChangeSummary(resp); len(summary.ChangedFields) > 0 || len(summary.RejectedFields) > 0 || len(summary.UnchangedDefaults) > 0 {
|
||||
if lang == "zh" {
|
||||
if len(summary.ChangedFields) > 0 {
|
||||
zhMsg += "\n- 实际写入配置:" + strings.Join(summary.ChangedFields, "、")
|
||||
}
|
||||
if len(summary.RejectedFields) > 0 {
|
||||
zhMsg += "\n- 未写入字段:" + strings.Join(summary.RejectedFields, "、")
|
||||
}
|
||||
if len(summary.UnchangedDefaults) > 0 {
|
||||
zhMsg += "\n- 仍使用默认值:" + strings.Join(summary.UnchangedDefaults, "、")
|
||||
}
|
||||
} else {
|
||||
if len(summary.ChangedFields) > 0 {
|
||||
enMsg += "\n- Config fields written: " + strings.Join(summary.ChangedFields, ", ")
|
||||
}
|
||||
if len(summary.RejectedFields) > 0 {
|
||||
enMsg += "\n- Rejected fields: " + strings.Join(summary.RejectedFields, ", ")
|
||||
}
|
||||
if len(summary.UnchangedDefaults) > 0 {
|
||||
enMsg += "\n- Defaults still in use: " + strings.Join(summary.UnchangedDefaults, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
if lang == "zh" {
|
||||
return zhMsg
|
||||
}
|
||||
return enMsg
|
||||
}
|
||||
|
||||
type strategyToolChangeSummary struct {
|
||||
CreatedStrategyID string `json:"created_strategy_id"`
|
||||
StrategyID string `json:"strategy_id"`
|
||||
ChangedFields []string `json:"changed_fields"`
|
||||
UnchangedDefaults []string `json:"unchanged_defaults"`
|
||||
RejectedFields []string `json:"rejected_fields"`
|
||||
}
|
||||
|
||||
func parseStrategyToolChangeSummary(raw string) strategyToolChangeSummary {
|
||||
var payload strategyToolChangeSummary
|
||||
_ = json.Unmarshal([]byte(raw), &payload)
|
||||
payload.ChangedFields = cleanStringList(payload.ChangedFields)
|
||||
payload.UnchangedDefaults = cleanStringList(payload.UnchangedDefaults)
|
||||
payload.RejectedFields = cleanStringList(payload.RejectedFields)
|
||||
return payload
|
||||
}
|
||||
|
||||
func parseToolWarnings(raw string) []string {
|
||||
var payload struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
|
||||
@@ -373,6 +373,17 @@ func unmarshalStrategyCreateDraft(raw, lang string) store.StrategyConfig {
|
||||
|
||||
func strategyCreateConfigFromSession(session skillSession, lang string) (store.StrategyConfig, map[string]any, []string, error) {
|
||||
cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang)
|
||||
for _, key := range manualStrategyEditableFieldKeys() {
|
||||
switch key {
|
||||
case "name", "description", "is_public", "config_visible":
|
||||
continue
|
||||
}
|
||||
if value := fieldValue(session, key); strings.TrimSpace(value) != "" {
|
||||
if err := applyStrategyConfigPatch(&cfg, key, value); err != nil {
|
||||
return cfg, nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
|
||||
var patch map[string]any
|
||||
if patchRaw != "" {
|
||||
@@ -387,7 +398,25 @@ func strategyCreateConfigFromSession(session skillSession, lang string) (store.S
|
||||
}
|
||||
beforeClamp := cfg
|
||||
cfg.ClampLimits()
|
||||
return cfg, patch, store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language), nil
|
||||
if strings.TrimSpace(cfg.StrategyType) == "" {
|
||||
cfg.StrategyType = "ai_trading"
|
||||
}
|
||||
rawCfg, _ := json.Marshal(cfg)
|
||||
var configMap map[string]any
|
||||
_ = json.Unmarshal(rawCfg, &configMap)
|
||||
removeLockedStrategyCreateFields(configMap)
|
||||
return cfg, configMap, store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language), nil
|
||||
}
|
||||
|
||||
func removeLockedStrategyCreateFields(configMap map[string]any) {
|
||||
if configMap == nil {
|
||||
return
|
||||
}
|
||||
risk, ok := configMap["risk_control"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(risk, "min_position_size")
|
||||
}
|
||||
|
||||
func strategyCreateConfirmationReply(text string) bool {
|
||||
@@ -398,7 +427,140 @@ func strategyCreateConfirmationReply(text string) bool {
|
||||
return isYesReply(text) || lower == "确认创建" || lower == "创建吧" || lower == "就按这个创建" || lower == "按这个创建" || lower == "确认应用" || lower == "应用" || lower == "就按这个应用"
|
||||
}
|
||||
|
||||
func formatStrategyCreateDraftSummary(lang, name string, changedFields, warnings []string) string {
|
||||
func strategyCreateDefaultConfigReply(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{
|
||||
"默认", "先创建", "直接创建", "不用配置", "其他默认", "用默认", "按默认", "默认配置",
|
||||
"use default", "use defaults", "default config", "create now", "create directly",
|
||||
})
|
||||
}
|
||||
|
||||
func explicitStrategyCreateType(session skillSession) string {
|
||||
if value := strings.TrimSpace(fieldValue(session, "strategy_type")); value != "" {
|
||||
return value
|
||||
}
|
||||
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
|
||||
if patchRaw == "" {
|
||||
return ""
|
||||
}
|
||||
var patch map[string]any
|
||||
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
|
||||
return ""
|
||||
}
|
||||
if value, ok := patch["strategy_type"].(string); ok {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
if gridConfig, ok := patch["grid_config"]; ok && gridConfig != nil {
|
||||
return "grid_trading"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
switch strategyType {
|
||||
case "grid_trading":
|
||||
grid := cfg.GridConfig
|
||||
if grid == nil {
|
||||
return false, "grid_trading"
|
||||
}
|
||||
if strings.TrimSpace(grid.Symbol) == "" || grid.GridCount <= 0 || grid.TotalInvestment <= 0 || grid.Leverage <= 0 {
|
||||
return false, "grid_trading"
|
||||
}
|
||||
if !grid.UseATRBounds && (grid.UpperPrice <= 0 || grid.LowerPrice <= 0) {
|
||||
return false, "grid_trading"
|
||||
}
|
||||
return true, ""
|
||||
case "ai_trading":
|
||||
if strings.TrimSpace(cfg.CoinSource.SourceType) == "" || strings.TrimSpace(cfg.Indicators.Klines.PrimaryTimeframe) == "" {
|
||||
return false, "ai_trading"
|
||||
}
|
||||
return true, ""
|
||||
default:
|
||||
return false, "strategy_type"
|
||||
}
|
||||
}
|
||||
|
||||
func strategyCreateHasExplicitConfigBeyondType(session skillSession) bool {
|
||||
for _, key := range manualStrategyEditableFieldKeys() {
|
||||
switch key {
|
||||
case "name", "description", "is_public", "config_visible", "strategy_type":
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(fieldValue(session, key)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
|
||||
if patchRaw == "" {
|
||||
return false
|
||||
}
|
||||
var patch map[string]any
|
||||
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
|
||||
return true
|
||||
}
|
||||
for key := range patch {
|
||||
if strings.TrimSpace(key) != "" && strings.TrimSpace(key) != "strategy_type" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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")
|
||||
case "ai_trading":
|
||||
return strings.Join([]string{
|
||||
"好的,先不创建空模板。AI 策略需要先把核心配置补齐,之后我再调用 create 落库。",
|
||||
"需要确认这些配置:",
|
||||
"- 选币来源:static、ai500、oi_top、oi_low",
|
||||
"- K 线主周期和多周期",
|
||||
"- 风控:杠杆、最小置信度、最小盈亏比",
|
||||
"- 提示词方向:角色定义、交易频率、入场标准、决策流程",
|
||||
"你可以一次性告诉我这些参数;如果想先用默认值,也可以明确说“用默认配置创建”。",
|
||||
}, "\n")
|
||||
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."
|
||||
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."
|
||||
default:
|
||||
return "Choose the strategy type first: grid_trading or ai_trading. I will collect the matching config before creating it."
|
||||
}
|
||||
}
|
||||
|
||||
func formatStrategyCreateDraftSummary(lang, name, strategyType string, changedFields, warnings []string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
if lang == "zh" {
|
||||
@@ -425,7 +587,14 @@ func formatStrategyCreateDraftSummary(lang, name string, changedFields, warnings
|
||||
lines = append(lines, "你可以继续告诉我其他字段怎么设计;如果接受当前安全范围,也可以直接回复“确认创建”。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
lines = append(lines, "你可以继续补充其他字段,比如选币来源、最大持仓、置信度、盈亏比、多周期;如果现在就创建,直接回复“确认创建”。")
|
||||
switch strategyType {
|
||||
case "grid_trading":
|
||||
lines = append(lines, "这是网格策略草稿。你可以继续补充交易对、网格数量、总投入、杠杆、价格区间和网格风控;如果想让我按默认值补齐,直接说“用默认配置创建”。")
|
||||
case "ai_trading":
|
||||
lines = append(lines, "这是 AI 策略草稿。你可以继续补充选币来源、时间周期、风险参数和提示词方向;如果想让我按默认值补齐,直接说“用默认配置创建”。")
|
||||
default:
|
||||
lines = append(lines, "你可以继续补充策略类型和对应参数;如果现在就创建,直接回复“确认创建”。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -446,7 +615,74 @@ func formatStrategyCreateDraftSummary(lang, name string, changedFields, warnings
|
||||
lines = append(lines, "You can keep refining the draft, or reply 'confirm' to create it with the safe adjusted values.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
lines = append(lines, "You can keep refining the draft, or reply 'confirm' to create it now.")
|
||||
switch strategyType {
|
||||
case "grid_trading":
|
||||
lines = append(lines, "This is a grid strategy draft. You can keep refining symbol, grid count, total investment, leverage, price bounds, and grid risk settings, or say 'use defaults' before creating it.")
|
||||
case "ai_trading":
|
||||
lines = append(lines, "This is an AI strategy draft. You can keep refining coin source, timeframes, risk settings, and prompt direction, or say 'use defaults' before creating it.")
|
||||
default:
|
||||
lines = append(lines, "You can keep refining the strategy type and matching parameters, or reply 'confirm' to create it now.")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatStrategyCreateFinalConfirmation(lang string, session skillSession, cfg store.StrategyConfig) string {
|
||||
name := defaultIfEmpty(fieldValue(session, "name"), "未命名策略")
|
||||
if lang != "zh" {
|
||||
name = defaultIfEmpty(fieldValue(session, "name"), "unnamed strategy")
|
||||
}
|
||||
if lang == "zh" {
|
||||
lines := []string{fmt.Sprintf("我已经把“%s”的配置整理好了,确认后我再创建到策略列表。", name)}
|
||||
switch cfg.StrategyType {
|
||||
case "grid_trading":
|
||||
grid := cfg.GridConfig
|
||||
if grid == nil {
|
||||
grid = &store.GridStrategyConfig{}
|
||||
}
|
||||
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),
|
||||
)
|
||||
if grid.UseATRBounds {
|
||||
lines = append(lines, fmt.Sprintf("- 价格区间:ATR 动态范围(倍数 %.2f)", grid.ATRMultiplier))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- 价格区间:%.2f ~ %.2f", grid.LowerPrice, grid.UpperPrice))
|
||||
}
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- 网格分布:%s", defaultIfEmpty(grid.Distribution, "uniform")),
|
||||
fmt.Sprintf("- 最大回撤:%.2f%%", grid.MaxDrawdownPct),
|
||||
fmt.Sprintf("- 止损:%.2f%%", grid.StopLossPct),
|
||||
fmt.Sprintf("- 日亏损限制:%.2f%%", grid.DailyLossLimitPct),
|
||||
)
|
||||
default:
|
||||
lines = append(lines,
|
||||
"- 类型:AI 策略",
|
||||
fmt.Sprintf("- 选币来源:%s", defaultIfEmpty(cfg.CoinSource.SourceType, "未设置")),
|
||||
fmt.Sprintf("- 主周期:%s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "未设置")),
|
||||
fmt.Sprintf("- 最小置信度:%d", cfg.RiskControl.MinConfidence),
|
||||
fmt.Sprintf("- 最小盈亏比:%.2f", cfg.RiskControl.MinRiskRewardRatio),
|
||||
)
|
||||
}
|
||||
lines = append(lines, "确认创建的话,直接回复“确认创建”。要调整也可以直接说改哪项。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
lines := []string{fmt.Sprintf("I prepared the config for %q. Confirm and I will create it in the strategy list.", 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),
|
||||
)
|
||||
} else {
|
||||
lines = append(lines, "- Type: AI strategy")
|
||||
}
|
||||
lines = append(lines, "Reply 'confirm create' to create it, or tell me what to change.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -720,10 +956,6 @@ func formatTraderCreateDraftSummary(lang string, session skillSession) string {
|
||||
}
|
||||
|
||||
func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||||
if answer, ok := a.answerSkillSessionExplanation(storeUserID, lang, session, text); ok {
|
||||
a.saveSkillSession(userID, session)
|
||||
return answer
|
||||
}
|
||||
name := fieldValue(session, "name")
|
||||
if actionRequiresSlot("strategy_management", "create", "name") && strings.TrimSpace(name) == "" {
|
||||
setSkillDAGStep(&session, "resolve_name")
|
||||
@@ -733,6 +965,11 @@ func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, la
|
||||
}
|
||||
return "One more thing: give this strategy a name."
|
||||
}
|
||||
if fieldValue(session, "strategy_type") == "" {
|
||||
if strategyType := parseStrategyTypeValue(text); strategyType != "" {
|
||||
setField(&session, "strategy_type", strategyType)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang)
|
||||
changedFields := applyStrategyCreateIntentToConfig(&cfg, text, lang)
|
||||
@@ -748,6 +985,10 @@ func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, la
|
||||
session.Phase = "draft_create"
|
||||
|
||||
if strategyCreateConfirmationReply(text) {
|
||||
if ready, missingKind := strategyCreateConfigReady(session, cfg, text); !ready {
|
||||
a.saveSkillSession(userID, session)
|
||||
return formatStrategyCreateConfigNeeded(lang, missingKind)
|
||||
}
|
||||
args := map[string]any{
|
||||
"action": "create",
|
||||
"name": name,
|
||||
@@ -775,7 +1016,7 @@ func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, la
|
||||
}
|
||||
|
||||
a.saveSkillSession(userID, session)
|
||||
return formatStrategyCreateDraftSummary(lang, name, changedFields, warnings)
|
||||
return formatStrategyCreateDraftSummary(lang, name, explicitStrategyCreateType(session), changedFields, warnings)
|
||||
}
|
||||
|
||||
func hasExplicitStrategyDetailIntent(text string) bool {
|
||||
@@ -1615,7 +1856,12 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
|
||||
}
|
||||
return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'."
|
||||
}
|
||||
_, patch, warnings, cfgErr := strategyCreateConfigFromSession(session, lang)
|
||||
if fieldValue(session, "strategy_type") == "" {
|
||||
if strategyType := parseStrategyTypeValue(text); strategyType != "" {
|
||||
setField(&session, "strategy_type", strategyType)
|
||||
}
|
||||
}
|
||||
cfg, configMap, warnings, cfgErr := strategyCreateConfigFromSession(session, lang)
|
||||
if cfgErr != nil {
|
||||
a.saveSkillSession(userID, session)
|
||||
if lang == "zh" {
|
||||
@@ -1623,6 +1869,13 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
|
||||
}
|
||||
return "That strategy config could not be prepared: " + cfgErr.Error()
|
||||
}
|
||||
if ready, missingKind := strategyCreateConfigReady(session, cfg, text); !ready {
|
||||
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
|
||||
setSkillDAGStep(&session, "collect_config")
|
||||
session.Phase = "draft_create"
|
||||
a.saveSkillSession(userID, session)
|
||||
return formatStrategyCreateConfigNeeded(lang, missingKind)
|
||||
}
|
||||
|
||||
setSkillDAGStep(&session, "execute_create")
|
||||
args := map[string]any{
|
||||
@@ -1631,8 +1884,8 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
|
||||
"lang": defaultIfEmpty(lang, "zh"),
|
||||
"allow_clamped_update": true,
|
||||
}
|
||||
if len(patch) > 0 {
|
||||
args["config"] = patch
|
||||
if len(configMap) > 0 {
|
||||
args["config"] = configMap
|
||||
}
|
||||
raw, _ := json.Marshal(args)
|
||||
resp := a.toolManageStrategy(storeUserID, string(raw))
|
||||
|
||||
@@ -152,8 +152,6 @@ Rules:
|
||||
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
|
||||
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
|
||||
- If you choose "complete", produce the final user-facing answer in the user's language.
|
||||
- For strategy_management create/update outcomes, only mention config fields present in changed_fields, unchanged_defaults, rejected_fields, warnings, or user_message. Do not add strategy settings that are not in the structured skill outcome.
|
||||
- If a strategy field is not in the current StrategyConfig or appears in rejected_fields, say it was not written / is not in the current strategy config. Do not use trading common sense to invent fields.
|
||||
- ` + cleanUserFacingReplyInstruction + `
|
||||
|
||||
Return JSON with this exact shape:
|
||||
|
||||
@@ -203,9 +203,9 @@ func buildSkillRoutingSummary(lang string, skillNames []string) string {
|
||||
}
|
||||
case "strategy_management":
|
||||
if lang == "zh" {
|
||||
parts = append(parts, "策略模板不能直接启动;只有绑定了该策略的交易员可以启动。")
|
||||
parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。")
|
||||
} else {
|
||||
parts = append(parts, "Strategy templates do not run directly; only traders bound to a strategy can run.")
|
||||
parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.")
|
||||
}
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " ")))
|
||||
@@ -237,9 +237,9 @@ func buildSkillDefinitionSummary(lang string, skillNames []string) string {
|
||||
}
|
||||
case "strategy_management":
|
||||
if lang == "zh" {
|
||||
parts = append(parts, "策略模板不能直接启动;只有绑定了该策略的交易员可以启动。")
|
||||
parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。")
|
||||
} else {
|
||||
parts = append(parts, "Strategy templates do not run directly; only traders bound to a strategy can run.")
|
||||
parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.")
|
||||
}
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " ")))
|
||||
|
||||
@@ -1,210 +1,12 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
type skillSemanticGateDecision struct {
|
||||
Decision string `json:"decision,omitempty"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Agent) evaluateHardSkillCandidate(ctx context.Context, storeUserID string, userID int64, lang string, session skillSession, skillName, action, text string) skillSemanticGateDecision {
|
||||
fallback := fallbackSkillSemanticGate(skillName, action, session, text)
|
||||
if a == nil || a.aiClient == nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
systemPrompt := `You are the second-stage semantic gate for one NOFXi hard skill.
|
||||
Return JSON only. No markdown.
|
||||
|
||||
Decide exactly one:
|
||||
- "execute": this request is concrete enough for this hard skill to handle now
|
||||
- "explain": the user is asking about visible UI fields, options, requirements, or how to fill them
|
||||
- "planner": this request is too open-ended, strategic, subjective, ambiguous, or not yet UI-ready for this hard skill
|
||||
|
||||
Rules:
|
||||
- Use "execute" when the request maps to the current skill's visible fields, existing entity options, or a normal multi-turn form flow.
|
||||
- Use "explain" when the user asks what fields/options exist, what a field means, or how to fill it.
|
||||
- Use "planner" when the user asks for outcome-seeking advice like "make money", "don't lose", "best", "optimize", or otherwise asks you to design the solution before UI-level parameters are clear.
|
||||
- Use "planner" when semantic readiness is not met: the route points to a skill/action, but the request is still missing core required fields and would otherwise mostly result in a mechanical missing-field error.
|
||||
- Be conservative. If this hard skill would mostly respond with a misleading missing-field error, choose "planner" instead.
|
||||
|
||||
Return JSON:
|
||||
{"decision":"execute|explain|planner","field":"","reason":""}`
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"Language: %s\nSkill: %s\nAction: %s\nActive skill session JSON: %s\nSemantic readiness summary:\n%s\nVisible field summary:\n%s\nVisible option summary:\n%s\nDomain primer:\n%s\nUser message: %s",
|
||||
lang,
|
||||
skillName,
|
||||
action,
|
||||
mustMarshalJSON(session),
|
||||
a.skillSemanticReadinessSummary(lang, session, skillName, action, text),
|
||||
a.skillVisibleFieldSummary(storeUserID, lang, skillName, action),
|
||||
a.skillVisibleOptionSummary(storeUserID, lang, skillName, action),
|
||||
buildSkillDomainPrimer(lang, skillName),
|
||||
text,
|
||||
)
|
||||
|
||||
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
|
||||
defer cancel()
|
||||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: stageCtx,
|
||||
})
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
if parsed, ok := parseSkillSemanticGateDecision(raw); ok {
|
||||
if parsed.Field == "" {
|
||||
parsed.Field = detectSkillQuestionField(skillName, text, session)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseSkillSemanticGateDecision(raw string) (skillSemanticGateDecision, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var out skillSemanticGateDecision
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &out) != nil {
|
||||
return skillSemanticGateDecision{}, false
|
||||
}
|
||||
}
|
||||
out.Decision = strings.TrimSpace(strings.ToLower(out.Decision))
|
||||
out.Field = strings.TrimSpace(out.Field)
|
||||
out.Reason = strings.TrimSpace(out.Reason)
|
||||
switch out.Decision {
|
||||
case "execute", "explain", "planner":
|
||||
return out, true
|
||||
default:
|
||||
return skillSemanticGateDecision{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackSkillSemanticGate(skillName, action string, session skillSession, text string) skillSemanticGateDecision {
|
||||
if looksLikeExplanationQuestion(text) {
|
||||
return skillSemanticGateDecision{
|
||||
Decision: "explain",
|
||||
Field: "",
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(session.Name) == skillName && strings.TrimSpace(session.Action) != "" {
|
||||
return skillSemanticGateDecision{Decision: "execute"}
|
||||
}
|
||||
if isSimpleEntityMutationAction(action) && !hasExplicitSkillCueForSemanticGate(skillName, text) {
|
||||
return skillSemanticGateDecision{Decision: "planner", Reason: "requires_llm_intent_resolution"}
|
||||
}
|
||||
if missing := semanticReadinessMissingSlots(skillName, action, session, text); len(missing) > 0 {
|
||||
return skillSemanticGateDecision{Decision: "planner", Reason: "semantic_readiness_missing_core_fields"}
|
||||
}
|
||||
return skillSemanticGateDecision{Decision: "execute"}
|
||||
}
|
||||
|
||||
func hasExplicitSkillCueForSemanticGate(skillName, text string) bool {
|
||||
switch strings.TrimSpace(skillName) {
|
||||
case "trader_management":
|
||||
return hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")
|
||||
case "exchange_management":
|
||||
return hasExplicitManagementDomainCue(text, "exchange")
|
||||
case "model_management":
|
||||
return hasExplicitManagementDomainCue(text, "model")
|
||||
case "strategy_management":
|
||||
return hasExplicitManagementDomainCue(text, "strategy")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func semanticReadinessMissingSlots(skillName, action string, session skillSession, text string) []string {
|
||||
if strings.TrimSpace(action) == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(session.Name) == skillName && strings.TrimSpace(session.Action) != "" {
|
||||
return nil
|
||||
}
|
||||
values := map[string]string{}
|
||||
missing := missingRequiredActionSlots(skillName, action, values)
|
||||
if action != "create" {
|
||||
return nil
|
||||
}
|
||||
if skillName == "strategy_management" {
|
||||
return nil
|
||||
}
|
||||
if skillName == "model_management" {
|
||||
coreMissing := missingCoreReadinessSlots([]string{"provider"}, values)
|
||||
if len(coreMissing) > 0 {
|
||||
return coreMissing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if skillName == "exchange_management" {
|
||||
coreMissing := missingCoreReadinessSlots([]string{"exchange_type", "account_name", "api_key", "secret_key"}, values)
|
||||
if len(coreMissing) > 0 {
|
||||
return coreMissing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(missing) == 0 || len(missing) == len(missingRequiredActionSlots(skillName, action, map[string]string{})) {
|
||||
if hasExplicitCreateIntentForDomain(text, "trader") || containsAny(strings.ToLower(text), []string{"创建", "新建", "create", "new"}) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if len(missing) >= 2 {
|
||||
return missing
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func missingCoreReadinessSlots(keys []string, values map[string]string) []string {
|
||||
missing := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
if strings.TrimSpace(values[key]) == "" {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func (a *Agent) skillSemanticReadinessSummary(lang string, session skillSession, skillName, action, text string) string {
|
||||
missing := semanticReadinessMissingSlots(skillName, action, session, text)
|
||||
if len(missing) == 0 {
|
||||
if lang == "zh" {
|
||||
return "当前语义已足够进入该 skill。"
|
||||
}
|
||||
return "Semantic readiness is sufficient for this skill."
|
||||
}
|
||||
display := make([]string, 0, len(missing))
|
||||
for _, slot := range missing {
|
||||
display = append(display, slotDisplayName(slot, lang))
|
||||
}
|
||||
if lang == "zh" {
|
||||
return "当前语义还缺核心字段:" + strings.Join(display, "、") + "。如果直接执行只会变成程序式缺字段提示,应优先走 planner/ask_user。"
|
||||
}
|
||||
return "Core semantic fields are still missing: " + strings.Join(display, ", ") + ". Prefer planner/ask_user before direct execution."
|
||||
}
|
||||
|
||||
func detectSkillQuestionField(skillName, text string, session skillSession) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action string) string {
|
||||
fieldNames := make([]string, 0, 20)
|
||||
add := func(field string) {
|
||||
@@ -259,6 +61,42 @@ func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action st
|
||||
return prefix + ":" + strings.Join(fieldNames, "、")
|
||||
}
|
||||
|
||||
func (a *Agent) strategyTypeForTarget(storeUserID string, target *EntityReference) (string, bool) {
|
||||
if a == nil || a.store == nil || target == nil {
|
||||
return "", false
|
||||
}
|
||||
var strategy *store.Strategy
|
||||
var err error
|
||||
if id := strings.TrimSpace(target.ID); id != "" {
|
||||
strategy, err = a.store.Strategy().Get(storeUserID, id)
|
||||
} else if name := strings.TrimSpace(target.Name); name != "" {
|
||||
strategies, listErr := a.store.Strategy().List(storeUserID)
|
||||
if listErr != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, item := range strategies {
|
||||
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), name) {
|
||||
strategy = item
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return "", false
|
||||
}
|
||||
if err != nil || strategy == nil {
|
||||
return "", false
|
||||
}
|
||||
cfg := store.GetDefaultStrategyConfig("zh")
|
||||
if strings.TrimSpace(strategy.Config) != "" {
|
||||
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
|
||||
}
|
||||
strategyType := strings.TrimSpace(cfg.StrategyType)
|
||||
if strategyType == "" {
|
||||
strategyType = "ai_trading"
|
||||
}
|
||||
return strategyType, true
|
||||
}
|
||||
|
||||
func (a *Agent) skillVisibleOptionSummary(storeUserID, lang, skillName, action string) string {
|
||||
switch skillName {
|
||||
case "model_management":
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
"description": "策略类型:ai_trading(AI 量化)或 grid_trading(网格策略)。"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "网格策略的交易对,例如 BTCUSDT。"
|
||||
"type": "enum",
|
||||
"values": ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"],
|
||||
"description": "网格策略页面交易对下拉选项,只能从 BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT 中选择。用户问“交易对有哪些选项”时,直接列出这些选项。"
|
||||
},
|
||||
"source_type": {
|
||||
"type": "enum",
|
||||
@@ -47,7 +48,8 @@
|
||||
},
|
||||
"static_coins": {
|
||||
"type": "string_array",
|
||||
"description": "静态币池,例如 [\"BTCUSDT\", \"ETHUSDT\"],source_type=static 时使用。"
|
||||
"max_items": 10,
|
||||
"description": "静态币池,例如 [\"BTCUSDT\", \"ETHUSDT\"],source_type=static 时使用,手动页面最多 10 个。页面支持常规合约币种,也支持 xyz: 前缀资产(如 xyz:TSLA、xyz:GOLD、xyz:XYZ100)。"
|
||||
},
|
||||
"excluded_coins": {
|
||||
"type": "string_array",
|
||||
@@ -60,7 +62,9 @@
|
||||
},
|
||||
"selected_timeframes": {
|
||||
"type": "string_array",
|
||||
"description": "多周期分析时间框架列表,例如 [\"5m\",\"15m\",\"1h\"]。"
|
||||
"values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"],
|
||||
"max_items": 4,
|
||||
"description": "多周期分析时间框架列表,例如 [\"5m\",\"15m\",\"1h\"];手动页面最多选择 4 个。"
|
||||
},
|
||||
"btceth_max_leverage": {
|
||||
"type": "int",
|
||||
@@ -77,18 +81,19 @@
|
||||
"max_positions": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "最大同时持仓数量,最小 1。"
|
||||
"description": "最大同时持仓数量。策略页展示为 System enforced,不是普通手动可编辑项;除非用户明确要求高级配置,否则不要主动修改。"
|
||||
},
|
||||
"min_confidence": {
|
||||
"type": "int",
|
||||
"min": 0,
|
||||
"min": 50,
|
||||
"max": 100,
|
||||
"description": "最小开仓置信度,范围 0~100,数值越高开单越谨慎。"
|
||||
"description": "最小开仓置信度,手动页面范围 50~100,数值越高开单越谨慎。"
|
||||
},
|
||||
"min_risk_reward_ratio": {
|
||||
"type": "float",
|
||||
"min": 0.1,
|
||||
"description": "最小盈亏比,例如 1.5 表示每笔交易至少 1.5 倍风险收益比。"
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"description": "最小盈亏比,手动页面范围 1~10,步进 0.5;例如 1.5 表示每笔交易至少 1.5 倍风险收益比。"
|
||||
},
|
||||
"custom_prompt": {
|
||||
"type": "text",
|
||||
@@ -112,13 +117,14 @@
|
||||
},
|
||||
"grid_count": {
|
||||
"type": "int",
|
||||
"min": 2,
|
||||
"description": "网格数量,grid_trading 类型专用,最小 2。"
|
||||
"min": 5,
|
||||
"max": 50,
|
||||
"description": "网格数量,grid_trading 类型专用,手动页面范围 5~50。"
|
||||
},
|
||||
"total_investment": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"description": "网格总投入金额,grid_trading 类型专用。"
|
||||
"min": 100,
|
||||
"description": "网格总投入金额,grid_trading 类型专用,手动页面最小 100 USDT,步进 100。"
|
||||
},
|
||||
"leverage": {
|
||||
"type": "int",
|
||||
@@ -146,8 +152,9 @@
|
||||
},
|
||||
"atr_multiplier": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"description": "ATR 边界倍数,use_atr_bounds=true 时使用。"
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"description": "ATR 边界倍数,use_atr_bounds=true 时使用,手动页面范围 1~5,步进 0.5。"
|
||||
},
|
||||
"enable_direction_adjust": {
|
||||
"type": "bool",
|
||||
@@ -156,26 +163,27 @@
|
||||
},
|
||||
"direction_bias_ratio": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"description": "方向偏置比例,决定多空倾向强弱。"
|
||||
"min": 0.55,
|
||||
"max": 0.9,
|
||||
"description": "方向偏置比例,决定多空倾向强弱;手动页面范围 0.55~0.90,通常以 55%~90% 展示。"
|
||||
},
|
||||
"max_drawdown_pct": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "最大回撤百分比止损,范围 0~100。"
|
||||
"min": 5,
|
||||
"max": 50,
|
||||
"description": "网格策略最大回撤百分比,手动页面范围 5~50。"
|
||||
},
|
||||
"stop_loss_pct": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "止损百分比,范围 0~100。"
|
||||
"min": 1,
|
||||
"max": 20,
|
||||
"description": "网格策略止损百分比,手动页面范围 1~20。"
|
||||
},
|
||||
"daily_loss_limit_pct": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "每日最大亏损比例,达到后当天停止新开仓。"
|
||||
"min": 1,
|
||||
"max": 30,
|
||||
"description": "网格策略每日最大亏损比例,手动页面范围 1~30,达到后当天停止新开仓。"
|
||||
},
|
||||
"use_maker_only": {
|
||||
"type": "bool",
|
||||
@@ -190,7 +198,8 @@
|
||||
"ai500_limit": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "AI500 榜单选取数量。"
|
||||
"max": 10,
|
||||
"description": "AI500 榜单选取数量,手动页面范围 1~10。"
|
||||
},
|
||||
"use_oi_top": {
|
||||
"type": "bool",
|
||||
@@ -200,7 +209,8 @@
|
||||
"oi_top_limit": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "OI Top 选取数量。"
|
||||
"max": 10,
|
||||
"description": "OI Top 选取数量,手动页面范围 1~10。"
|
||||
},
|
||||
"use_oi_low": {
|
||||
"type": "bool",
|
||||
@@ -210,12 +220,14 @@
|
||||
"oi_low_limit": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "OI Low 选取数量。"
|
||||
"max": 10,
|
||||
"description": "OI Low 选取数量,手动页面范围 1~10。"
|
||||
},
|
||||
"primary_count": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "主周期样本数量。"
|
||||
"min": 10,
|
||||
"max": 30,
|
||||
"description": "主周期 K 线样本数量,手动页面范围 10~30。"
|
||||
},
|
||||
"enable_ema": {
|
||||
"type": "bool",
|
||||
@@ -298,13 +310,15 @@
|
||||
"description": "是否启用 OI 排行榜。"
|
||||
},
|
||||
"oi_ranking_duration": {
|
||||
"type": "string",
|
||||
"description": "OI 排行榜统计周期。"
|
||||
"type": "enum",
|
||||
"values": ["1h", "4h", "24h"],
|
||||
"description": "OI 排行榜统计周期,页面选项为 1h、4h、24h。"
|
||||
},
|
||||
"oi_ranking_limit": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "OI 排行榜返回数量。"
|
||||
"min": 5,
|
||||
"max": 20,
|
||||
"description": "OI 排行榜返回数量,页面选项为 5、10、15、20。"
|
||||
},
|
||||
"enable_netflow_ranking": {
|
||||
"type": "bool",
|
||||
@@ -312,13 +326,15 @@
|
||||
"description": "是否启用净流入排行榜。"
|
||||
},
|
||||
"netflow_ranking_duration": {
|
||||
"type": "string",
|
||||
"description": "净流入排行榜统计周期。"
|
||||
"type": "enum",
|
||||
"values": ["1h", "4h", "24h"],
|
||||
"description": "净流入排行榜统计周期,页面选项为 1h、4h、24h。"
|
||||
},
|
||||
"netflow_ranking_limit": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "净流入排行榜返回数量。"
|
||||
"min": 5,
|
||||
"max": 20,
|
||||
"description": "净流入排行榜返回数量,页面选项为 5、10、15、20。"
|
||||
},
|
||||
"enable_price_ranking": {
|
||||
"type": "bool",
|
||||
@@ -326,42 +342,52 @@
|
||||
"description": "是否启用价格波动排行榜。"
|
||||
},
|
||||
"price_ranking_duration": {
|
||||
"type": "string",
|
||||
"description": "价格排行榜统计周期。"
|
||||
"type": "enum",
|
||||
"values": ["1h", "4h", "24h", "1h,4h,24h"],
|
||||
"description": "价格排行榜统计周期,页面选项为 1h、4h、24h、1h,4h,24h。"
|
||||
},
|
||||
"price_ranking_limit": {
|
||||
"type": "int",
|
||||
"min": 1,
|
||||
"description": "价格排行榜返回数量。"
|
||||
"min": 5,
|
||||
"max": 20,
|
||||
"description": "价格排行榜返回数量,页面选项为 5、10、15、20。"
|
||||
},
|
||||
"btceth_max_position_value_ratio": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"description": "BTC/ETH 单仓最大仓位价值占比。"
|
||||
"description": "BTC/ETH 单仓最大仓位价值占比。策略页展示为 System enforced,不是普通手动可编辑项;除非用户明确要求高级配置,否则不要主动修改。"
|
||||
},
|
||||
"altcoin_max_position_value_ratio": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"description": "山寨币单仓最大仓位价值占比。"
|
||||
"description": "山寨币单仓最大仓位价值占比。策略页展示为 System enforced,不是普通手动可编辑项;除非用户明确要求高级配置,否则不要主动修改。"
|
||||
},
|
||||
"max_margin_usage": {
|
||||
"type": "float",
|
||||
"min": 0,
|
||||
"description": "最大保证金占用比例。"
|
||||
"description": "最大保证金占用比例。策略页展示为 System enforced,不是普通手动可编辑项;除非用户明确要求高级配置,否则不要主动修改。"
|
||||
}
|
||||
},
|
||||
"validation_rules": [
|
||||
"AI 策略页面包含:选币来源、指标/K线/量化数据、风险控制、Prompt Sections、自定义提示词、发布设置。",
|
||||
"网格策略页面包含:symbol、grid_count、total_investment、leverage、upper_price、lower_price、use_atr_bounds、atr_multiplier、distribution、max_drawdown_pct、stop_loss_pct、daily_loss_limit_pct、use_maker_only、enable_direction_adjust、direction_bias_ratio、发布设置。",
|
||||
"用户询问字段有哪些选项、范围或页面能不能设置时,应直接按本 skill 的 field_constraints 回答;不要说“平台会自动匹配”或编造页面不存在的字段。",
|
||||
"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。",
|
||||
"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,超出时自动收敛并告知用户。",
|
||||
"grid_trading 的 leverage 需与手动页面一致,范围 1~5,超出时自动收敛并告知用户。",
|
||||
"min_confidence 范围 0~100,超出时自动收敛并告知用户。",
|
||||
"min_confidence 范围 50~100,超出时自动收敛并告知用户。",
|
||||
"grid_trading 类型时,lower_price 必须小于 upper_price,否则提示用户修正。",
|
||||
"grid_count 最小为 2,低于 2 时提示用户修正。",
|
||||
"策略模板不能直接启动运行,只有绑定了该策略的交易员才能启动。",
|
||||
"策略模板创建成功后应出现在策略列表/策略页。",
|
||||
"创建策略模板时不要要求用户先绑定或添加交易所账户,也不要要求绑定 AI 模型;交易所和模型只属于 trader 创建、部署或启动流程。",
|
||||
"如果用户只是创建或配置策略模板,不要把它升级成 trader 创建流程。",
|
||||
"删除操作不可逆,必须先向用户确认再执行。",
|
||||
"激活(activate)操作将该策略设为默认模板,不是启动运行。",
|
||||
"scan_interval_minutes、initial_balance、lighter_api_key_index 这类交易员/交易所边界值不属于策略本身,若用户在改策略时提到,应引导去对应 trader 或 exchange 配置。",
|
||||
"btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage 等风控字段若越界,应先自动收敛或提示用户确认修正后的值。",
|
||||
"最小开仓金额是系统固定值 12 USDT,Agent 不能修改;若用户要求改这个值,应直接说明这是手动面板中的 System enforced 固定项。",
|
||||
"max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size 在策略页属于 System enforced / 非普通手动编辑项;若用户问页面能否改,应说明页面不提供普通编辑入口。min_position_size 固定为 12 USDT,Agent 不能修改。",
|
||||
"启用量化数据相关开关时,若需要 nofxos_api_key,应主动提醒用户补齐。",
|
||||
"启用排行榜相关能力时,只修改用户明确提到的 enable_*、duration、limit 字段,不要偷偷打开其他排行榜。"
|
||||
],
|
||||
@@ -374,7 +400,10 @@
|
||||
"dynamic_rules": [
|
||||
"若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。",
|
||||
"若用户明确要求新建策略,至少先收齐名称;其他配置可继续追问或按默认值协助补齐。",
|
||||
"策略模板不能直接启动运行;本动作成功后通常返回 strategy_id 供后续 trader 绑定使用。",
|
||||
"创建策略模板本身不需要交易所账户或 AI 模型;不要在 create 策略时询问用户是否已有交易所账户。",
|
||||
"只有当用户明确要求运行、部署、实盘、创建交易员或绑定到交易员时,才进入 trader 流程并收集交易所/模型。",
|
||||
"策略模板创建成功后应出现在策略列表/策略页。",
|
||||
"只有用户要运行或部署时,才继续 trader 绑定。",
|
||||
"杠杆超出 1~20 范围时,自动收敛并告知用户。"
|
||||
],
|
||||
"success_output": "返回 strategy_id 和新策略摘要(名称、类型、主要配置)。",
|
||||
|
||||
@@ -74,6 +74,91 @@ func manualStrategyEditableFieldKeys() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func manualStrategyEditableFieldKeysForType(strategyType string) []string {
|
||||
common := []string{
|
||||
"name",
|
||||
"description",
|
||||
"is_public",
|
||||
"config_visible",
|
||||
"strategy_type",
|
||||
}
|
||||
switch strategyType {
|
||||
case "grid_trading":
|
||||
return append(common,
|
||||
"symbol",
|
||||
"grid_count",
|
||||
"total_investment",
|
||||
"leverage",
|
||||
"upper_price",
|
||||
"lower_price",
|
||||
"use_atr_bounds",
|
||||
"atr_multiplier",
|
||||
"distribution",
|
||||
"enable_direction_adjust",
|
||||
"direction_bias_ratio",
|
||||
"max_drawdown_pct",
|
||||
"stop_loss_pct",
|
||||
"daily_loss_limit_pct",
|
||||
"use_maker_only",
|
||||
)
|
||||
case "ai_trading":
|
||||
return append(common,
|
||||
"source_type",
|
||||
"static_coins",
|
||||
"excluded_coins",
|
||||
"use_ai500",
|
||||
"ai500_limit",
|
||||
"use_oi_top",
|
||||
"oi_top_limit",
|
||||
"use_oi_low",
|
||||
"oi_low_limit",
|
||||
"primary_timeframe",
|
||||
"primary_count",
|
||||
"selected_timeframes",
|
||||
"enable_ema",
|
||||
"enable_macd",
|
||||
"enable_rsi",
|
||||
"enable_atr",
|
||||
"enable_boll",
|
||||
"enable_volume",
|
||||
"enable_oi",
|
||||
"enable_funding_rate",
|
||||
"ema_periods",
|
||||
"rsi_periods",
|
||||
"atr_periods",
|
||||
"boll_periods",
|
||||
"nofxos_api_key",
|
||||
"enable_quant_data",
|
||||
"enable_quant_oi",
|
||||
"enable_quant_netflow",
|
||||
"enable_oi_ranking",
|
||||
"oi_ranking_duration",
|
||||
"oi_ranking_limit",
|
||||
"enable_netflow_ranking",
|
||||
"netflow_ranking_duration",
|
||||
"netflow_ranking_limit",
|
||||
"enable_price_ranking",
|
||||
"price_ranking_duration",
|
||||
"price_ranking_limit",
|
||||
"btceth_max_leverage",
|
||||
"altcoin_max_leverage",
|
||||
"max_positions",
|
||||
"btceth_max_position_value_ratio",
|
||||
"altcoin_max_position_value_ratio",
|
||||
"max_margin_usage",
|
||||
"min_risk_reward_ratio",
|
||||
"min_confidence",
|
||||
"role_definition",
|
||||
"trading_frequency",
|
||||
"entry_standards",
|
||||
"decision_process",
|
||||
"custom_prompt",
|
||||
)
|
||||
default:
|
||||
return manualStrategyEditableFieldKeys()
|
||||
}
|
||||
}
|
||||
|
||||
func agentStrategyUpdatableFieldKeys() []string {
|
||||
return []string{
|
||||
"name",
|
||||
|
||||
233
agent/tools.go
233
agent/tools.go
@@ -161,125 +161,6 @@ func looksLikeStrategyMutationIntent(text string) bool {
|
||||
containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "修改", "更新", "编辑", "调整", "配置", "create", "new", "update", "edit", "configure"})
|
||||
}
|
||||
|
||||
type strategyConfigPatchValidation struct {
|
||||
Config map[string]any
|
||||
ChangedFields []string
|
||||
UnchangedDefaults []string
|
||||
RejectedFields []string
|
||||
}
|
||||
|
||||
func validateStrategyConfigPatch(config map[string]any) strategyConfigPatchValidation {
|
||||
out := strategyConfigPatchValidation{
|
||||
Config: map[string]any{},
|
||||
}
|
||||
if len(config) == 0 {
|
||||
out.UnchangedDefaults = defaultStrategyConfigSections()
|
||||
return out
|
||||
}
|
||||
schema := strategyConfigSchema()
|
||||
props, _ := schema["properties"].(map[string]any)
|
||||
for key, value := range config {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
prop, ok := props[key]
|
||||
if !ok {
|
||||
out.RejectedFields = append(out.RejectedFields, key+" (not in current strategy config)")
|
||||
continue
|
||||
}
|
||||
cleaned, changed, rejected := sanitizeStrategyConfigValue(key, value, prop)
|
||||
out.RejectedFields = append(out.RejectedFields, rejected...)
|
||||
if len(changed) == 0 {
|
||||
continue
|
||||
}
|
||||
out.Config[key] = cleaned
|
||||
out.ChangedFields = append(out.ChangedFields, changed...)
|
||||
}
|
||||
out.UnchangedDefaults = unchangedStrategyDefaults(out.ChangedFields)
|
||||
sort.Strings(out.ChangedFields)
|
||||
sort.Strings(out.UnchangedDefaults)
|
||||
sort.Strings(out.RejectedFields)
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeStrategyConfigValue(path string, value any, schema any) (any, []string, []string) {
|
||||
schemaMap, _ := schema.(map[string]any)
|
||||
if schemaMap == nil {
|
||||
return value, []string{path}, nil
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(fmt.Sprint(schemaMap["type"])), "object") {
|
||||
props, _ := schemaMap["properties"].(map[string]any)
|
||||
if len(props) == 0 {
|
||||
return value, []string{path}, nil
|
||||
}
|
||||
valueMap, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
if typed, ok := value.(map[string]string); ok {
|
||||
valueMap = make(map[string]any, len(typed))
|
||||
for k, v := range typed {
|
||||
valueMap[k] = v
|
||||
}
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, nil, []string{path + " (expected object)"}
|
||||
}
|
||||
out := make(map[string]any, len(valueMap))
|
||||
var changed []string
|
||||
var rejected []string
|
||||
for key, nestedValue := range valueMap {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
nestedPath := path + "." + key
|
||||
prop, ok := props[key]
|
||||
if !ok {
|
||||
rejected = append(rejected, nestedPath+" (not in current strategy config)")
|
||||
continue
|
||||
}
|
||||
cleaned, nestedChanged, nestedRejected := sanitizeStrategyConfigValue(nestedPath, nestedValue, prop)
|
||||
rejected = append(rejected, nestedRejected...)
|
||||
if len(nestedChanged) == 0 {
|
||||
continue
|
||||
}
|
||||
out[key] = cleaned
|
||||
changed = append(changed, nestedChanged...)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil, rejected
|
||||
}
|
||||
return out, changed, rejected
|
||||
}
|
||||
return value, []string{path}, nil
|
||||
}
|
||||
|
||||
func defaultStrategyConfigSections() []string {
|
||||
return []string{"strategy_type", "language", "coin_source", "indicators", "custom_prompt", "risk_control", "prompt_sections", "grid_config"}
|
||||
}
|
||||
|
||||
func unchangedStrategyDefaults(changedFields []string) []string {
|
||||
changedTop := make(map[string]bool, len(changedFields))
|
||||
for _, field := range changedFields {
|
||||
top := strings.TrimSpace(field)
|
||||
if idx := strings.Index(top, "."); idx >= 0 {
|
||||
top = top[:idx]
|
||||
}
|
||||
if top != "" {
|
||||
changedTop[top] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(defaultStrategyConfigSections()))
|
||||
for _, section := range defaultStrategyConfigSections() {
|
||||
if !changedTop[section] {
|
||||
out = append(out, section)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizedEntityName(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
@@ -390,20 +271,20 @@ func strategyConfigSchema() map[string]any {
|
||||
"type": "object",
|
||||
"description": "Full or partial strategy config. Only include the fields you want to create or update.",
|
||||
"properties": map[string]any{
|
||||
"strategy_type": map[string]any{"type": "string", "enum": []string{"ai_trading", "grid_trading"}},
|
||||
"strategy_type": map[string]any{"type": "string", "enum": []string{"ai_trading", "grid_trading"}, "description": "ai_trading uses coin source, indicators, risk_control, and prompts. grid_trading uses grid_config and publish settings."},
|
||||
"language": map[string]any{"type": "string", "enum": []string{"zh", "en"}},
|
||||
"coin_source": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"source_type": map[string]any{"type": "string", "enum": []string{"static", "ai500", "oi_top", "oi_low", "mixed"}},
|
||||
"static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT."),
|
||||
"source_type": map[string]any{"type": "string", "enum": []string{"static", "ai500", "oi_top", "oi_low", "mixed"}, "description": "Manual page coin source: static, ai500, oi_top, oi_low; mixed can be displayed when already configured."},
|
||||
"static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT. Manual page allows at most 10. xyz: assets such as xyz:TSLA, xyz:GOLD, xyz:XYZ100 are also supported."),
|
||||
"excluded_coins": stringArraySchema("Coin symbols to exclude from all sources."),
|
||||
"use_ai500": map[string]any{"type": "boolean"},
|
||||
"ai500_limit": map[string]any{"type": "number"},
|
||||
"ai500_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
|
||||
"use_oi_top": map[string]any{"type": "boolean"},
|
||||
"oi_top_limit": map[string]any{"type": "number"},
|
||||
"oi_top_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
|
||||
"use_oi_low": map[string]any{"type": "boolean"},
|
||||
"oi_low_limit": map[string]any{"type": "number"},
|
||||
"oi_low_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
|
||||
"use_hyper_all": map[string]any{"type": "boolean"},
|
||||
"use_hyper_main": map[string]any{"type": "boolean"},
|
||||
"hyper_main_limit": map[string]any{"type": "number"},
|
||||
@@ -415,12 +296,12 @@ func strategyConfigSchema() map[string]any {
|
||||
"klines": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"primary_timeframe": map[string]any{"type": "string"},
|
||||
"primary_count": map[string]any{"type": "number"},
|
||||
"primary_timeframe": map[string]any{"type": "string", "enum": []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}},
|
||||
"primary_count": map[string]any{"type": "number", "minimum": 10, "maximum": 30, "description": "Manual page range 10-30."},
|
||||
"longer_timeframe": map[string]any{"type": "string"},
|
||||
"longer_count": map[string]any{"type": "number"},
|
||||
"enable_multi_timeframe": map[string]any{"type": "boolean"},
|
||||
"selected_timeframes": stringArraySchema("Selected analysis timeframes, e.g. 5m,15m,1h."),
|
||||
"selected_timeframes": stringArraySchema("Selected analysis timeframes. Allowed values: 1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w. Manual page allows at most 4."),
|
||||
},
|
||||
},
|
||||
"enable_raw_klines": map[string]any{"type": "boolean"},
|
||||
@@ -441,28 +322,28 @@ func strategyConfigSchema() map[string]any {
|
||||
"enable_quant_oi": map[string]any{"type": "boolean"},
|
||||
"enable_quant_netflow": map[string]any{"type": "boolean"},
|
||||
"enable_oi_ranking": map[string]any{"type": "boolean"},
|
||||
"oi_ranking_duration": map[string]any{"type": "string"},
|
||||
"oi_ranking_limit": map[string]any{"type": "number"},
|
||||
"oi_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h"}},
|
||||
"oi_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
|
||||
"enable_netflow_ranking": map[string]any{"type": "boolean"},
|
||||
"netflow_ranking_duration": map[string]any{"type": "string"},
|
||||
"netflow_ranking_limit": map[string]any{"type": "number"},
|
||||
"netflow_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h"}},
|
||||
"netflow_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
|
||||
"enable_price_ranking": map[string]any{"type": "boolean"},
|
||||
"price_ranking_duration": map[string]any{"type": "string"},
|
||||
"price_ranking_limit": map[string]any{"type": "number"},
|
||||
"price_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h", "1h,4h,24h"}},
|
||||
"price_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
|
||||
},
|
||||
},
|
||||
"custom_prompt": map[string]any{"type": "string"},
|
||||
"risk_control": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"max_positions": map[string]any{"type": "number"},
|
||||
"btc_eth_max_leverage": map[string]any{"type": "number"},
|
||||
"altcoin_max_leverage": map[string]any{"type": "number"},
|
||||
"btc_eth_max_position_value_ratio": map[string]any{"type": "number"},
|
||||
"altcoin_max_position_value_ratio": map[string]any{"type": "number"},
|
||||
"max_margin_usage": map[string]any{"type": "number"},
|
||||
"min_risk_reward_ratio": map[string]any{"type": "number"},
|
||||
"min_confidence": map[string]any{"type": "number"},
|
||||
"max_positions": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless the user explicitly asks for advanced configuration."},
|
||||
"btc_eth_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
|
||||
"altcoin_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
|
||||
"btc_eth_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
|
||||
"altcoin_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
|
||||
"max_margin_usage": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
|
||||
"min_risk_reward_ratio": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10, step 0.5."},
|
||||
"min_confidence": map[string]any{"type": "number", "minimum": 50, "maximum": 100, "description": "Manual page range 50-100."},
|
||||
},
|
||||
},
|
||||
"prompt_sections": map[string]any{
|
||||
@@ -477,21 +358,21 @@ func strategyConfigSchema() map[string]any {
|
||||
"grid_config": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"symbol": map[string]any{"type": "string"},
|
||||
"grid_count": map[string]any{"type": "number"},
|
||||
"total_investment": map[string]any{"type": "number"},
|
||||
"leverage": map[string]any{"type": "number"},
|
||||
"symbol": map[string]any{"type": "string", "enum": []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"}, "description": "Manual page dropdown options for grid trading symbols."},
|
||||
"grid_count": map[string]any{"type": "number", "minimum": 5, "maximum": 50, "description": "Manual page range 5-50."},
|
||||
"total_investment": map[string]any{"type": "number", "minimum": 100, "description": "Manual page minimum 100 USDT."},
|
||||
"leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 5, "description": "Manual page range 1-5."},
|
||||
"upper_price": map[string]any{"type": "number"},
|
||||
"lower_price": map[string]any{"type": "number"},
|
||||
"use_atr_bounds": map[string]any{"type": "boolean"},
|
||||
"atr_multiplier": map[string]any{"type": "number"},
|
||||
"atr_multiplier": map[string]any{"type": "number", "minimum": 1, "maximum": 5, "description": "Manual page range 1-5, step 0.5."},
|
||||
"distribution": map[string]any{"type": "string", "enum": []string{"uniform", "gaussian", "pyramid"}},
|
||||
"max_drawdown_pct": map[string]any{"type": "number"},
|
||||
"stop_loss_pct": map[string]any{"type": "number"},
|
||||
"daily_loss_limit_pct": map[string]any{"type": "number"},
|
||||
"max_drawdown_pct": map[string]any{"type": "number", "minimum": 5, "maximum": 50, "description": "Manual page range 5-50."},
|
||||
"stop_loss_pct": map[string]any{"type": "number", "minimum": 1, "maximum": 20, "description": "Manual page range 1-20."},
|
||||
"daily_loss_limit_pct": map[string]any{"type": "number", "minimum": 1, "maximum": 30, "description": "Manual page range 1-30."},
|
||||
"use_maker_only": map[string]any{"type": "boolean"},
|
||||
"enable_direction_adjust": map[string]any{"type": "boolean"},
|
||||
"direction_bias_ratio": map[string]any{"type": "number"},
|
||||
"direction_bias_ratio": map[string]any{"type": "number", "minimum": 0.55, "maximum": 0.9, "description": "Manual page range 0.55-0.90 (shown as 55%-90%)."},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2012,15 +1893,14 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
||||
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
|
||||
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
|
||||
}
|
||||
validation := validateStrategyConfigPatch(args.Config)
|
||||
if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil {
|
||||
return fmt.Sprintf(`{"error":"%s"}`, err)
|
||||
}
|
||||
defaultConfig := store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang))
|
||||
var cfg any = defaultConfig
|
||||
var warnings []string
|
||||
if len(validation.Config) > 0 {
|
||||
merged, err := store.MergeStrategyConfig(defaultConfig, validation.Config)
|
||||
if len(args.Config) > 0 {
|
||||
merged, err := store.MergeStrategyConfig(defaultConfig, args.Config)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err)
|
||||
}
|
||||
@@ -2051,14 +1931,10 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
||||
return fmt.Sprintf(`{"error":"failed to create strategy: %s"}`, err)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"status": "ok",
|
||||
"action": "create",
|
||||
"created_strategy_id": record.ID,
|
||||
"strategy": safeStrategyForTool(record),
|
||||
"changed_fields": validation.ChangedFields,
|
||||
"unchanged_defaults": validation.UnchangedDefaults,
|
||||
"rejected_fields": validation.RejectedFields,
|
||||
"warnings": warnings,
|
||||
"status": "ok",
|
||||
"action": "create",
|
||||
"strategy": safeStrategyForTool(record),
|
||||
"warnings": warnings,
|
||||
})
|
||||
return string(payload)
|
||||
case "update":
|
||||
@@ -2069,7 +1945,6 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
||||
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
|
||||
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
|
||||
}
|
||||
validation := validateStrategyConfigPatch(args.Config)
|
||||
existing, err := a.store.Strategy().Get(storeUserID, strategyID)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err)
|
||||
@@ -2098,29 +1973,16 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
||||
if args.ConfigVisible != nil {
|
||||
configVisible = *args.ConfigVisible
|
||||
}
|
||||
metadataChanged := make([]string, 0, 4)
|
||||
if !sameEntityName(name, existing.Name) {
|
||||
metadataChanged = append(metadataChanged, "name")
|
||||
}
|
||||
if description != existing.Description {
|
||||
metadataChanged = append(metadataChanged, "description")
|
||||
}
|
||||
if isPublic != existing.IsPublic {
|
||||
metadataChanged = append(metadataChanged, "is_public")
|
||||
}
|
||||
if configVisible != existing.ConfigVisible {
|
||||
metadataChanged = append(metadataChanged, "config_visible")
|
||||
}
|
||||
configJSON := existing.Config
|
||||
var warnings []string
|
||||
if len(validation.Config) > 0 {
|
||||
if len(args.Config) > 0 {
|
||||
var existingConfig store.StrategyConfig
|
||||
if strings.TrimSpace(existing.Config) != "" {
|
||||
if err := json.Unmarshal([]byte(existing.Config), &existingConfig); err != nil {
|
||||
return fmt.Sprintf(`{"error":"failed to load existing strategy config: %s"}`, err)
|
||||
}
|
||||
}
|
||||
merged, err := store.MergeStrategyConfig(existingConfig, validation.Config)
|
||||
merged, err := store.MergeStrategyConfig(existingConfig, args.Config)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err)
|
||||
}
|
||||
@@ -2152,18 +2014,11 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"strategy updated but failed to reload: %s"}`, err)
|
||||
}
|
||||
changedFields := append([]string{}, metadataChanged...)
|
||||
changedFields = append(changedFields, validation.ChangedFields...)
|
||||
sort.Strings(changedFields)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"status": "ok",
|
||||
"action": "update",
|
||||
"strategy_id": updated.ID,
|
||||
"strategy": safeStrategyForTool(updated),
|
||||
"changed_fields": changedFields,
|
||||
"unchanged_defaults": validation.UnchangedDefaults,
|
||||
"rejected_fields": validation.RejectedFields,
|
||||
"warnings": warnings,
|
||||
"status": "ok",
|
||||
"action": "update",
|
||||
"strategy": safeStrategyForTool(updated),
|
||||
"warnings": warnings,
|
||||
})
|
||||
return string(payload)
|
||||
case "delete":
|
||||
|
||||
@@ -553,6 +553,7 @@ func TestStrategyCreateUsesConfigPatch(t *testing.T) {
|
||||
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||
|
||||
patch := map[string]any{
|
||||
"strategy_type": "ai_trading",
|
||||
"coin_source": map[string]any{
|
||||
"source_type": "static",
|
||||
"static_coins": []any{"BTCUSDT"},
|
||||
@@ -620,6 +621,258 @@ func TestStrategyCreateUsesConfigPatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateAsksTypeBeforeUsingDefaultTemplateType(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-create-ask-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, "先选择策略类型") || strings.Contains(reply, "交易所") {
|
||||
t.Fatalf("expected strategy type question without exchange binding, 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 confirmed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateWaitsForGridConfigBeforeCreate(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-draft.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",
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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 grid config is ready")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateGridDraftSummaryDoesNotMentionAIFields(t *testing.T) {
|
||||
reply := formatStrategyCreateDraftSummary("zh", "我的网格策略", "grid_trading", nil, nil)
|
||||
for _, unexpected := range []string{"选币来源", "最大持仓", "置信度", "盈亏比", "多周期"} {
|
||||
if strings.Contains(reply, unexpected) {
|
||||
t.Fatalf("grid draft summary should not mention AI-only field %q: %s", unexpected, reply)
|
||||
}
|
||||
}
|
||||
for _, expected := range []string{"网格策略", "交易对", "网格数量", "总投入", "杠杆", "价格区间"} {
|
||||
if !strings.Contains(reply, expected) {
|
||||
t.Fatalf("grid draft summary should mention %q, got: %s", expected, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedStrategyCreateFieldsFollowSelectedStrategyType(t *testing.T) {
|
||||
gridSession := skillSession{
|
||||
Name: "strategy_management",
|
||||
Action: "create",
|
||||
Fields: map[string]string{
|
||||
"strategy_type": "grid_trading",
|
||||
},
|
||||
}
|
||||
gridSpecs := allowedFieldSpecsForSkillSession(gridSession, "zh")
|
||||
gridKeys := make(map[string]bool, len(gridSpecs))
|
||||
for _, spec := range gridSpecs {
|
||||
gridKeys[spec.Key] = true
|
||||
}
|
||||
for _, expected := range []string{"symbol", "grid_count", "total_investment", "leverage", "max_drawdown_pct"} {
|
||||
if !gridKeys[expected] {
|
||||
t.Fatalf("expected grid field %q in specs", expected)
|
||||
}
|
||||
}
|
||||
for _, unexpected := range []string{"source_type", "selected_timeframes", "min_confidence", "min_risk_reward_ratio"} {
|
||||
if gridKeys[unexpected] {
|
||||
t.Fatalf("grid specs should not expose AI-only field %q", unexpected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateReadyConfigRequiresFinalConfirmation(t *testing.T) {
|
||||
patch := map[string]any{
|
||||
"strategy_type": "grid_trading",
|
||||
"grid_config": map[string]any{
|
||||
"symbol": "BTCUSDT",
|
||||
"grid_count": 20,
|
||||
"total_investment": 200,
|
||||
"leverage": 2,
|
||||
"use_atr_bounds": true,
|
||||
"atr_multiplier": 2,
|
||||
"distribution": "uniform",
|
||||
"max_drawdown_pct": 15,
|
||||
"stop_loss_pct": 8,
|
||||
"daily_loss_limit_pct": 6,
|
||||
"use_maker_only": true,
|
||||
"enable_direction_adjust": false,
|
||||
},
|
||||
}
|
||||
rawPatch, _ := json.Marshal(patch)
|
||||
session := ActiveSkillSession{
|
||||
SkillName: "strategy_management",
|
||||
ActionName: "create",
|
||||
CollectedFields: map[string]any{
|
||||
"name": "小白策略",
|
||||
"strategy_type": "grid_trading",
|
||||
strategyCreateConfigPatchField: string(rawPatch),
|
||||
},
|
||||
}
|
||||
|
||||
reply, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session)
|
||||
if !blocked {
|
||||
t.Fatalf("expected ready strategy create config to require final confirmation")
|
||||
}
|
||||
if !strings.Contains(reply, "确认后我再创建") || !strings.Contains(reply, "BTCUSDT") || !strings.Contains(reply, "20") {
|
||||
t.Fatalf("expected final confirmation summary, got: %s", reply)
|
||||
}
|
||||
|
||||
session.CollectedFields["awaiting_final_confirmation"] = true
|
||||
if _, blocked := guardStrategyCreateBeforeFinalConfirmation("zh", session); blocked {
|
||||
t.Fatalf("already-confirmable session should not be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateCreatesGridAfterConfigPatch(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-ready.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": 12,
|
||||
"total_investment": 1000,
|
||||
"leverage": 3,
|
||||
"use_atr_bounds": true,
|
||||
"atr_multiplier": 2,
|
||||
"distribution": "gaussian",
|
||||
},
|
||||
}
|
||||
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 created *store.Strategy
|
||||
for _, strategy := range strategies {
|
||||
if strategy.Name == "我的网格策略" {
|
||||
created = strategy
|
||||
break
|
||||
}
|
||||
}
|
||||
if created == nil {
|
||||
t.Fatalf("expected grid strategy to be created")
|
||||
}
|
||||
var cfg store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
if cfg.StrategyType != "grid_trading" || cfg.GridConfig == nil || cfg.GridConfig.Symbol != "ETHUSDT" {
|
||||
t.Fatalf("expected grid config to persist, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyCreateGridPatchInfersStrategyType(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "strategy-grid-create-infers-type.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{
|
||||
"grid_config": map[string]any{
|
||||
"symbol": "BTCUSDT",
|
||||
"grid_count": 20,
|
||||
"total_investment": 200,
|
||||
"leverage": 2,
|
||||
"use_atr_bounds": true,
|
||||
"atr_multiplier": 2,
|
||||
},
|
||||
}
|
||||
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.StrategyType != "grid_trading" || cfg.GridConfig == nil || cfg.GridConfig.Symbol != "BTCUSDT" {
|
||||
t.Fatalf("expected grid patch to infer grid_trading, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMFlowExtractionFiltersFieldsToAllowedSchema(t *testing.T) {
|
||||
result := llmFlowExtractionResult{
|
||||
Intent: "continue",
|
||||
|
||||
@@ -2,8 +2,11 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestParseUnifiedTurnDecisionNormalizesContextPolicy(t *testing.T) {
|
||||
@@ -139,6 +142,62 @@ func TestExecuteUnifiedTurnDecisionDirectAnswerRecordsHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedTurnDecisionContinueActiveDoesNotHandOffToPlanner(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "continue-active-router.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
a := New(nil, st, DefaultConfig(), nil)
|
||||
userID := int64(102)
|
||||
|
||||
session := newActiveSkillSession(userID, "strategy_management", "create")
|
||||
session.Goal = "创建网格策略"
|
||||
session.CollectedFields["name"] = "我的网格策略"
|
||||
session.CollectedFields["strategy_type"] = "grid_trading"
|
||||
setActiveSessionPendingHint(&session, "现在还需要确认网格交易对、网格数量、总投入、杠杆和价格区间。")
|
||||
a.saveActiveSkillSession(session)
|
||||
|
||||
decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{
|
||||
TopicIntent: "continue_active",
|
||||
BusinessAction: "planned_agent",
|
||||
ContextMode: "use_current",
|
||||
Confidence: 0.9,
|
||||
})
|
||||
answer, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "那你帮我创吧", decision, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("execute unified decision: %v", err)
|
||||
}
|
||||
if !handled {
|
||||
t.Fatal("expected active session continuation to be handled")
|
||||
}
|
||||
if !strings.Contains(answer, "先不创建空模板") || 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 {
|
||||
t.Fatalf("expected strategy active session to remain pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardUnexecutedActiveTaskCompletionBlocksCreationClaim(t *testing.T) {
|
||||
session := ActiveSkillSession{
|
||||
SkillName: "strategy_management",
|
||||
ActionName: "create",
|
||||
}
|
||||
reply, blocked := guardUnexecutedActiveTaskCompletion("zh", session, "已经创建好了。策略现在就在你的策略列表里。")
|
||||
if !blocked {
|
||||
t.Fatalf("expected unexecuted active create completion claim to be blocked")
|
||||
}
|
||||
if !strings.Contains(reply, "还没有真正创建") {
|
||||
t.Fatalf("expected honest not-created reply, got: %s", reply)
|
||||
}
|
||||
|
||||
_, blocked = guardUnexecutedActiveTaskCompletion("zh", session, "我建议先用 BTCUSDT 做新手网格策略。")
|
||||
if blocked {
|
||||
t.Fatalf("non-completion proposal should not be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) {
|
||||
a := New(nil, nil, DefaultConfig(), nil)
|
||||
systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(42, "zh", "不是交易员,是策略")
|
||||
@@ -148,6 +207,7 @@ func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) {
|
||||
"downstream modules",
|
||||
"tasks format",
|
||||
"skill_tasks",
|
||||
"topic_intent as the primary decision",
|
||||
} {
|
||||
if !strings.Contains(systemPrompt, want) {
|
||||
t.Fatalf("expected system prompt to contain %q", want)
|
||||
|
||||
@@ -32,10 +32,13 @@ var (
|
||||
"no such host",
|
||||
"stream error", // HTTP/2 stream error
|
||||
"INTERNAL_ERROR", // Server internal error
|
||||
"status 502", // Bad Gateway
|
||||
"status 503", // Service Unavailable
|
||||
"status 520", // Cloudflare origin error
|
||||
"status 524", // Cloudflare timeout
|
||||
"status 429", // Rate limit / upstream gateway throttling
|
||||
"rate_limit_error",
|
||||
"upstream_empty_output",
|
||||
"status 502", // Bad Gateway
|
||||
"status 503", // Service Unavailable
|
||||
"status 520", // Cloudflare origin error
|
||||
"status 524", // Cloudflare timeout
|
||||
}
|
||||
|
||||
// TokenUsageCallback is called after each AI request with token usage info
|
||||
|
||||
@@ -345,6 +345,11 @@ func TestClient_IsRetryableError(t *testing.T) {
|
||||
err: errors.New("connection reset by peer"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "upstream empty output",
|
||||
err: errors.New(`API returned error (status 429): {"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","type":"rate_limit_error"}}`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "normal error",
|
||||
err: errors.New("bad request"),
|
||||
|
||||
@@ -410,6 +410,47 @@ func TestClient_CallWithRequest_RetrySleepStopsWhenContextCancelled(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallWithRequest_RetriesUpstreamEmptyOutput(t *testing.T) {
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
attempts := 0
|
||||
mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
body := `{"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","type":"rate_limit_error"}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok after retry"}}]}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
client := NewClient(
|
||||
WithHTTPClient(mockHTTP.ToHTTPClient()),
|
||||
WithLogger(NewMockLogger()),
|
||||
WithAPIKey("sk-test-key"),
|
||||
WithMaxRetries(2),
|
||||
WithRetryWaitBase(time.Millisecond),
|
||||
)
|
||||
request := NewRequestBuilder().WithUserPrompt("Hello").MustBuild()
|
||||
|
||||
result, err := client.CallWithRequest(request)
|
||||
if err != nil {
|
||||
t.Fatalf("should retry upstream empty output and succeed: %v", err)
|
||||
}
|
||||
if result != "ok after retry" {
|
||||
t.Fatalf("expected retry result, got %q", result)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallWithRequest_MultiRound(t *testing.T) {
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
mockHTTP.SetSuccessResponse("Multi-round response")
|
||||
|
||||
@@ -142,6 +142,7 @@ func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyCon
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
|
||||
normalizeStrategyConfigPatch(patch)
|
||||
mergeJSONMaps(mergedMap, patch)
|
||||
|
||||
mergedJSON, err := json.Marshal(mergedMap)
|
||||
@@ -156,6 +157,18 @@ func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyCon
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func normalizeStrategyConfigPatch(patch map[string]any) {
|
||||
if patch == nil {
|
||||
return
|
||||
}
|
||||
if _, hasType := patch["strategy_type"]; hasType {
|
||||
return
|
||||
}
|
||||
if gridConfig, hasGrid := patch["grid_config"]; hasGrid && gridConfig != nil {
|
||||
patch["strategy_type"] = "grid_trading"
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJSONMaps(dst, src map[string]any) {
|
||||
for key, srcVal := range src {
|
||||
srcMap, srcIsMap := srcVal.(map[string]any)
|
||||
|
||||
Reference in New Issue
Block a user