Improve active skill schema handling

This commit is contained in:
lky-spec
2026-04-26 11:58:29 +08:00
parent 9ee931ee30
commit 903eb591eb
25 changed files with 1401 additions and 673 deletions

View File

@@ -868,16 +868,33 @@ func aiServiceFailureGuidance(lang, reason string) string {
strings.Contains(lower, "unexpected character '<'") ||
strings.Contains(lower, "<html") ||
strings.Contains(lower, "<!doctype html")
looksLikeUpstreamEmptyOutput := strings.Contains(lower, "upstream_empty_output") ||
(strings.Contains(lower, "empty output") && strings.Contains(lower, "rate_limit_error"))
looksLikeRateLimit := strings.Contains(lower, "status 429") ||
strings.Contains(lower, "rate limit") ||
strings.Contains(lower, "rate_limit_error")
if lang == "zh" {
if looksLikeHTMLGateway {
return "这不是“未配置模型”。这次更像是上游返回了 HTML 页面或网关/反代错误页,而不是标准 JSON 响应。更可能原因是模型服务地址配错、网关拦截、支付/鉴权页返回、或上游服务临时异常。请优先检查当前启用模型的 custom_api_url、反向代理/网关状态,以及对应 provider 的服务状态。"
}
if looksLikeUpstreamEmptyOutput {
return "这不是“未配置模型”。这次更像是上游模型没有返回有效内容,当前 provider 把它包装成了 429 / rate_limit_error。更可能原因是上游临时限流、服务拥塞、模型空响应或 provider 网关没有拿到有效结果;不应优先归因成“余额不足”。请先重试一次;如果持续出现,再检查当前启用模型的 provider 状态、限流配额、网关日志,或先切换到另一个可用模型。"
}
if looksLikeRateLimit {
return "这不是“未配置模型”。这次更像是当前模型 provider 触发了限流或网关节流。更可能原因是并发过高、调用频率超限、provider 临时拥塞,或上游配额限制。请先稍后重试;如果持续出现,再检查当前启用模型的 provider 配额、限流策略和网关状态。"
}
return "这不是“未配置模型”。更可能是模型服务余额不足、接口报错、鉴权失败或超时。请检查当前启用模型的 API 状态后再试。"
}
if looksLikeHTMLGateway {
return "This is not a missing-model issue. It looks more like the upstream returned an HTML page or gateway/proxy error page instead of the expected JSON response. The likely causes are a wrong model endpoint URL, gateway interception, a payment/auth page being returned, or a temporary upstream outage. Check the active model's custom_api_url, proxy/gateway status, and the provider service health first."
}
if looksLikeUpstreamEmptyOutput {
return "This is not a missing-model issue. The upstream model appears to have returned no usable output, and the provider wrapped it as a 429 / rate_limit_error. The more likely causes are temporary throttling, upstream congestion, an empty model response, or a gateway that did not receive a valid result. Do not treat this as an insufficient-balance issue first. Retry once, then check the active provider status, rate limits, gateway logs, or switch to another model."
}
if looksLikeRateLimit {
return "This is not a missing-model issue. The active model provider more likely hit rate limiting or gateway throttling. Check the provider quota, rate-limit policy, and gateway status, then retry."
}
return "This is not a missing-model issue. The active model provider more likely returned an API error, authentication failure, timeout, or insufficient-balance response. Please check the active model API and try again."
}

View File

@@ -29,3 +29,26 @@ func TestAIServiceFailureHighlightsHTMLGatewayResponse(t *testing.T) {
t.Fatalf("html parse error should not use the generic balance/timeout-only guidance: %s", msg)
}
}
func TestAIServiceFailureHighlightsUpstreamEmptyOutputRateLimit(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 429): {"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","param":null,"type":"rate_limit_error"}}`))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"上游模型没有返回有效内容",
"不应优先归因成“余额不足”",
"切换到另一个可用模型",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "更可能是模型服务余额不足、接口报错、鉴权失败或超时") {
t.Fatalf("upstream empty output should not use the generic balance/auth/timeout guidance: %s", msg)
}
}

View File

@@ -12,7 +12,7 @@ import (
// brainDecision is the routing contract between the first-pass LLM and the executor.
type brainDecision struct {
ThoughtProcess string `json:"thought_process"`
ActionType string `json:"action_type"` // CONTINUE_TASK | NEW_TASK | EXPLAIN_KNOWLEDGE | CANCEL_TASK
ActionType string `json:"action_type"` // CONTINUE_TASK | NEW_TASK | EXPLAIN_KNOWLEDGE | CANCEL_TASK
TargetSkill string `json:"target_skill,omitempty"` // "skill_name:action" for NEW_TASK
ExtractedData map[string]any `json:"extracted_data,omitempty"`
ReplyToUser string `json:"reply_to_user"`
@@ -90,6 +90,7 @@ Rules:
- Domain guard: if the user says "模型", "AI 模型", or "model" and asks to create or configure one, you must route to model_management, not exchange_management.
- Domain guard: for model_management, the field "provider" means the AI model vendor such as OpenAI, DeepSeek, Claude, Gemini, Qwen, Kimi, Grok, Minimax, claw402, blockrun-base, or blockrun-sol. It never means an exchange like Binance, OKX, Bybit, CFD, forex, or metals.
- extracted_data should include any concrete facts from the user's message.
- When an active session exposes allowed_field_spec_json, extracted_data must use only those canonical keys. Never output aliases, translated labels, or raw user wording as keys.
- If the user clearly means a bulk destructive operation like "删除所有策略" or "全部删除策略", put the intent signal into extracted_data too. Example: {"bulk_scope":"all"}.
- reply_to_user should be concise and in the user's language.
- For NEW_TASK, target_skill format must be "skill_name:action", for example "strategy_management:create".
@@ -130,6 +131,11 @@ func buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, cur
sb.WriteString(missing)
sb.WriteString("\n")
}
fieldSpecs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(activeSession), lang)
if len(fieldSpecs) > 0 {
fieldSpecsJSON, _ := json.Marshal(fieldSpecs)
sb.WriteString(fmt.Sprintf("allowed_field_spec_json: %s\n", fieldSpecsJSON))
}
} else {
sb.WriteString("none\n")
}
@@ -234,6 +240,7 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us
}
session := newActiveSkillSession(userID, skill, action)
session.Goal = strings.TrimSpace(text)
d.ExtractedData = filterExtractedDataForActiveSession(session, d.ExtractedData, lang)
mergeExtractedData(&session, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
@@ -241,6 +248,7 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us
if !hasActive {
return "", false, nil
}
d.ExtractedData = filterExtractedDataForActiveSession(activeSession, d.ExtractedData, lang)
mergeExtractedData(&activeSession, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
@@ -366,6 +374,8 @@ func (a *Agent) planActiveSessionStep(ctx context.Context, storeUserID string, u
resourcesJSON, _ := json.Marshal(resources)
collectedJSON, _ := json.Marshal(session.CollectedFields)
missingSummary := formatConversationMissingFields(lang, missingRequiredFieldsForBrain(session))
fieldSpecs := allowedFieldSpecsForSkillSession(legacy, lang)
fieldSpecsJSON, _ := json.Marshal(fieldSpecs)
localHistory := formatActiveSessionLocalHistory(session.LocalHistory)
if localHistory == "" {
localHistory = "(empty)"
@@ -391,6 +401,9 @@ Current missing field summary:
Relevant disclosed resources:
%s
Allowed field spec JSON:
%s
Domain knowledge:
%s
@@ -406,6 +419,8 @@ Rules:
- If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it.
- 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.
- extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys.
- If a user-provided value does not fit one of those canonical keys, omit it; never create another key.
- If this task is already done and the best next step is just to tell the user the result, choose "finish_task".
- If the user aborts the task, choose "cancel_task".
@@ -417,6 +432,7 @@ Return JSON with this exact shape:
defaultIfEmpty(string(collectedJSON), "{}"),
missingSummary,
defaultIfEmpty(string(resourcesJSON), "{}"),
defaultIfEmpty(string(fieldSpecsJSON), "[]"),
defaultIfEmpty(domainPrimer, "(none)"),
))
userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nPrevious assistant reply:\n%s\n\nActive task local history:\n%s\n", lang, text, defaultIfEmpty(previousAssistantReply, "(empty)"), localHistory)
@@ -434,7 +450,12 @@ Return JSON with this exact shape:
if err != nil {
return activeSessionStepDecision{}, false
}
return parseActiveSessionStepDecision(raw)
decision, ok := parseActiveSessionStepDecision(raw)
if !ok {
return activeSessionStepDecision{}, false
}
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
return decision, true
}
func (a *Agent) executeActiveSkillSession(storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (skillOutcome, ActiveSkillSession, bool, bool) {
@@ -662,6 +683,38 @@ func mergeExtractedData(s *ActiveSkillSession, data map[string]any) {
}
}
func filterExtractedDataForActiveSession(session ActiveSkillSession, data map[string]any, lang string) map[string]any {
if len(data) == 0 {
return data
}
specs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(session), lang)
if len(specs) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(specs))
for _, spec := range specs {
key := strings.TrimSpace(spec.Key)
if key != "" {
allowed[key] = struct{}{}
}
}
out := make(map[string]any, len(data))
for key, value := range data {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := allowed[key]; !ok {
continue
}
out[key] = value
}
if len(out) == 0 {
return nil
}
return out
}
func emitBrainReply(onEvent func(event, data string), reply string) {
if onEvent == nil || reply == "" {
return

View File

@@ -87,6 +87,58 @@ func TestToolManageModelConfigCreateReusesExistingProviderRecord(t *testing.T) {
}
}
func TestToolManageExchangeConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-create-enabled.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"binance","account_name":"Binance Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to succeed, got: %s", resp)
}
exchanges, err := st.Exchange().List("default")
if err != nil {
t.Fatalf("list exchanges: %v", err)
}
if len(exchanges) != 1 || exchanges[0] == nil {
t.Fatalf("expected one created exchange, got %#v", exchanges)
}
if !exchanges[0].Enabled {
t.Fatalf("expected agent-created exchange to default to enabled so it matches manual creation")
}
}
func TestToolManageExchangeConfigUpdateAutoEnablesWhenConfigBecomesComplete(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-update-auto-enable.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
exchangeID, err := st.Exchange().Create("default", "okx", "OKX Main", false, "api-test-123456", "secret-test-123456", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed incomplete exchange: %v", err)
}
resp := a.toolManageExchangeConfig("default", `{"action":"update","exchange_id":"`+exchangeID+`","passphrase":"passphrase-123456"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected update to succeed, got: %s", resp)
}
updated, err := st.Exchange().GetByID("default", exchangeID)
if err != nil {
t.Fatalf("reload exchange: %v", err)
}
if !updated.Enabled {
t.Fatalf("expected completed exchange config to auto-enable after update")
}
}
func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility-list.db")
st, err := store.New(dbPath)
@@ -155,6 +207,50 @@ func TestToolManageStrategyUpdateRejectsOutOfRangeLeverageBeforeSave(t *testing.
}
}
func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-fixed-min-position.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-fixed-min-position",
UserID: "default",
Name: "固定最小开仓策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-fixed-min-position","config":{"risk_control":{"min_position_size":20}}}`)
if !strings.Contains(resp, "固定值 12 USDT") {
t.Fatalf("expected fixed min position size rejection, got: %s", resp)
}
updated, err := st.Strategy().Get("default", strategy.ID)
if err != nil {
t.Fatalf("reload strategy: %v", err)
}
parsed, err := updated.ParseConfig()
if err != nil {
t.Fatalf("parse updated strategy config: %v", err)
}
if parsed.RiskControl.MinPositionSize != 12 {
t.Fatalf("expected stored min position size to remain fixed at 12, got %v", parsed.RiskControl.MinPositionSize)
}
}
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
st, err := store.New(dbPath)
@@ -246,9 +342,12 @@ func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T)
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "最小开仓金额") {
t.Fatalf("strategy field summary should not expose fixed min position size editing: %s", summary)
}
}
func TestSkillVisibleFieldSummaryForTraderExcludesManualBalanceEditing(t *testing.T) {
func TestSkillVisibleFieldSummaryForTraderMatchesManualPanelFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
@@ -257,13 +356,118 @@ func TestSkillVisibleFieldSummaryForTraderExcludesManualBalanceEditing(t *testin
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "trader_management", "update")
for _, expected := range []string{"名称", "交易所", "模型", "策略", "扫描间隔"} {
for _, expected := range []string{"交易所", "模型", "策略", "扫描间隔", "全仓模式", "竞技场显示"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected trader field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "初始资金") || strings.Contains(summary, "初始余额") {
t.Fatalf("trader field summary should not expose manual balance editing: %s", summary)
for _, unexpected := range []string{"名称", "初始资金", "初始余额", "杠杆", "交易对", "Prompt", "AI500", "OI Top"} {
if strings.Contains(summary, unexpected) {
t.Fatalf("trader field summary should stay within manual panel fields, got: %s", summary)
}
}
}
func TestToolUpdateTraderRejectsRenameOutsideManualPanel(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-update-reject-rename.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-trader-rename",
UserID: "default",
Name: "Rename Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
if err := st.Trader().Create(&store.Trader{
ID: "trader-rename",
UserID: "default",
Name: "原交易员",
AIModelID: "default_deepseek",
ExchangeID: exchangeID,
StrategyID: "strategy-trader-rename",
InitialBalance: 1000,
ScanIntervalMinutes: 5,
IsCrossMargin: true,
ShowInCompetition: true,
}); err != nil {
t.Fatalf("seed trader: %v", err)
}
resp := a.toolManageTrader("default", `{"action":"update","trader_id":"trader-rename","name":"新名字"}`)
if !strings.Contains(resp, "trader rename is not supported here") {
t.Fatalf("expected rename rejection, got: %s", resp)
}
}
func TestToolCreateTraderResponseHidesLegacyTraderTuningFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-create-response-shape.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-trader-shape",
UserID: "default",
Name: "Shape Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
originalFetcher := traderInitialBalanceFetcher
traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) {
return 88.5, true, nil
}
defer func() {
traderInitialBalanceFetcher = originalFetcher
}()
resp := a.toolManageTrader("default", `{"action":"create","name":"形状测试","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-trader-shape"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected trader create to succeed, got: %s", resp)
}
for _, blocked := range []string{"btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "system_prompt_template"} {
if strings.Contains(resp, blocked) {
t.Fatalf("expected trader create response to hide legacy tuning field %q, got: %s", blocked, resp)
}
}
}

View File

@@ -9,21 +9,12 @@ type entityFieldMeta struct {
}
var traderFieldCatalog = []entityFieldMeta{
{Key: "name", Keywords: []string{"改名", "重命名", "rename", "名字"}, ValueType: "name", ManualEditable: true, AgentUpdatable: true},
{Key: "ai_model_id", Keywords: []string{"换模型", "切换模型", "模型"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "exchange_id", Keywords: []string{"换交易所", "切换交易所", "交易所"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "strategy_id", Keywords: []string{"换策略", "切换策略", "策略"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "scan_interval_minutes", Keywords: []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
{Key: "is_cross_margin", Keywords: []string{"全仓", "cross margin", "is_cross_margin"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "show_in_competition", Keywords: []string{"竞技场显示", "显示在竞技场", "show in competition", "competition"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "btc_eth_leverage", Keywords: []string{"btc/eth杠杆", "主流币杠杆", "btc eth leverage"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
{Key: "altcoin_leverage", Keywords: []string{"山寨币杠杆", "altcoin leverage", "alts leverage"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
{Key: "trading_symbols", Keywords: []string{"交易对", "symbols", "币种"}, ValueType: "text", ManualEditable: true, AgentUpdatable: true},
{Key: "custom_prompt", Keywords: []string{"自定义prompt", "custom prompt", "提示词"}, ValueType: "text", ManualEditable: true, AgentUpdatable: true},
{Key: "override_base_prompt", Keywords: []string{"覆盖基础提示词", "override base prompt"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "system_prompt_template", Keywords: []string{"提示词模板", "system prompt template", "prompt template"}, ValueType: "text", ManualEditable: true, AgentUpdatable: true},
{Key: "use_ai500", Keywords: []string{"ai500"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "use_oi_top", Keywords: []string{"oi top", "持仓量增长"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
}
var modelFieldCatalog = []entityFieldMeta{

View File

@@ -45,7 +45,13 @@ Rules:
- Prefer "continue" only when the message clearly contributes to the current flow.
- Set target_snapshot_id only when the user is clearly referring to one suspended snapshot from Suspended snapshots JSON.
- For greetings, thanks, and casual chat, use "instant_reply".
- Consider Current references JSON and Suspended snapshots JSON when resolving vague references like "那个", "刚才那个", or "前面那个".`
- Consider Current references JSON and Suspended snapshots JSON when resolving vague references like "那个", "刚才那个", or "前面那个".
- Treat this as semantic slot filling, not keyword copying.
- Users will often speak in natural language, shorthand, colloquial labels, translated labels, or mild misspellings instead of exact schema keys.
- Your job is to decide which allowed canonical field each value belongs to based on the active flow, field descriptions, current missing fields, and conversation context.
- Never require the user to say the exact internal field key.
- In task.fields, always emit the canonical field keys from Allowed field spec JSON, never aliases, paraphrases, or user wording.
- If the user clearly supplied a value for one allowed field, normalize it to that canonical key before returning JSON.`
sections := []string{
fmt.Sprintf("Language: %s", lang),
@@ -99,6 +105,11 @@ func (a *Agent) extractSkillSessionFieldsWithLLM(ctx context.Context, userID int
- This is the structured continuation input for an active NOFXi task flow.
- For "continue", return exactly one task for the active skill/action and place extracted field values in task.fields.
- Only extract fields from the allowed field spec list.
- Treat Allowed field spec JSON as the canonical output schema.
- If a user-provided value does not fit one of those canonical keys, omit it; never create another key.
- Use field descriptions plus current missing fields to infer the best canonical destination field for each user-provided value.
- When the user supplies a credential, endpoint, name, toggle, or config value in natural language, map it to the most plausible allowed canonical field instead of echoing the user's label.
- Do not return near-match keys, guessed aliases, or raw user labels as JSON keys.
- Do not invent values that were not supported by the user message or strong context.
- If the user explicitly says "you choose one for me", you may leave that field empty and explain it in reason.
- If the active skill dependency summary says the current flow depends on other resource configs, treat dependency repair as continuation of the active flow instead of a new peer task.
@@ -122,7 +133,7 @@ Return JSON with this shape:
if err != nil {
return llmFlowExtractionResult{}
}
return parseLLMFlowExtractionResult(raw)
return filterLLMFlowExtractionFields(parseLLMFlowExtractionResult(raw), fieldSpecs)
}
func parseLLMFlowExtractionResult(raw string) llmFlowExtractionResult {
@@ -191,6 +202,44 @@ func parseRawFlowExtractionEnvelope(raw string) (llmFlowExtractionResult, bool)
return out, out.Intent != ""
}
func filterLLMFlowExtractionFields(result llmFlowExtractionResult, specs []llmFlowFieldSpec) llmFlowExtractionResult {
if len(specs) == 0 {
result.Fields = nil
for i := range result.Tasks {
result.Tasks[i].Fields = nil
}
return result
}
allowed := make(map[string]struct{}, len(specs))
for _, spec := range specs {
key := strings.TrimSpace(spec.Key)
if key != "" {
allowed[key] = struct{}{}
}
}
filter := func(fields map[string]string) map[string]string {
if len(fields) == 0 {
return fields
}
clean := make(map[string]string, len(fields))
for key, value := range fields {
if _, ok := allowed[key]; !ok {
continue
}
clean[key] = value
}
if len(clean) == 0 {
return nil
}
return clean
}
result.Fields = filter(result.Fields)
for i := range result.Tasks {
result.Tasks[i].Fields = filter(result.Tasks[i].Fields)
}
return result
}
func skillSessionExtractionContext(session skillSession, lang string) (string, []llmFlowFieldSpec, map[string]string, []string) {
currentStep, _ := currentSkillDAGStep(session)
fieldSpecs := allowedFieldSpecsForSkillSession(session, lang)
@@ -205,6 +254,13 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
*out = append(*out, llmFlowFieldSpec{Key: key, Description: description, Required: required})
}
out := make([]llmFlowFieldSpec, 0, 24)
if actionRequiresSlot(session.Name, session.Action, "target_ref") {
add(&out, "target_ref_id", slotDisplayName("target_ref", lang)+" ID", true)
add(&out, "target_ref_name", slotDisplayName("target_ref", lang), true)
}
if supportsBulkTargetSelection(session.Name, session.Action) {
add(&out, "bulk_scope", "bulk deletion scope, use all only when the user clearly requested all targets", false)
}
switch session.Name {
case "model_management":
required := map[string]bool{"provider": true}
@@ -472,6 +528,26 @@ func (a *Agent) applyLLMExtractionToSkillSession(storeUserID string, session *sk
if value == "" {
continue
}
switch key {
case "target_ref_id":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.ID = value
if session.TargetRef.Source == "" {
session.TargetRef.Source = "llm_extraction"
}
continue
case "target_ref_name":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.Name = value
if session.TargetRef.Source == "" {
session.TargetRef.Source = "llm_extraction"
}
continue
}
switch session.Name {
case "model_management":
if key == "provider" || key == "name" || key == "custom_model_name" || key == "api_key" || key == "custom_api_url" || key == "enabled" || key == "update_field" {

View File

@@ -1,42 +1,28 @@
package agent
import "testing"
import (
"strings"
"testing"
)
func TestSanitizeLLMExtractionForSkillSessionDoesNotInventModelProvider(t *testing.T) {
session := skillSession{Name: "model_management", Action: "create"}
result := llmFlowExtractionResult{
Intent: "continue",
Tasks: []llmFlowExtractionTask{{
Skill: "model_management",
Action: "create",
Fields: map[string]string{
"provider": "claw402",
},
}},
}
func TestBuildActiveFlowExtractionPromptRequiresCanonicalFieldOutput(t *testing.T) {
systemPrompt, _ := buildActiveFlowExtractionPrompt(
"zh",
"skill_session",
"Active flow type: skill_session\nSkill: exchange_management\nAction: create",
"secret是abc123456",
"",
nil,
nil,
nil,
)
sanitized := sanitizeLLMExtractionForSkillSession("新建一个模型", session, result)
if got := sanitized.Tasks[0].Fields["provider"]; got != "" {
t.Fatalf("expected provider guess to be stripped, got %q", got)
for _, want := range []string{
"Treat this as semantic slot filling, not keyword copying.",
"always emit the canonical field keys from Allowed field spec JSON",
} {
if !strings.Contains(systemPrompt, want) {
t.Fatalf("expected system prompt to contain %q, got:\n%s", want, systemPrompt)
}
}
}
func TestSanitizeLLMExtractionForSkillSessionKeepsExplicitModelProvider(t *testing.T) {
session := skillSession{Name: "model_management", Action: "create"}
result := llmFlowExtractionResult{
Intent: "continue",
Tasks: []llmFlowExtractionTask{{
Skill: "model_management",
Action: "create",
Fields: map[string]string{
"provider": "claw402",
},
}},
}
sanitized := sanitizeLLMExtractionForSkillSession("新建一个 claw402 模型", session, result)
if got := sanitized.Tasks[0].Fields["provider"]; got != "claw402" {
t.Fatalf("expected explicit provider to remain, got %q", got)
}
}

View File

@@ -82,27 +82,44 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
return answer, true, err
}
interruptedActiveContext := false
if a.hasAnyActiveContext(userID) {
a.clearPendingProposalSession(userID)
return a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent)
if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, decision.TargetSnapshotID) {
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
}
interruptedActiveContext = true
}
switch decision.Route {
case "workflow":
a.clearPendingProposalSession(userID)
answer, handled, execErr := a.executeWorkflowDecomposition(ctx, storeUserID, userID, lang, text, workflowDecomposition{Tasks: decision.Tasks}, onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, handled, execErr
case "skill":
a.clearPendingProposalSession(userID)
return a.executeRoutedAtomicSkill(ctx, storeUserID, userID, lang, text, decision, onEvent)
answer, handled, execErr := a.executeRoutedAtomicSkill(ctx, storeUserID, userID, lang, text, decision, onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, handled, execErr
case "planner":
a.clearPendingProposalSession(userID)
answer, execErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
answer, execErr := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, plannerContextModeFromRouteDecision(decision), onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, true, execErr
default:
if decision.NeedPlannerHelp || decision.Track == "planning_track" {
a.clearPendingProposalSession(userID)
answer, execErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
answer, execErr := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, plannerContextModeFromRouteDecision(decision), onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, true, execErr
}
}
@@ -110,6 +127,13 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
return "", false, nil
}
func plannerContextModeFromRouteDecision(decision llmSkillRouteDecision) string {
if decision.ContextSwitch {
return "fresh_context"
}
return ""
}
func (a *Agent) executeRoutedAtomicSkill(ctx context.Context, storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision, onEvent func(event, data string)) (string, bool, error) {
outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision)
if !ok {
@@ -346,6 +370,7 @@ Rules:
- If the request is broad, ambiguous, or creative, you may choose route "planner".
- If a single management or diagnosis skill can handle it directly, prefer route "skill".
- If multiple dependent steps are needed, prefer route "workflow".
- 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:
@@ -361,16 +386,20 @@ 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", route "skill", and the matching query action. For example, "现有策略有哪些" means strategy_management/query_list and must use the strategy query tool, not a freeform answer.
- 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", route "workflow", and fill tasks exactly like the normal top-level router. Do not squeeze a complex new request into only the first skill/action.
- 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.
- If intent=start_new, keep the same business routing semantics as the normal router: use route "skill" for one atomic management action, route "workflow" for multiple dependent or independent management actions, and route "planner" for broad or ambiguous work.
- You may set target_skill when intent=start_new and the next task is clear.
- 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":"","target_skill":"","extracted_fields":{},"need_planner_help":false,"reason":"","confidence":0.0}`)
{"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","route":"skill|workflow|planner","track":"fast_track|planning_track","skill":"","action":"","target_skill":"","filter":"","tasks":[],"context_switch":false,"extracted_fields":{},"need_planner_help":false,"reason":"","confidence":0.0}`)
}
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nManagement skill summary:\n%s\n\nManagement domain primer:\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",

View File

@@ -2582,8 +2582,7 @@ Return JSON with this exact shape:
}
a.history.Add(userID, "user", text)
a.history.Add(userID, "assistant", answer)
a.maybeUpdateTaskStateIncrementally(ctx, userID)
a.maybeCompressHistory(ctx, userID)
a.runPostResponseMaintenanceAsync(userID)
if onEvent != nil {
emitStreamText(onEvent, answer)
}
@@ -2735,13 +2734,17 @@ func (a *Agent) tryRecoverFromInternalAgentJSON(ctx context.Context, storeUserID
}
func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) {
return a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, "", onEvent)
}
func (a *Agent) runPlannedAgentWithContextMode(ctx context.Context, storeUserID string, userID int64, lang, text string, contextMode string, onEvent func(event, data string)) (string, error) {
a.history.Add(userID, "user", text)
if onEvent != nil {
onEvent(StreamEventPlanning, a.planningStatusText(lang))
}
requestStartedAt := time.Now()
state, err := a.prepareExecutionState(ctx, storeUserID, userID, lang, text)
state, err := a.prepareExecutionState(ctx, storeUserID, userID, lang, text, contextMode)
if err != nil {
a.logPlannerTiming("", userID, "prepare_execution_state", requestStartedAt, err)
if isPlannerTimeoutError(err) {
@@ -2777,13 +2780,29 @@ func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID
}
a.history.Add(userID, "assistant", answer)
a.maybeUpdateTaskStateIncrementally(ctx, userID)
a.maybeCompressHistory(ctx, userID)
a.runPostResponseMaintenanceAsync(userID)
a.logPlannerTiming(state.SessionID, userID, "run_planned_agent_total", requestStartedAt, nil)
return answer, nil
}
func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, userID int64, lang, text string) (ExecutionState, error) {
func (a *Agent) runPostResponseMaintenanceAsync(userID int64) {
if a == nil || a.aiClient == nil || a.history == nil {
return
}
go func() {
defer func() {
if r := recover(); r != nil {
a.log().Warn("post-response maintenance panicked", "user_id", userID, "panic", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
a.maybeUpdateTaskStateIncrementally(ctx, userID)
a.maybeCompressHistory(ctx, userID)
}()
}
func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, userID int64, lang, text, contextMode string) (ExecutionState, error) {
existing := a.getExecutionState(userID)
if shouldResetExecutionStateForNewAttempt(text, existing) {
a.clearExecutionState(userID)
@@ -2817,9 +2836,15 @@ func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, u
}
state := newExecutionState(userID, text)
if mem := a.getReferenceMemory(userID); mem.CurrentReferences != nil {
state.CurrentReferences = mem.CurrentReferences
state.ReferenceHistory = mem.ReferenceHistory
mem := a.getReferenceMemory(userID)
switch strings.TrimSpace(contextMode) {
case "fresh_context":
a.SnapshotManager(userID).Clear()
default:
if mem.CurrentReferences != nil {
state.CurrentReferences = mem.CurrentReferences
state.ReferenceHistory = mem.ReferenceHistory
}
}
a.refreshCurrentReferencesForUserText(storeUserID, text, &state)
state = a.refreshStateForDynamicRequests(storeUserID, text, state)

View File

@@ -35,15 +35,6 @@ func buildSkillDAGRegistry() map[string]SkillDAG {
{ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_bindings",

View File

@@ -275,13 +275,15 @@ func (a *Agent) loadEnabledModelOptions(storeUserID string) []traderSkillOption
}
out := make([]traderSkillOption, 0, len(models))
for _, model := range models {
parts := cleanStringList([]string{
strings.TrimSpace(model.Name),
name := strings.TrimSpace(model.Name)
if name == "" {
name = strings.TrimSpace(model.ID)
}
hint := strings.Join(cleanStringList([]string{
strings.TrimSpace(model.CustomModelName),
strings.TrimSpace(model.Provider),
})
name := strings.Join(parts, " ")
out = append(out, traderSkillOption{ID: model.ID, Name: name, Enabled: model.Enabled})
}), " / ")
out = append(out, traderSkillOption{ID: model.ID, Name: name, Hint: hint, Enabled: model.Enabled})
}
return out
}
@@ -554,34 +556,25 @@ func (a *Agent) handleCreateTraderSkill(storeUserID string, userID int64, lang,
return formatValidationFeedback(lang, "trader", err), true
}
}
if missing := missingFieldKeysForSkillSession(session); len(missing) > 0 {
session.Phase = "collecting"
a.saveSkillSession(userID, session)
return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true
}
if !result.Ready && result.Question == "" {
if missing := missingFieldKeysForSkillSession(session); len(missing) > 0 {
session.Phase = "collecting"
a.saveSkillSession(userID, session)
return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true
}
result.Ready = true
}
if !result.Ready {
session.Phase = "collecting"
a.saveSkillSession(userID, session)
if result.Question != "" {
return result.Question, true
}
return "", false
}
if stillMissing := missingFieldKeysForSkillSession(session); len(stillMissing) > 0 {
session.Phase = "collecting"
a.saveSkillSession(userID, session)
if result.Question != "" {
return result.Question, true
}
if lang == "zh" {
return "我理解了你的意思,但创建交易员还缺这些信息:" + strings.Join(renderSkillMissingLabels(lang, stillMissing), "、") + "。", true
}
return "I understand the intent, but creating the trader still needs: " + strings.Join(renderSkillMissingLabels(lang, stillMissing), ", ") + ".", true
return a.buildTraderCreateMissingPrompt(storeUserID, lang, session, a.buildTraderCreateConversationResources(storeUserID, session)), true
}
if fieldValue(session, "auto_start") == "true" {
@@ -679,12 +672,19 @@ func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session
missing := missingFieldKeysForSkillSession(session)
missingLabels := strings.Join(renderSkillMissingLabels(lang, missing), "、")
prereqs := make([]string, 0, 3)
optionLines := make([]string, 0, 3)
if exchanges, _ := availableResources["exchanges"].([]traderSkillOption); len(exchanges) == 0 && containsString(missing, "exchange_name") {
if lang == "zh" {
prereqs = append(prereqs, "当前还没有可用交易所配置")
} else {
prereqs = append(prereqs, "there is no exchange config yet")
}
} else if containsString(missing, "exchange_name") {
if list := formatOptionList("现有交易所:", exchanges); lang == "zh" && list != "" {
optionLines = append(optionLines, list)
} else if list := formatOptionList("Available exchanges:", exchanges); lang != "zh" && list != "" {
optionLines = append(optionLines, list)
}
}
if models, _ := availableResources["models"].([]traderSkillOption); len(models) == 0 && containsString(missing, "model_name") {
if lang == "zh" {
@@ -692,6 +692,12 @@ func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session
} else {
prereqs = append(prereqs, "there is no model config yet")
}
} else if containsString(missing, "model_name") {
if list := formatOptionList("现有模型:", models); lang == "zh" && list != "" {
optionLines = append(optionLines, list)
} else if list := formatOptionList("Available models:", models); lang != "zh" && list != "" {
optionLines = append(optionLines, list)
}
}
if strategies, _ := availableResources["strategies"].([]traderSkillOption); len(strategies) == 0 && containsString(missing, "strategy_name") {
if lang == "zh" {
@@ -699,18 +705,30 @@ func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session
} else {
prereqs = append(prereqs, "there is no strategy yet")
}
} else if containsString(missing, "strategy_name") {
if list := formatOptionList("现有策略:", strategies); lang == "zh" && list != "" {
optionLines = append(optionLines, list)
} else if list := formatOptionList("Available strategies:", strategies); lang != "zh" && list != "" {
optionLines = append(optionLines, list)
}
}
if lang == "zh" {
reply := "还缺这些信息" + missingLabels + "。"
reply := "新建交易员还缺这些槽位" + missingLabels + "。"
if len(prereqs) > 0 {
reply += "\n" + strings.Join(prereqs, "") + "。"
}
if len(optionLines) > 0 {
reply += "\n" + strings.Join(optionLines, "\n")
}
return reply
}
reply := "Still missing: " + strings.Join(renderSkillMissingLabels(lang, missing), ", ") + "."
reply := "Creating the trader still needs these slots: " + strings.Join(renderSkillMissingLabels(lang, missing), ", ") + "."
if len(prereqs) > 0 {
reply += "\n" + strings.Join(prereqs, "; ") + "."
}
if len(optionLines) > 0 {
reply += "\n" + strings.Join(optionLines, "\n")
}
return reply
}
@@ -738,23 +756,14 @@ func (a *Agent) executeCreateTraderSkill(storeUserID string, userID int64, lang
a.hydrateCreateTraderSlotReferences(storeUserID, &session)
normalizedArgs, _ := normalizeTraderArgsToManualLimits(lang, buildTraderUpdateArgsFromSession(session))
args := manageTraderArgs{
Action: "create",
Name: fieldValue(session, "name"),
AIModelID: fieldValue(session, "model_id"),
ExchangeID: fieldValue(session, "exchange_id"),
StrategyID: fieldValue(session, "strategy_id"),
InitialBalance: normalizedArgs.InitialBalance,
ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes,
IsCrossMargin: normalizedArgs.IsCrossMargin,
ShowInCompetition: normalizedArgs.ShowInCompetition,
BTCETHLeverage: normalizedArgs.BTCETHLeverage,
AltcoinLeverage: normalizedArgs.AltcoinLeverage,
TradingSymbols: normalizedArgs.TradingSymbols,
CustomPrompt: normalizedArgs.CustomPrompt,
OverrideBasePrompt: normalizedArgs.OverrideBasePrompt,
SystemPromptTemplate: normalizedArgs.SystemPromptTemplate,
UseAI500: normalizedArgs.UseAI500,
UseOITop: normalizedArgs.UseOITop,
Action: "create",
Name: fieldValue(session, "name"),
AIModelID: fieldValue(session, "model_id"),
ExchangeID: fieldValue(session, "exchange_id"),
StrategyID: fieldValue(session, "strategy_id"),
ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes,
IsCrossMargin: normalizedArgs.IsCrossMargin,
ShowInCompetition: normalizedArgs.ShowInCompetition,
}
createRaw := a.toolCreateTrader(storeUserID, args)
if errMsg := parseSkillError(createRaw); errMsg != "" && strings.Contains(createRaw, `"error"`) {
@@ -1009,6 +1018,15 @@ func (a *Agent) hydrateCreateTraderSlotReferences(storeUserID string, session *s
setField(session, "exchange_id", opt.ID)
}
}
if fieldValue(*session, "exchange_id") != "" {
options := a.loadExchangeOptions(storeUserID)
if opt := findOptionByIDOrName(options, fieldValue(*session, "exchange_id")); opt != nil {
setField(session, "exchange_id", opt.ID)
if fieldValue(*session, "exchange_name") == "" {
setField(session, "exchange_name", opt.Name)
}
}
}
if fieldValue(*session, "model_id") == "" && fieldValue(*session, "model_name") != "" {
options := a.loadEnabledModelOptions(storeUserID)
if opt := findOptionByIDOrName(options, fieldValue(*session, "model_name")); opt != nil {
@@ -1017,6 +1035,15 @@ func (a *Agent) hydrateCreateTraderSlotReferences(storeUserID string, session *s
setField(session, "model_id", opt.ID)
}
}
if fieldValue(*session, "model_id") != "" {
options := a.loadEnabledModelOptions(storeUserID)
if opt := findOptionByIDOrName(options, fieldValue(*session, "model_id")); opt != nil {
setField(session, "model_id", opt.ID)
if fieldValue(*session, "model_name") == "" {
setField(session, "model_name", opt.Name)
}
}
}
if fieldValue(*session, "strategy_id") == "" && fieldValue(*session, "strategy_name") != "" {
options := a.loadStrategyOptions(storeUserID)
if opt := findOptionByIDOrName(options, fieldValue(*session, "strategy_name")); opt != nil {
@@ -1025,6 +1052,15 @@ func (a *Agent) hydrateCreateTraderSlotReferences(storeUserID string, session *s
setField(session, "strategy_id", opt.ID)
}
}
if fieldValue(*session, "strategy_id") != "" {
options := a.loadStrategyOptions(storeUserID)
if opt := findOptionByIDOrName(options, fieldValue(*session, "strategy_id")); opt != nil {
setField(session, "strategy_id", opt.ID)
if fieldValue(*session, "strategy_name") == "" {
setField(session, "strategy_name", opt.Name)
}
}
}
}
func (a *Agent) maybeResumeParentTaskAfterSuccessfulSkill(storeUserID string, userID int64, lang, skill, action, answer string) string {
@@ -1085,10 +1121,24 @@ func resolveTargetSelection(text string, options []traderSkillOption, existing *
if existing != nil && strings.TrimSpace(existing.ID) != "" {
for _, opt := range options {
if opt.ID == existing.ID {
return targetResolution{Ref: existing}
return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: defaultIfEmpty(opt.Name, existing.Name), Source: existing.Source}}
}
}
}
if existing != nil && strings.TrimSpace(existing.Name) != "" {
if opt := findOptionByIDOrName(options, existing.Name); opt != nil {
return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: existing.Source}}
}
if opt := findUniqueContainingOption(options, existing.Name); opt != nil {
return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: existing.Source}}
}
}
if opt := findOptionByIDOrName(options, text); opt != nil {
return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: "user_mention"}}
}
if opt := findUniqueContainingOption(options, text); opt != nil {
return targetResolution{Ref: &EntityReference{ID: opt.ID, Name: opt.Name, Source: "user_mention"}}
}
if len(options) == 1 {
return targetResolution{Ref: &EntityReference{ID: options[0].ID, Name: options[0].Name}}
}

View File

@@ -57,10 +57,6 @@ func parseFlagValue(text string, keywords []string) (bool, bool) {
return false, false
}
func extractCredentialValue(text string, keywords []string) string {
return ""
}
func parseScanIntervalMinutes(text string) (int, bool) {
return 0, false
}
@@ -305,22 +301,6 @@ func parseLooseTextValue(text string) string {
return ""
}
func parseModelFieldValue(text, field string) (string, bool) {
return "", false
}
func parseExchangeFieldValue(text, field string) (string, bool) {
return "", false
}
func (a *Agent) parseTraderFieldValue(storeUserID, text, field string) (string, bool) {
return "", false
}
func detectCatalogFieldPatches(text string, catalog []entityFieldMeta, overrides map[string]string) []entityFieldPatch {
return nil
}
func entityFieldExplicitlyMentioned(text string, keywords []string) bool {
if len(keywords) == 0 {
return false
@@ -328,54 +308,18 @@ func entityFieldExplicitlyMentioned(text string, keywords []string) bool {
return containsAny(strings.ToLower(text), keywords)
}
func hasTraderUpdatePatch(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return len(detectTraderUpdatePatches(nil, "", text)) > 0
}
func hasModelUpdatePatch(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return len(detectModelUpdatePatches(text)) > 0
}
func hasExchangeUpdatePatch(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return len(detectExchangeUpdatePatches(text)) > 0
}
type traderUpdateArgs struct {
AIModelID string
ExchangeID string
StrategyID string
InitialBalance *float64
ScanIntervalMinutes *int
IsCrossMargin *bool
ShowInCompetition *bool
BTCETHLeverage *int
AltcoinLeverage *int
TradingSymbols string
CustomPrompt string
OverrideBasePrompt *bool
SystemPromptTemplate string
UseAI500 *bool
UseOITop *bool
AIModelID string
ExchangeID string
StrategyID string
ScanIntervalMinutes *int
IsCrossMargin *bool
ShowInCompetition *bool
}
func (a traderUpdateArgs) hasAny() bool {
return a.AIModelID != "" || a.ExchangeID != "" || a.StrategyID != "" || a.InitialBalance != nil ||
a.ScanIntervalMinutes != nil || a.IsCrossMargin != nil || a.ShowInCompetition != nil ||
a.BTCETHLeverage != nil || a.AltcoinLeverage != nil || a.TradingSymbols != "" ||
a.CustomPrompt != "" || a.OverrideBasePrompt != nil || a.SystemPromptTemplate != "" ||
a.UseAI500 != nil || a.UseOITop != nil
return a.AIModelID != "" || a.ExchangeID != "" || a.StrategyID != "" ||
a.ScanIntervalMinutes != nil || a.IsCrossMargin != nil || a.ShowInCompetition != nil
}
func parseStandaloneTraderUpdateArgs(text string) traderUpdateArgs {
@@ -392,9 +336,6 @@ func mergeTraderUpdateArgs(base, patch traderUpdateArgs) traderUpdateArgs {
if patch.StrategyID != "" {
base.StrategyID = patch.StrategyID
}
if patch.InitialBalance != nil {
base.InitialBalance = patch.InitialBalance
}
if patch.ScanIntervalMinutes != nil {
base.ScanIntervalMinutes = patch.ScanIntervalMinutes
}
@@ -404,41 +345,9 @@ func mergeTraderUpdateArgs(base, patch traderUpdateArgs) traderUpdateArgs {
if patch.ShowInCompetition != nil {
base.ShowInCompetition = patch.ShowInCompetition
}
if patch.BTCETHLeverage != nil {
base.BTCETHLeverage = patch.BTCETHLeverage
}
if patch.AltcoinLeverage != nil {
base.AltcoinLeverage = patch.AltcoinLeverage
}
if patch.TradingSymbols != "" {
base.TradingSymbols = patch.TradingSymbols
}
if patch.CustomPrompt != "" {
base.CustomPrompt = patch.CustomPrompt
}
if patch.OverrideBasePrompt != nil {
base.OverrideBasePrompt = patch.OverrideBasePrompt
}
if patch.SystemPromptTemplate != "" {
base.SystemPromptTemplate = patch.SystemPromptTemplate
}
if patch.UseAI500 != nil {
base.UseAI500 = patch.UseAI500
}
if patch.UseOITop != nil {
base.UseOITop = patch.UseOITop
}
return base
}
func buildTraderUpdateArgs(a *Agent, storeUserID string, text string) traderUpdateArgs {
return traderUpdateArgs{}
}
func detectTraderUpdatePatches(a *Agent, storeUserID, text string) []entityFieldPatch {
return nil
}
func applyTraderUpdateArgsToSession(session *skillSession, args traderUpdateArgs) {
if args.AIModelID != "" {
setField(session, "ai_model_id", args.AIModelID)
@@ -458,30 +367,6 @@ func applyTraderUpdateArgsToSession(session *skillSession, args traderUpdateArgs
if args.ShowInCompetition != nil {
setField(session, "show_in_competition", strconv.FormatBool(*args.ShowInCompetition))
}
if args.BTCETHLeverage != nil {
setField(session, "btc_eth_leverage", strconv.Itoa(*args.BTCETHLeverage))
}
if args.AltcoinLeverage != nil {
setField(session, "altcoin_leverage", strconv.Itoa(*args.AltcoinLeverage))
}
if args.TradingSymbols != "" {
setField(session, "trading_symbols", args.TradingSymbols)
}
if args.CustomPrompt != "" {
setField(session, "custom_prompt", args.CustomPrompt)
}
if args.OverrideBasePrompt != nil {
setField(session, "override_base_prompt", strconv.FormatBool(*args.OverrideBasePrompt))
}
if args.SystemPromptTemplate != "" {
setField(session, "system_prompt_template", args.SystemPromptTemplate)
}
if args.UseAI500 != nil {
setField(session, "use_ai500", strconv.FormatBool(*args.UseAI500))
}
if args.UseOITop != nil {
setField(session, "use_oi_top", strconv.FormatBool(*args.UseOITop))
}
}
func buildTraderUpdateArgsFromSession(session skillSession) traderUpdateArgs {
@@ -502,31 +387,6 @@ func buildTraderUpdateArgsFromSession(session skillSession) traderUpdateArgs {
parsed := value == "true"
args.ShowInCompetition = &parsed
}
if value := fieldValue(session, "btc_eth_leverage"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
args.BTCETHLeverage = &parsed
}
}
if value := fieldValue(session, "altcoin_leverage"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
args.AltcoinLeverage = &parsed
}
}
args.TradingSymbols = fieldValue(session, "trading_symbols")
args.CustomPrompt = fieldValue(session, "custom_prompt")
if value := fieldValue(session, "override_base_prompt"); value != "" {
parsed := value == "true"
args.OverrideBasePrompt = &parsed
}
args.SystemPromptTemplate = fieldValue(session, "system_prompt_template")
if value := fieldValue(session, "use_ai500"); value != "" {
parsed := value == "true"
args.UseAI500 = &parsed
}
if value := fieldValue(session, "use_oi_top"); value != "" {
parsed := value == "true"
args.UseOITop = &parsed
}
return args
}
@@ -541,14 +401,6 @@ func (p modelUpdatePatch) hasAny() bool {
return p.Enabled != nil || p.APIKey != "" || p.CustomAPIURL != "" || p.CustomModelName != ""
}
func buildModelUpdatePatch(text string) modelUpdatePatch {
return modelUpdatePatch{}
}
func detectModelUpdatePatches(text string) []entityFieldPatch {
return nil
}
func applyModelUpdatePatchToSession(session *skillSession, patch modelUpdatePatch) {
if patch.CustomAPIURL != "" {
setField(session, "custom_api_url", patch.CustomAPIURL)
@@ -615,14 +467,6 @@ func (p exchangeUpdatePatch) hasAny() bool {
p.LighterAPIKeyPrivateKey != "" || p.LighterAPIKeyIndex != nil
}
func buildExchangeUpdatePatch(text string) exchangeUpdatePatch {
return exchangeUpdatePatch{}
}
func detectExchangeUpdatePatches(text string) []entityFieldPatch {
return nil
}
func applyExchangeUpdatePatchToSession(session *skillSession, patch exchangeUpdatePatch) {
if patch.AccountName != "" {
setField(session, "account_name", patch.AccountName)
@@ -1624,8 +1468,6 @@ func strategyFieldKeywords(field string) []string {
return []string{"山寨币仓位价值倍数", "altcoin position value"}
case "max_margin_usage":
return []string{"最大保证金使用率", "max margin usage"}
case "min_position_size":
return []string{"最小开仓金额", "min position size"}
default:
return nil
}
@@ -1689,8 +1531,6 @@ func strategyFieldExplicitlyMentioned(text, field string) bool {
keywords = []string{"山寨币仓位价值倍数", "altcoin position value"}
case "max_margin_usage":
keywords = []string{"最大保证金使用率", "max margin usage"}
case "min_position_size":
keywords = []string{"最小开仓金额", "min position size"}
case "primary_timeframe":
keywords = []string{"主周期", "主时间周期", "primary timeframe"}
case "primary_count":
@@ -1760,6 +1600,9 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "await_confirmation")
}
if session.Action == "delete" && fieldValue(session, "bulk_scope") == "all" {
return a.executeBulkTraderDelete(storeUserID, userID, lang, text, session)
}
if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID)); waiting {
a.saveSkillSession(userID, session)
return msg
@@ -1791,7 +1634,7 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
return fmt.Sprintf("已完成交易员操作:%s。", session.Action)
}
return fmt.Sprintf("Completed trader action: %s.", session.Action)
case "update", "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" {
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "collect_bindings")
@@ -1994,68 +1837,13 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
setSkillDAGStep(&session, "collect_name")
}
parsedArgs := buildTraderUpdateArgsFromSession(session)
if !parsedArgs.hasAny() {
parsedArgs = buildTraderUpdateArgs(a, storeUserID, text)
}
selectedField := fieldValue(session, "update_field")
selectedFieldJustChosen := false
if selectedField == "" {
if session.Action == "update_name" {
selectedField = "name"
} else if !parsedArgs.hasAny() {
if !parsedArgs.hasAny() {
selectedField = detectCatalogField(text, traderFieldCatalog)
}
if selectedField != "" {
setField(&session, "update_field", selectedField)
selectedFieldJustChosen = true
}
}
if !parsedArgs.hasAny() && selectedField != "" && !(selectedFieldJustChosen && looksLikeBareFieldSelection(text, traderFieldKeywords(selectedField))) {
if value, ok := a.parseTraderFieldValue(storeUserID, text, selectedField); ok {
switch selectedField {
case "ai_model_id":
parsedArgs.AIModelID = value
case "exchange_id":
parsedArgs.ExchangeID = value
case "strategy_id":
parsedArgs.StrategyID = value
case "scan_interval_minutes":
if parsed, err := strconv.Atoi(value); err == nil {
parsedArgs.ScanIntervalMinutes = &parsed
}
case "is_cross_margin":
parsed := value == "true"
parsedArgs.IsCrossMargin = &parsed
case "show_in_competition":
parsed := value == "true"
parsedArgs.ShowInCompetition = &parsed
case "btc_eth_leverage":
if parsed, err := strconv.Atoi(value); err == nil {
parsedArgs.BTCETHLeverage = &parsed
}
case "altcoin_leverage":
if parsed, err := strconv.Atoi(value); err == nil {
parsedArgs.AltcoinLeverage = &parsed
}
case "trading_symbols":
parsedArgs.TradingSymbols = value
case "custom_prompt":
parsedArgs.CustomPrompt = value
case "override_base_prompt":
parsed := value == "true"
parsedArgs.OverrideBasePrompt = &parsed
case "system_prompt_template":
parsedArgs.SystemPromptTemplate = value
case "use_ai500":
parsed := value == "true"
parsedArgs.UseAI500 = &parsed
case "use_oi_top":
parsed := value == "true"
parsedArgs.UseOITop = &parsed
}
if selectedField == "name" {
setField(&session, "name", value)
}
}
}
applyTraderUpdateArgsToSession(&session, parsedArgs)
@@ -2064,23 +1852,14 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
normalizedArgs, warnings := normalizeTraderArgsToManualLimits(lang, parsedArgs)
applyTraderUpdateArgsToSession(&session, normalizedArgs)
args := manageTraderArgs{
Action: "update",
TraderID: session.TargetRef.ID,
AIModelID: normalizedArgs.AIModelID,
ExchangeID: normalizedArgs.ExchangeID,
StrategyID: normalizedArgs.StrategyID,
InitialBalance: normalizedArgs.InitialBalance,
ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes,
IsCrossMargin: normalizedArgs.IsCrossMargin,
ShowInCompetition: normalizedArgs.ShowInCompetition,
BTCETHLeverage: normalizedArgs.BTCETHLeverage,
AltcoinLeverage: normalizedArgs.AltcoinLeverage,
TradingSymbols: normalizedArgs.TradingSymbols,
CustomPrompt: normalizedArgs.CustomPrompt,
OverrideBasePrompt: normalizedArgs.OverrideBasePrompt,
SystemPromptTemplate: normalizedArgs.SystemPromptTemplate,
UseAI500: normalizedArgs.UseAI500,
UseOITop: normalizedArgs.UseOITop,
Action: "update",
TraderID: session.TargetRef.ID,
AIModelID: normalizedArgs.AIModelID,
ExchangeID: normalizedArgs.ExchangeID,
StrategyID: normalizedArgs.StrategyID,
ScanIntervalMinutes: normalizedArgs.ScanIntervalMinutes,
IsCrossMargin: normalizedArgs.IsCrossMargin,
ShowInCompetition: normalizedArgs.ShowInCompetition,
}
setSkillDAGStep(&session, "execute_update")
resp := a.toolUpdateTrader(storeUserID, args)
@@ -2104,54 +1883,134 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
}
return reply
}
newName := ""
if newName != "" {
setField(&session, "name", newName)
if selectedField != "" {
setSkillDAGStep(&session, "collect_field_value")
} else {
setSkillDAGStep(&session, "collect_name")
}
newName = fieldValue(session, "name")
if newName == "" {
if selectedField != "" {
setSkillDAGStep(&session, "collect_field_value")
} else {
setSkillDAGStep(&session, "collect_name")
}
a.saveSkillSession(userID, session)
if lang == "zh" {
if selectedField != "" {
if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" {
return fmt.Sprintf("还差一步:请告诉我你想换成哪个%s。", displayCatalogFieldName(selectedField, lang))
}
return fmt.Sprintf("还差一步:请告诉我新的%s。", displayCatalogFieldName(selectedField, lang))
}
return "你可以直接告诉我想改哪一项,比如名称,或者绑定的模型、交易所、策略。若你要改策略参数、模型配置或交易所凭证,我会切到对应配置流程。"
}
a.saveSkillSession(userID, session)
if lang == "zh" {
if selectedField != "" {
if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" {
return fmt.Sprintf("One more thing: tell me which %s you want to use.", displayCatalogFieldName(selectedField, lang))
return fmt.Sprintf("还差一步:请告诉我你想换成哪个%s。", displayCatalogFieldName(selectedField, lang))
}
return fmt.Sprintf("One more thing: tell me the new %s.", displayCatalogFieldName(selectedField, lang))
return fmt.Sprintf("还差一步:请告诉我新的%s", displayCatalogFieldName(selectedField, lang))
}
return "Tell me what you want to change first, for example the name or the linked model, exchange, or strategy. If you want to edit the internals of a strategy, model, or exchange, I'll switch to the right config flow."
return "你可以直接告诉我想改哪一项,比如绑定的模型、交易所、策略,或者扫描间隔、保证金模式、是否展示到竞技场。若你要改策略参数、模型配置或交易所凭证,我会切到对应配置流程。"
}
args := manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID, Name: newName}
setSkillDAGStep(&session, "execute_update")
resp := a.toolUpdateTrader(storeUserID, args)
a.clearSkillSession(userID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
if lang == "zh" {
return "这次没改成功:" + errMsg
if selectedField != "" {
if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" {
return fmt.Sprintf("One more thing: tell me which %s you want to use.", displayCatalogFieldName(selectedField, lang))
}
return "That change did not go through: " + errMsg
return fmt.Sprintf("One more thing: tell me the new %s.", displayCatalogFieldName(selectedField, lang))
}
if lang == "zh" {
return fmt.Sprintf("已将交易员改名为“%s”。", newName)
}
return fmt.Sprintf("Renamed trader to %q.", newName)
return "Tell me what you want to change first, for example the linked model, exchange, strategy, scan interval, margin mode, or competition visibility. If you want to edit the internals of a strategy, model, or exchange, I'll switch to the right config flow."
default:
return ""
}
}
func (a *Agent) executeBulkTraderDelete(storeUserID string, userID int64, lang, text string, session skillSession) string {
if a == nil || a.store == nil {
if lang == "zh" {
return "我这边暂时无法读取交易员列表。"
}
return "I cannot load the trader list right now."
}
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
if lang == "zh" {
return "我这边暂时没读到交易员列表:" + err.Error()
}
return "I could not load the trader list just now: " + err.Error()
}
if len(traders) == 0 {
a.clearSkillSession(userID)
if lang == "zh" {
return "当前没有可删除的交易员。"
}
return "There are no traders to delete."
}
deletable := make([]*store.Trader, 0, len(traders))
runningNames := make([]string, 0)
for _, trader := range traders {
if trader == nil {
continue
}
isRunning := trader.IsRunning
if a.traderManager != nil {
if memTrader, err := a.traderManager.GetTrader(trader.ID); err == nil {
if running, ok := memTrader.GetStatus()["is_running"].(bool); ok {
isRunning = running
}
}
}
if isRunning {
runningNames = append(runningNames, defaultIfEmpty(trader.Name, trader.ID))
continue
}
deletable = append(deletable, trader)
}
if len(deletable) == 0 {
a.clearSkillSession(userID)
if lang == "zh" {
return "当前所有交易员都还在运行中,删除前需要先停止:" + strings.Join(runningNames, "、")
}
return "All traders are still running. Stop them before deleting: " + strings.Join(runningNames, ", ")
}
targetLabel := fmt.Sprintf("全部已停止交易员(共 %d 个)", len(deletable))
if msg, waiting := a.beginConfirmationIfNeeded(userID, lang, &session, targetLabel); waiting {
a.saveSkillSession(userID, session)
return msg
}
if msg, waiting := awaitingConfirmationButNotApproved(lang, session, text); waiting {
a.saveSkillSession(userID, session)
return msg
}
setSkillDAGStep(&session, "execute_delete")
deletedNames := make([]string, 0, len(deletable))
failedNames := make([]string, 0)
for _, trader := range deletable {
resp := a.toolDeleteTrader(storeUserID, trader.ID)
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
failedNames = append(failedNames, fmt.Sprintf("%s%s", defaultIfEmpty(trader.Name, trader.ID), errMsg))
continue
}
deletedNames = append(deletedNames, defaultIfEmpty(trader.Name, trader.ID))
}
a.clearSkillSession(userID)
if lang == "zh" {
parts := []string{fmt.Sprintf("批量删除交易员已完成:成功删除 %d 个。", len(deletedNames))}
if len(runningNames) > 0 {
parts = append(parts, "这些交易员仍在运行,已跳过,删除前需要先停止:"+strings.Join(runningNames, "、"))
}
if len(failedNames) > 0 {
parts = append(parts, "这些没删成功:"+strings.Join(failedNames, ""))
}
if len(deletedNames) > 0 {
parts = append(parts, "已删除:"+strings.Join(deletedNames, "、"))
}
return strings.Join(parts, "\n")
}
parts := []string{fmt.Sprintf("Bulk trader deletion finished: deleted %d trader(s).", len(deletedNames))}
if len(runningNames) > 0 {
parts = append(parts, "Skipped running traders; stop them before deleting: "+strings.Join(runningNames, ", "))
}
if len(failedNames) > 0 {
parts = append(parts, "These did not delete successfully: "+strings.Join(failedNames, "; "))
}
if len(deletedNames) > 0 {
parts = append(parts, "Deleted: "+strings.Join(deletedNames, ", "))
}
return strings.Join(parts, "\n")
}
func (a *Agent) executeExchangeManagementAction(storeUserID string, userID int64, lang, text string, session skillSession) string {
switch session.Action {
case "query_detail":
@@ -2811,10 +2670,18 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
}
if fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" {
if strings.Contains(strings.ToLower(text), "min position size") || strings.Contains(strings.ToLower(text), "最小开仓金额") {
a.clearSkillSession(userID)
return strategyLockedFieldError(lang, "min_position_size")
}
patches := detectStrategyConfigPatches(text)
if len(patches) > 1 {
changed := make([]string, 0, len(patches))
for _, patch := range patches {
if patch.Field == "min_position_size" {
a.clearSkillSession(userID)
return strategyLockedFieldError(lang, "min_position_size")
}
if err := applyStrategyConfigPatch(&cfg, patch.Field, patch.Value); err != nil {
a.saveSkillSession(userID, session)
if lang == "zh" {
@@ -2849,6 +2716,10 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
if field == "" {
field = detectStrategyConfigField(text)
if field != "" {
if field == "min_position_size" {
a.clearSkillSession(userID)
return strategyLockedFieldError(lang, field)
}
setField(&session, "config_field", field)
if currentStep.ID == "resolve_config_field" {
advanceSkillDAGStep(&session, currentStep.ID)

View File

@@ -180,7 +180,12 @@ func textMeansAllTargets(text string) bool {
}
func supportsBulkTargetSelection(skillName, action string) bool {
return skillName == "strategy_management" && action == "delete"
switch skillName {
case "strategy_management", "trader_management":
return action == "delete"
default:
return false
}
}
func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference {
@@ -253,7 +258,18 @@ func ensureLiveTargetReference(session *skillSession, options []traderSkillOptio
if session == nil || session.TargetRef == nil {
return true
}
match := findOptionByIDOrName(options, defaultIfEmpty(session.TargetRef.ID, session.TargetRef.Name))
var match *traderSkillOption
if id := strings.TrimSpace(session.TargetRef.ID); id != "" {
match = findOptionByIDOrName(options, id)
}
if match == nil {
if name := strings.TrimSpace(session.TargetRef.Name); name != "" {
match = findOptionByIDOrName(options, name)
if match == nil {
match = findUniqueContainingOption(options, name)
}
}
}
if match == nil {
session.TargetRef = nil
return false
@@ -601,7 +617,7 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string {
if lang != "zh" {
apiURL = defaultIfEmpty(fieldValue(session, "custom_api_url"), "provider default endpoint")
}
enabled := fieldValue(session, "enabled") == "true"
enabled := fieldValue(session, "enabled") != "false"
if lang == "zh" {
lines := []string{
fmt.Sprintf("我先整理了一份模型配置草稿“%s”。", name),
@@ -609,7 +625,7 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string {
fmt.Sprintf("- 配置名称:%s", name),
fmt.Sprintf("- 模型名称:%s", modelName),
fmt.Sprintf("- 接口地址:%s", apiURL),
fmt.Sprintf("- 启用状态:%t未指定时默认 false", enabled),
fmt.Sprintf("- 启用状态:%t未指定时默认 true", enabled),
modelProviderDetailedGuidance(lang, providerID),
"如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。",
}
@@ -621,7 +637,7 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string {
fmt.Sprintf("- Config name: %s", name),
fmt.Sprintf("- Model name: %s", modelName),
fmt.Sprintf("- API URL: %s", apiURL),
fmt.Sprintf("- Enabled: %t (defaults to false if omitted)", enabled),
fmt.Sprintf("- Enabled: %t (defaults to true if omitted)", enabled),
modelProviderDetailedGuidance(lang, providerID),
"Reply 'confirm' to create it, or keep refining any field.",
}
@@ -631,14 +647,14 @@ func formatModelCreateDraftSummary(lang string, session skillSession) string {
func formatExchangeCreateDraftSummary(lang string, session skillSession) string {
exType := defaultIfEmpty(fieldValue(session, "exchange_type"), "未选择")
accountName := defaultIfEmpty(fieldValue(session, "account_name"), "未命名账户")
enabled := fieldValue(session, "enabled") == "true"
enabled := fieldValue(session, "enabled") != "false"
testnet := fieldValue(session, "testnet") == "true"
if lang == "zh" {
lines := []string{
fmt.Sprintf("我先整理了一份交易所配置草稿“%s”。", accountName),
fmt.Sprintf("- 交易所:%s", exType),
fmt.Sprintf("- 账户名:%s", accountName),
fmt.Sprintf("- 启用状态:%t未指定时默认 false", enabled),
fmt.Sprintf("- 启用状态:%t未指定时默认 true", enabled),
fmt.Sprintf("- 测试网:%t未指定时默认 false", testnet),
}
switch exType {
@@ -685,7 +701,7 @@ func formatExchangeCreateDraftSummary(lang string, session skillSession) string
fmt.Sprintf("I prepared a draft exchange config %q.", accountName),
fmt.Sprintf("- Exchange: %s", exType),
fmt.Sprintf("- Account name: %s", accountName),
fmt.Sprintf("- Enabled: %t (defaults to false if omitted)", enabled),
fmt.Sprintf("- Enabled: %t (defaults to true if omitted)", enabled),
fmt.Sprintf("- Testnet: %t (defaults to false if omitted)", testnet),
}
switch exType {
@@ -1449,9 +1465,6 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang
if v := inferCreateDisplayName(text); fieldValue(session, "account_name") == "" && v != "" {
setField(&session, "account_name", v)
}
patch := buildExchangeUpdatePatch(text)
patch, warnings := normalizeExchangePatchToManualLimits(lang, patch)
applyExchangeUpdatePatchToSession(&session, patch)
exType := fieldValue(session, "exchange_type")
accountName := fieldValue(session, "account_name")
missing := make([]string, 0, 6)
@@ -1510,15 +1523,7 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang
session.Phase = "await_create_confirmation"
setSkillDAGStep(&session, "await_create_confirmation")
a.saveSkillSession(userID, session)
reply := formatExchangeCreateDraftSummary(lang, session)
if len(warnings) > 0 {
if lang == "zh" {
reply += "\n这些字段里有超出手动面板范围的值我已经先按风控范围收敛\n- " + strings.Join(warnings, "\n- ")
} else {
reply += "\nSome values exceeded the manual editor limits, so I normalized them first:\n- " + strings.Join(warnings, "\n- ")
}
}
return reply
return formatExchangeCreateDraftSummary(lang, session)
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
@@ -1554,17 +1559,9 @@ func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang
a.clearSkillSession(userID)
a.rememberReferencesFromToolResult(userID, "manage_exchange_config", resp)
if lang == "zh" {
reply := fmt.Sprintf("已创建交易所配置:%s%s。", accountName, exType)
if len(warnings) > 0 {
reply += "\n\n已按手动面板范围自动调整\n- " + strings.Join(warnings, "\n- ")
}
return reply
return fmt.Sprintf("已创建交易所配置:%s%s。", accountName, exType)
}
reply := fmt.Sprintf("Created exchange config %s (%s).", accountName, exType)
if len(warnings) > 0 {
reply += "\n\nAdjusted to stay within the manual editor limits:\n- " + strings.Join(warnings, "\n- ")
}
return reply
return fmt.Sprintf("Created exchange config %s (%s).", accountName, exType)
}
func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
@@ -1587,8 +1584,6 @@ func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, t
if v := inferCreateDisplayName(text); fieldValue(session, "name") == "" && v != "" {
setField(&session, "name", v)
}
patch := buildModelUpdatePatch(text)
applyModelUpdatePatchToSession(&session, patch)
provider := fieldValue(session, "provider")
if provider != "" && fieldValue(session, "api_key") == "" {
if credential := inferModelCredentialFromText(provider, text); credential != "" {
@@ -1791,10 +1786,17 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang,
return result.Question, true
}
}
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
setField(&session, "bulk_scope", "all")
session.TargetRef = nil
}
if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 {
currentStep, _ := currentSkillDAGStep(session)
if currentStep.ID == "resolve_target" {
if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil {
session.TargetRef = resolved.Ref
}
if session.TargetRef == nil {
session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName)
}
@@ -1826,6 +1828,9 @@ func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang,
}
}
} else {
if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil {
session.TargetRef = resolved.Ref
}
if session.TargetRef == nil {
session.TargetRef = a.inferredCurrentReferenceForSkill(userID, skillName)
}

View File

@@ -47,8 +47,8 @@ func normalizeAtomicSkillAction(skill, action string) string {
case "query_binding":
return "query_detail"
case "update":
return "update_name"
case "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return "update_bindings"
case "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return action
}
case "exchange_management":

View File

@@ -237,10 +237,9 @@ func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action st
add(displayCatalogFieldName(field, lang))
}
case "trader_management":
add(slotDisplayName("name", lang))
add(slotDisplayName("exchange", lang))
add(slotDisplayName("model", lang))
add(slotDisplayName("strategy", lang))
if strings.TrimSpace(action) == "create" {
add(slotDisplayName("name", lang))
}
for _, field := range manualTraderEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}

View File

@@ -38,8 +38,8 @@
},
"enabled": {
"type": "bool",
"default": false,
"description": "是否启用该交易所配置。启用前必须通过凭证完整性校验。"
"default": true,
"description": "是否启用该交易所配置。只要必要字段齐全并配置成功,就默认启用。"
},
"hyperliquid_wallet_addr": {
"type": "credential",

View File

@@ -348,11 +348,6 @@
"type": "float",
"min": 0,
"description": "最大保证金占用比例。"
},
"min_position_size": {
"type": "float",
"min": 0,
"description": "最小下单金额。"
}
},
"validation_rules": [
@@ -365,7 +360,8 @@
"删除操作不可逆,必须先向用户确认再执行。",
"激活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、min_position_size 等风控字段若越界,应先自动收敛或提示用户确认修正后的值。",
"btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage 等风控字段若越界,应先自动收敛或提示用户确认修正后的值。",
"最小开仓金额是系统固定值 12 USDTAgent 不能修改;若用户要求改这个值,应直接说明这是手动面板中的 System enforced 固定项。",
"启用量化数据相关开关时,若需要 nofxos_api_key应主动提醒用户补齐。",
"启用排行榜相关能力时,只修改用户明确提到的 enable_*、duration、limit 字段,不要偷偷打开其他排行榜。"
],
@@ -373,7 +369,7 @@
"create": {
"description": "创建策略模板。至少需要名称,其他配置可按需追问或按默认值补齐。",
"required_slots": ["name"],
"optional_slots": ["description", "is_public", "config_visible", "lang", "strategy_type", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "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_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"],
"optional_slots": ["description", "is_public", "config_visible", "lang", "strategy_type", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "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_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage"],
"goal": "创建一个可供 trader 绑定使用的策略模板。",
"dynamic_rules": [
"若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。",
@@ -387,7 +383,7 @@
"update": {
"description": "更新策略模板的任意可编辑字段。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "description", "is_public", "config_visible", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "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_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"],
"optional_slots": ["name", "description", "is_public", "config_visible", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "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_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage"],
"goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",

View File

@@ -25,7 +25,7 @@
"若单笔名义价值超过账户权益的 100%,直接拒绝,不允许创建待确认订单。",
"加密货币订单的杠杆上限受策略 btceth_max_leverage / altcoin_max_leverage 约束,默认上限为 5x超出时直接拒绝。",
"BTC/ETH 单笔最大仓位价值默认不超过 5 倍账户权益,山寨币默认不超过 1 倍账户权益;若策略里有自定义比例,以策略为准。",
"最小仓位价值默认 12 USDT若策略配置了 min_position_size以策略为准。低于最小值时直接拒绝。",
"最小仓位价值固定为 12 USDT这是系统强制项,不允许通过 Agent 修改。低于最小值时直接拒绝。",
"创建后的待确认订单默认 5 分钟有效,超时自动失效。"
],
"success_output": "返回 trade_id、估算仓位价值、是否触发大额确认、确认命令和 5 分钟有效期。",

View File

@@ -2,7 +2,7 @@
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层,核心是名称以及绑定的交易所、模型、策略编辑交易员默认只换绑定,不修改这些依赖对象的内部配置。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时必须收齐名称、交易所、模型、策略;其中交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层;创建交易员时需要名称以及绑定的交易所、模型、策略编辑交易员只允许修改手动面板可改的 6 项:绑定交易所、绑定模型、绑定策略、扫描间隔、保证金模式、是否展示到竞技场;不修改这些依赖对象的内部配置,也不在这里改名。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时交易所、模型、策略既可以直接选择用户已有可用资源也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
@@ -50,45 +50,6 @@
"default": true,
"description": "是否在竞技场中显示该交易员的成绩。"
},
"btc_eth_leverage": {
"type": "int",
"min": 1,
"max": 20,
"description": "交易员级别的 BTC/ETH 杠杆覆盖值,手动面板上限 20。"
},
"altcoin_leverage": {
"type": "int",
"min": 1,
"max": 20,
"description": "交易员级别的山寨币杠杆覆盖值,手动面板上限 20。"
},
"trading_symbols": {
"type": "string",
"description": "指定交易币对,通常为逗号分隔,例如 BTCUSDT,ETHUSDT。"
},
"custom_prompt": {
"type": "string",
"description": "交易员级别附加提示词,用于覆盖或补充默认策略提示。"
},
"override_base_prompt": {
"type": "bool",
"default": false,
"description": "是否完全覆盖系统默认提示词。"
},
"system_prompt_template": {
"type": "string",
"description": "系统提示词模板名称,例如 default。"
},
"use_ai500": {
"type": "bool",
"default": false,
"description": "是否启用 AI500 作为交易员级别候选币来源。"
},
"use_oi_top": {
"type": "bool",
"default": false,
"description": "是否启用 OI Top 作为交易员级别候选币来源。"
},
"auto_start": {
"type": "bool",
"default": false,
@@ -103,8 +64,6 @@
"交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受用户手动设置、充值或修改。",
"启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。",
"若绑定的是 OKX 交易所,启用前必须已有 passphrase若绑定的是 Hyperliquid启用前必须已有 wallet_addr若绑定的是 Aster启用前必须已有 user、signer、private_key若绑定的是 Lighter启用前必须已有 wallet_addr 和 api_key_private_key。",
"btc_eth_leverage 和 altcoin_leverage 若超出系统允许范围,应自动收敛或提示用户修正。",
"trading_symbols 若填写,应保持为可识别的交易对列表格式。",
"启动start和停止stop操作属于高风险操作必须先向用户确认再执行。",
"删除delete操作不可逆必须先向用户确认再执行。"
],
@@ -112,14 +71,13 @@
"create": {
"description": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 skill 新建/启用后自动回流继续。",
"required_slots": ["name", "exchange", "model", "strategy"],
"optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition", "btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "override_base_prompt", "system_prompt_template", "use_ai500", "use_oi_top"],
"optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "创建并初始化一个交易员。",
"dynamic_rules": [
"若用户提到的交易所、模型或策略已经存在且可用,应优先直接补入对应槽位,不要重新创建。",
"若依赖资源不存在、被禁用,或用户明确要求新建或启用,禁止直接报缺字段;应切去对应 management:create 或 management:update_status 子任务。",
"子任务成功后,系统会恢复当前交易员草稿并继续补齐剩余槽位。",
"scan_interval_minutes 超出 360 时,自动收敛并告知用户。",
"若用户明确想覆盖杠杆、币种范围或提示词,应允许在创建阶段一并收集 btc_eth_leverage、altcoin_leverage、trading_symbols、custom_prompt、override_base_prompt、system_prompt_template、use_ai500、use_oi_top。",
"不要向用户收集或确认初始余额;创建时由系统自动读取绑定交易所账户净值作为初始余额。",
"创建完成后询问用户是否立即启动auto_start启动前再次确认。"
],
@@ -127,35 +85,27 @@
"failure_output": "用人话指出缺失依赖项,或说明当前正在进入哪个依赖子任务。"
},
"update": {
"description": "更新已有交易员,但默认只处理改名或换绑策略、交易所、模型。",
"description": "更新已有交易员,但只处理手动面板允许的字段:换绑策略、交易所、模型,或修改扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "exchange_id", "ai_model_id", "strategy_id"],
"goal": "更新一个已有交易员的名称或绑定关系,但不改动策略、模型、交易所内部配置。",
"optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "更新一个已有交易员的手动面板字段,但不改动策略、模型、交易所内部配置。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"换绑交易所/模型/策略时,新的资源必须已存在且已启用,否则提示用户先启用或新建。",
"如果用户要求改名,应明确告知交易员改名不在这里处理。",
"如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update应切到对应 management skill。"
],
"success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。",
"failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。"
},
"update_name": {
"description": "仅修改交易员名称。",
"required_slots": ["target_ref", "name"],
"goal": "把指定交易员改成新的名称,不影响其他配置。",
"dynamic_rules": [
"若当前输入里同时包含别的配置字段,应优先转去更通用的 update而不是只改名。"
],
"success_output": "返回 trader_id并明确告知新的交易员名称。",
"failure_output": "明确指出目标交易员不存在,或新的名称仍未收齐。"
},
"update_bindings": {
"description": "修改交易员绑定的交易所、模型或策略(可同时修改多个)。",
"description": "修改交易员手动面板可编辑的字段,可同时修改绑定关系、扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"optional_slots": ["exchange_id", "ai_model_id", "strategy_id"],
"goal": "调整交易员绑定的依赖资源,而不改动无关配置。",
"optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "调整交易员手动面板可编辑的字段,而不改动无关配置。",
"dynamic_rules": [
"新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。"
"新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。",
"扫描间隔超出 360 时,自动收敛并告知用户。"
],
"success_output": "返回 trader_id并明确展示新的模型/交易所/策略绑定结果。",
"failure_output": "明确指出缺少哪个绑定目标,或当前依赖资源为什么不可直接绑定。"
@@ -214,15 +164,17 @@
"failure_output": "明确指出缺少确认、目标交易员不存在,或当前状态无法停止。"
},
"delete": {
"description": "删除交易员,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"description": "删除交易员,不可逆操作,必须确认。支持删除单个、多个或全部交易员。",
"required_slots": [],
"needs_confirmation": true,
"goal": "删除一个交易员及其运行入口。",
"goal": "删除一个、多个或全部交易员及其运行入口。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒该操作不可逆。"
"必须在确认后执行,并明确提醒该操作不可逆。",
"删除范围可以是单个 target_ref、多个目标或 bulk_scope=all。",
"删除前必须确认目标交易员都已停止;若存在运行中的交易员,不能删除,应要求用户先停止这些交易员。"
],
"success_output": "返回删除成功结果,并明确告知交易员已被移除。",
"failure_output": "明确指出缺少确认、目标交易员不存在,或删除失败原因。"
"success_output": "返回删除成功结果,并明确告知哪些交易员已被移除。",
"failure_output": "明确指出缺少确认、目标交易员不存在、目标仍在运行,或删除失败原因。"
},
"query_list": {
"description": "查询所有交易员列表,包含名称、运行状态、绑定信息。",
@@ -256,7 +208,6 @@
"tool_mapping": {
"create": "manage_trader:create",
"update": "manage_trader:update",
"update_name": "manage_trader:update",
"update_bindings": "manage_trader:update",
"configure_strategy": "manage_trader:update",
"configure_exchange": "manage_trader:update",

View File

@@ -64,7 +64,6 @@ func manualStrategyEditableFieldKeys() []string {
"btceth_max_position_value_ratio",
"altcoin_max_position_value_ratio",
"max_margin_usage",
"min_position_size",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
@@ -139,7 +138,6 @@ func agentStrategyUpdatableFieldKeys() []string {
"btceth_max_position_value_ratio",
"altcoin_max_position_value_ratio",
"max_margin_usage",
"min_position_size",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",

View File

@@ -224,7 +224,6 @@ func strategyConfigSchema() map[string]any {
"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_position_size": map[string]any{"type": "number"},
"min_risk_reward_ratio": map[string]any{"type": "number"},
"min_confidence": map[string]any{"type": "number"},
},
@@ -335,21 +334,13 @@ func traderConfigFieldsSchema() map[string]any {
"type": "string",
"description": "Required for update, delete, start, and stop.",
},
"name": map[string]any{"type": "string", "description": "Trader display name."},
"ai_model_id": map[string]any{"type": "string", "description": "Bound AI model id."},
"exchange_id": map[string]any{"type": "string", "description": "Bound exchange id."},
"strategy_id": map[string]any{"type": "string", "description": "Bound strategy id."},
"scan_interval_minutes": map[string]any{"type": "number", "description": "Trading scan interval in minutes."},
"is_cross_margin": map[string]any{"type": "boolean", "description": "Whether cross margin is enabled."},
"show_in_competition": map[string]any{"type": "boolean", "description": "Whether to show this trader in competition views."},
"btc_eth_leverage": map[string]any{"type": "number", "description": "BTC/ETH leverage override."},
"altcoin_leverage": map[string]any{"type": "number", "description": "Altcoin leverage override."},
"trading_symbols": map[string]any{"type": "string", "description": "Comma-separated symbol list such as BTCUSDT,ETHUSDT."},
"custom_prompt": map[string]any{"type": "string", "description": "Additional trader custom prompt."},
"override_base_prompt": map[string]any{"type": "boolean", "description": "Whether to override the base system prompt."},
"system_prompt_template": map[string]any{"type": "string", "description": "Prompt template preset such as default."},
"use_ai500": map[string]any{"type": "boolean", "description": "Whether to use AI500 candidate sourcing."},
"use_oi_top": map[string]any{"type": "boolean", "description": "Whether to use OI Top candidate sourcing."},
"name": map[string]any{"type": "string", "description": "Trader display name. Required for create."},
"ai_model_id": map[string]any{"type": "string", "description": "Bound AI model id."},
"exchange_id": map[string]any{"type": "string", "description": "Bound exchange id."},
"strategy_id": map[string]any{"type": "string", "description": "Bound strategy id."},
"scan_interval_minutes": map[string]any{"type": "number", "description": "Trading scan interval in minutes."},
"is_cross_margin": map[string]any{"type": "boolean", "description": "Whether cross margin is enabled."},
"show_in_competition": map[string]any{"type": "boolean", "description": "Whether to show this trader in competition views."},
}
}
@@ -515,7 +506,7 @@ func buildAgentTools() []mcp.Tool {
Type: "function",
Function: mcp.FunctionDef{
Name: "manage_trader",
Description: "List, create, update, delete, start, or stop traders. Use this when the user asks to create a trader, rename one, switch its exchange/model/strategy bindings, or control its running state. If the user wants to modify the internal config of a strategy, model, or exchange, use the corresponding management tool instead.",
Description: "List, create, update, delete, start, or stop traders. Trader edits are limited to exchange/model/strategy bindings, scan interval, margin mode, and competition visibility so they match the manual trader panel. If the user wants to modify the internal config of a strategy, model, or exchange, use the corresponding management tool instead.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -523,22 +514,14 @@ func buildAgentTools() []mcp.Tool {
"type": "string",
"enum": []string{"list", "create", "update", "delete", "start", "stop"},
},
"trader_id": traderConfigFieldsSchema()["trader_id"],
"name": traderConfigFieldsSchema()["name"],
"ai_model_id": traderConfigFieldsSchema()["ai_model_id"],
"exchange_id": traderConfigFieldsSchema()["exchange_id"],
"strategy_id": traderConfigFieldsSchema()["strategy_id"],
"scan_interval_minutes": traderConfigFieldsSchema()["scan_interval_minutes"],
"is_cross_margin": traderConfigFieldsSchema()["is_cross_margin"],
"show_in_competition": traderConfigFieldsSchema()["show_in_competition"],
"btc_eth_leverage": traderConfigFieldsSchema()["btc_eth_leverage"],
"altcoin_leverage": traderConfigFieldsSchema()["altcoin_leverage"],
"trading_symbols": traderConfigFieldsSchema()["trading_symbols"],
"custom_prompt": traderConfigFieldsSchema()["custom_prompt"],
"override_base_prompt": traderConfigFieldsSchema()["override_base_prompt"],
"system_prompt_template": traderConfigFieldsSchema()["system_prompt_template"],
"use_ai500": traderConfigFieldsSchema()["use_ai500"],
"use_oi_top": traderConfigFieldsSchema()["use_oi_top"],
"trader_id": traderConfigFieldsSchema()["trader_id"],
"name": traderConfigFieldsSchema()["name"],
"ai_model_id": traderConfigFieldsSchema()["ai_model_id"],
"exchange_id": traderConfigFieldsSchema()["exchange_id"],
"strategy_id": traderConfigFieldsSchema()["strategy_id"],
"scan_interval_minutes": traderConfigFieldsSchema()["scan_interval_minutes"],
"is_cross_margin": traderConfigFieldsSchema()["is_cross_margin"],
"show_in_competition": traderConfigFieldsSchema()["show_in_competition"],
},
"required": []string{"action"},
},
@@ -825,21 +808,16 @@ type safeModelToolConfig struct {
}
type safeTraderToolConfig struct {
ID string `json:"id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
StrategyID string `json:"strategy_id,omitempty"`
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"`
IsCrossMargin bool `json:"is_cross_margin"`
ShowInCompetition bool `json:"show_in_competition"`
BTCETHLeverage int `json:"btc_eth_leverage,omitempty"`
AltcoinLeverage int `json:"altcoin_leverage,omitempty"`
TradingSymbols string `json:"trading_symbols,omitempty"`
CustomPrompt string `json:"custom_prompt,omitempty"`
SystemPromptTemplate string `json:"system_prompt_template,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
StrategyID string `json:"strategy_id,omitempty"`
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"`
IsCrossMargin bool `json:"is_cross_margin"`
ShowInCompetition bool `json:"show_in_competition"`
}
type safeStrategyToolConfig struct {
@@ -886,24 +864,15 @@ func stripSensitiveToolFields(value any) any {
}
type manageTraderArgs struct {
Action string `json:"action"`
TraderID string `json:"trader_id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
StrategyID string `json:"strategy_id"`
InitialBalance *float64 `json:"initial_balance"`
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"`
ShowInCompetition *bool `json:"show_in_competition"`
BTCETHLeverage *int `json:"btc_eth_leverage"`
AltcoinLeverage *int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt *bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"`
UseAI500 *bool `json:"use_ai500"`
UseOITop *bool `json:"use_oi_top"`
Action string `json:"action"`
TraderID string `json:"trader_id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
StrategyID string `json:"strategy_id"`
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"`
ShowInCompetition *bool `json:"show_in_competition"`
}
func safeExchangeForTool(ex *store.Exchange) safeExchangeToolConfig {
@@ -1034,21 +1003,16 @@ func modelConfigUsable(provider, modelID, apiKey, customAPIURL, customModelName
func safeTraderForTool(trader *store.Trader, isRunning bool) safeTraderToolConfig {
return safeTraderToolConfig{
ID: trader.ID,
Name: trader.Name,
AIModelID: trader.AIModelID,
ExchangeID: trader.ExchangeID,
StrategyID: trader.StrategyID,
InitialBalance: trader.InitialBalance,
ScanIntervalMinutes: trader.ScanIntervalMinutes,
IsRunning: isRunning,
IsCrossMargin: trader.IsCrossMargin,
ShowInCompetition: trader.ShowInCompetition,
BTCETHLeverage: trader.BTCETHLeverage,
AltcoinLeverage: trader.AltcoinLeverage,
TradingSymbols: trader.TradingSymbols,
CustomPrompt: trader.CustomPrompt,
SystemPromptTemplate: trader.SystemPromptTemplate,
ID: trader.ID,
Name: trader.Name,
AIModelID: trader.AIModelID,
ExchangeID: trader.ExchangeID,
StrategyID: trader.StrategyID,
InitialBalance: trader.InitialBalance,
ScanIntervalMinutes: trader.ScanIntervalMinutes,
IsRunning: isRunning,
IsCrossMargin: trader.IsCrossMargin,
ShowInCompetition: trader.ShowInCompetition,
}
}
@@ -1455,9 +1419,9 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string {
if trimmed := strings.TrimSpace(args.LighterAPIKeyPrivateKey); trimmed != "" {
effectiveLighterAPIKeyPrivateKey = trimmed
}
if err := (exchangeConfigValidator{
validator := exchangeConfigValidator{
exchangeType: existing.ExchangeType,
enabled: enabled,
enabled: true,
apiKey: effectiveAPIKey,
secretKey: effectiveSecretKey,
passphrase: effectivePassphrase,
@@ -1468,7 +1432,14 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string {
lighterWalletAddr: lighterWallet,
lighterPrivateKey: effectiveLighterPrivateKey,
lighterAPIKeyPrivateKey: effectiveLighterAPIKeyPrivateKey,
}).Validate(); err != nil {
}
if args.Enabled == nil {
if err := validator.Validate(); err == nil {
enabled = true
}
}
validator.enabled = enabled
if err := validator.Validate(); err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err)
}
if err := a.store.Exchange().Update(
@@ -1817,6 +1788,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
if name == "" {
return `{"error":"name is required for create"}`
}
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
}
if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err)
}
@@ -1866,6 +1840,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
if strategyID == "" {
return `{"error":"strategy_id is required for update"}`
}
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
}
existing, err := a.store.Strategy().Get(storeUserID, strategyID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err)
@@ -2234,14 +2211,8 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri
if existing == nil {
return `{"error":"trader not found"}`
}
name := existing.Name
if trimmed := strings.TrimSpace(args.Name); trimmed != "" {
name = trimmed
}
if !sameEntityName(name, existing.Name) {
if err := a.ensureUniqueTraderName(storeUserID, name, existing.ID); err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err)
}
if trimmed := strings.TrimSpace(args.Name); trimmed != "" && !sameEntityName(trimmed, existing.Name) {
return `{"error":"trader rename is not supported here; only bindings, scan interval, margin mode, and competition visibility can be edited"}`
}
aiModelID := existing.AIModelID
if trimmed := strings.TrimSpace(args.AIModelID); trimmed != "" {
@@ -2261,7 +2232,7 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri
record := &store.Trader{
ID: existing.ID,
UserID: storeUserID,
Name: name,
Name: existing.Name,
AIModelID: aiModelID,
ExchangeID: exchangeID,
StrategyID: strategyID,
@@ -2291,30 +2262,6 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri
if args.ShowInCompetition != nil {
record.ShowInCompetition = *args.ShowInCompetition
}
if args.BTCETHLeverage != nil && *args.BTCETHLeverage > 0 {
record.BTCETHLeverage = *args.BTCETHLeverage
}
if args.AltcoinLeverage != nil && *args.AltcoinLeverage > 0 {
record.AltcoinLeverage = *args.AltcoinLeverage
}
if trimmed := strings.TrimSpace(args.TradingSymbols); trimmed != "" {
record.TradingSymbols = trimmed
}
if trimmed := strings.TrimSpace(args.CustomPrompt); trimmed != "" {
record.CustomPrompt = trimmed
}
if args.OverrideBasePrompt != nil {
record.OverrideBasePrompt = *args.OverrideBasePrompt
}
if trimmed := strings.TrimSpace(args.SystemPromptTemplate); trimmed != "" {
record.SystemPromptTemplate = trimmed
}
if args.UseAI500 != nil {
record.UseAI500 = *args.UseAI500
}
if args.UseOITop != nil {
record.UseOITop = *args.UseOITop
}
if err := a.store.Trader().Update(record); err != nil {
return fmt.Sprintf(`{"error":"failed to update trader: %s"}`, err)
}
@@ -2334,13 +2281,27 @@ func (a *Agent) toolDeleteTrader(storeUserID, traderID string) string {
if traderID == "" {
return `{"error":"trader_id is required for delete"}`
}
if a.traderManager != nil {
if trader, err := a.traderManager.GetTrader(traderID); err == nil {
if running, ok := trader.GetStatus()["is_running"].(bool); ok && running {
return `{"error":"trader is running; stop it before deleting"}`
}
}
}
if record, err := a.store.Trader().GetFullConfig(storeUserID, traderID); err == nil && record != nil && record.Trader != nil && record.Trader.IsRunning {
return `{"error":"trader is running; stop it before deleting"}`
}
if traders, err := a.store.Trader().List(storeUserID); err == nil {
for _, trader := range traders {
if trader != nil && trader.ID == traderID && trader.IsRunning {
return `{"error":"trader is running; stop it before deleting"}`
}
}
}
if err := a.store.Trader().Delete(storeUserID, traderID); err != nil {
return fmt.Sprintf(`{"error":"failed to delete trader: %s"}`, err)
}
if a.traderManager != nil {
if trader, err := a.traderManager.GetTrader(traderID); err == nil {
trader.Stop()
}
a.traderManager.RemoveTrader(traderID)
}
result, _ := json.Marshal(map[string]any{
@@ -2983,6 +2944,36 @@ func maxInt(a, b int) int {
return b
}
func strategyLockedFieldError(lang, field string) string {
switch strings.TrimSpace(field) {
case "min_position_size":
if lang == "zh" {
return "最小开仓金额是系统固定值 12 USDT手动面板里也是 System enforcedAgent 不能修改。"
}
return "The minimum position size is a fixed system value of 12 USDT. It is System enforced in the manual panel and cannot be changed by the agent."
default:
if lang == "zh" {
return "这个字段是系统固定项Agent 不能修改。"
}
return "This field is system enforced and cannot be changed by the agent."
}
}
func strategyConfigContainsLockedField(config map[string]any) (string, bool) {
if len(config) == 0 {
return "", false
}
if _, ok := config["min_position_size"]; ok {
return "min_position_size", true
}
if risk, ok := config["risk_control"].(map[string]any); ok {
if _, ok := risk["min_position_size"]; ok {
return "min_position_size", true
}
}
return "", false
}
func validKlineInterval(interval string) bool {
switch strings.TrimSpace(strings.ToLower(interval)) {
case "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w", "1mo":

View File

@@ -1,11 +1,16 @@
package agent
import (
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestClassifyWorkflowTaskTreatsTraderEditAsBindingsOrRename(t *testing.T) {
func TestClassifyWorkflowTaskTreatsTraderEditAsManualPanelUpdate(t *testing.T) {
task, ok := classifyWorkflowTask("帮我把交易员小爱换策略")
if !ok {
t.Fatal("expected trader binding edit to classify")
@@ -14,12 +19,12 @@ func TestClassifyWorkflowTaskTreatsTraderEditAsBindingsOrRename(t *testing.T) {
t.Fatalf("unexpected task: %+v", task)
}
task, ok = classifyWorkflowTask("帮我把交易员小爱改名")
task, ok = classifyWorkflowTask("帮我把交易员小爱扫描间隔改成10分钟")
if !ok {
t.Fatal("expected trader rename to classify")
t.Fatal("expected trader manual-panel edit to classify")
}
if task.Skill != "trader_management" || task.Action != "update_name" {
t.Fatalf("unexpected rename task: %+v", task)
if task.Skill != "trader_management" || task.Action != "update_bindings" {
t.Fatalf("unexpected trader update task: %+v", task)
}
}
@@ -35,3 +40,424 @@ func TestTraderDomainPrimerExplainsInternalConfigBoundary(t *testing.T) {
}
}
}
func TestLoadEnabledModelOptionsUseConfigNameAsPrimaryLabel(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-model-options.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
options := a.loadEnabledModelOptions("default")
if len(options) != 1 {
t.Fatalf("expected one model option, got %d", len(options))
}
if options[0].Name != "DeepSeek AI" {
t.Fatalf("expected primary option label to stay on config name, got %q", options[0].Name)
}
if !strings.Contains(options[0].Hint, "deepseek-chat") || !strings.Contains(options[0].Hint, "deepseek") {
t.Fatalf("expected hint to retain runtime model/provider context, got %q", options[0].Hint)
}
}
func TestHydrateCreateTraderSlotReferencesNormalizesModelIDFromVisibleName(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-model-id-normalize.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "create",
Fields: map[string]string{
"model_id": "DeepSeek AI",
},
}
a.hydrateCreateTraderSlotReferences("default", &session)
if got := fieldValue(session, "model_id"); got != "default_deepseek" {
t.Fatalf("expected visible model name in model_id slot to normalize to actual id, got %q", got)
}
if got := fieldValue(session, "model_name"); got != "DeepSeek AI" {
t.Fatalf("expected normalized model name to be preserved, got %q", got)
}
}
func TestHydrateCreateTraderSlotReferencesNormalizesExchangeIDFromVisibleName(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-exchange-id-normalize.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
exchangeID, err := st.Exchange().Create("default", "okx", "小偶", true, "api-test", "secret-test", "pass", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "create",
Fields: map[string]string{
"exchange_id": "小偶",
},
}
a.hydrateCreateTraderSlotReferences("default", &session)
if got := fieldValue(session, "exchange_id"); got != exchangeID {
t.Fatalf("expected visible exchange name in exchange_id slot to normalize to actual id, got %q", got)
}
if got := fieldValue(session, "exchange_name"); got != "小偶" {
t.Fatalf("expected normalized exchange name to be preserved, got %q", got)
}
}
func TestToolDeleteTraderRejectsRunningTrader(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "delete-running-trader.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.Trader().Create(&store.Trader{
ID: "trader-running",
UserID: "default",
Name: "运行中",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 100,
ScanIntervalMinutes: 3,
IsRunning: true,
}); err != nil {
t.Fatalf("seed trader: %v", err)
}
resp := a.toolDeleteTrader("default", "trader-running")
if !strings.Contains(resp, "stop it before deleting") {
t.Fatalf("expected running trader delete to be rejected, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected running trader to remain, got %d traders", len(traders))
}
}
func TestBulkTraderDeleteDeletesOnlyStoppedTraders(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
for _, trader := range []*store.Trader{
{ID: "trader-stopped", UserID: "default", Name: "已停止", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: false},
{ID: "trader-running", UserID: "default", Name: "运行中", AIModelID: "model-1", ExchangeID: "exchange-1", InitialBalance: 100, ScanIntervalMinutes: 3, IsRunning: true},
} {
if err := st.Trader().Create(trader); err != nil {
t.Fatalf("seed trader %s: %v", trader.ID, err)
}
}
session := skillSession{
Name: "trader_management",
Action: "delete",
Phase: "await_confirmation",
Fields: map[string]string{
"bulk_scope": "all",
skillDAGStepField: "await_confirmation",
},
}
resp := a.executeBulkTraderDelete("default", 99, "zh", "确认", session)
if !strings.Contains(resp, "成功删除 1 个") || !strings.Contains(resp, "运行中") {
t.Fatalf("expected stopped trader deleted and running trader skipped, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 || traders[0].ID != "trader-running" {
t.Fatalf("expected only running trader to remain, got: %+v", traders)
}
}
func TestBulkTraderDeleteRequiresConfirmationBeforeDeleting(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "bulk-delete-traders-confirmation.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.Trader().Create(&store.Trader{
ID: "trader-stopped",
UserID: "default",
Name: "已停止",
AIModelID: "model-1",
ExchangeID: "exchange-1",
InitialBalance: 100,
ScanIntervalMinutes: 3,
IsRunning: false,
}); err != nil {
t.Fatalf("seed trader: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "delete",
Fields: map[string]string{
"bulk_scope": "all",
},
}
resp := a.executeBulkTraderDelete("default", 99, "zh", "全部删除", session)
if !strings.Contains(resp, "请回复“确认”继续") {
t.Fatalf("expected confirmation prompt, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected trader to remain before confirmation, got %d traders", len(traders))
}
}
func TestResolveTargetSelectionMatchesUniqueNameInUserText(t *testing.T) {
options := []traderSkillOption{
{ID: "exchange-a", Name: "okx"},
{ID: "exchange-b", Name: "为:小易"},
{ID: "exchange-c", Name: "小偶"},
}
resolved := resolveTargetSelection("先把 为:小易 删掉,其他 5 个先保留", options, nil)
if resolved.Ref == nil {
t.Fatal("expected target ref to resolve from user text")
}
if resolved.Ref.ID != "exchange-b" || resolved.Ref.Name != "为:小易" {
t.Fatalf("unexpected resolved target: %+v", resolved.Ref)
}
}
func TestBulkStrategyDeleteRequiresConfirmationBeforeDeleting(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "bulk-delete-strategies-confirmation.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-custom",
UserID: "default",
Name: "自定义策略",
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
session := skillSession{
Name: "strategy_management",
Action: "delete",
Fields: map[string]string{
"bulk_scope": "all",
},
}
resp := a.executeStrategyManagementAction("default", 99, "zh", "全部删除", session)
if !strings.Contains(resp, "请回复“确认”继续") {
t.Fatalf("expected confirmation prompt, got: %s", resp)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
found := false
for _, strategy := range strategies {
if strategy.ID == "strategy-custom" {
found = true
}
}
if !found {
t.Fatal("expected strategy to remain before confirmation")
}
}
func TestEnsureLiveTargetReferenceFallsBackFromStaleIDToName(t *testing.T) {
session := skillSession{
TargetRef: &EntityReference{
ID: "stale-id",
Name: "小易",
},
}
options := []traderSkillOption{
{ID: "exchange-a", Name: "okx"},
{ID: "exchange-b", Name: "为:小易"},
}
if !ensureLiveTargetReference(&session, options) {
t.Fatal("expected stale id with matching name to resolve")
}
if session.TargetRef == nil || session.TargetRef.ID != "exchange-b" || session.TargetRef.Name != "为:小易" {
t.Fatalf("unexpected target ref after live check: %+v", session.TargetRef)
}
}
func TestBuildTraderCreateMissingPromptListsAllMissingSlots(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-create-missing-prompt.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek AI", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "okx", "OKX 主账户", true, "api-test", "secret-test", "pass", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
_ = exchangeID
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-ai500",
UserID: "default",
Name: "AI500稳重策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
session := skillSession{
Name: "trader_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{},
}
prompt := a.buildTraderCreateMissingPrompt("default", "zh", session, a.buildTraderCreateConversationResources("default", session))
for _, want := range []string{"名称", "交易所", "模型", "策略"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected missing prompt to include %q, got: %s", want, prompt)
}
}
for _, want := range []string{"现有交易所", "现有模型", "现有策略"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected missing prompt to include options line %q, got: %s", want, prompt)
}
}
}
func TestPlannerContextModeFollowsRouterContextSwitch(t *testing.T) {
if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{ContextSwitch: true}); got != "fresh_context" {
t.Fatalf("expected fresh context mode, got %q", got)
}
if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{}); got != "" {
t.Fatalf("expected default context mode, got %q", got)
}
}
func TestLLMFlowExtractionFiltersFieldsToAllowedSchema(t *testing.T) {
result := llmFlowExtractionResult{
Intent: "continue",
Tasks: []llmFlowExtractionTask{{
Skill: "exchange_management",
Action: "create",
Fields: map[string]string{
"secret": "wrong-key",
"secret_key": "canonical-secret",
"api_key": "api",
},
}},
}
filtered := filterLLMFlowExtractionFields(result, []llmFlowFieldSpec{
{Key: "secret_key"},
{Key: "api_key"},
})
fields := filtered.Tasks[0].Fields
if _, ok := fields["secret"]; ok {
t.Fatalf("expected invented field key to be filtered, got: %+v", fields)
}
if fields["secret_key"] != "canonical-secret" || fields["api_key"] != "api" {
t.Fatalf("expected canonical fields to remain, got: %+v", fields)
}
}
func TestExchangeCreateAllowedFieldSpecsUseCanonicalSecretKey(t *testing.T) {
specs := allowedFieldSpecsForSkillSession(skillSession{Name: "exchange_management", Action: "create"}, "zh")
foundSecretKey := false
for _, spec := range specs {
if spec.Key == "secret" {
t.Fatal("exchange create schema should not expose non-canonical secret key")
}
if spec.Key == "secret_key" {
foundSecretKey = true
}
}
if !foundSecretKey {
t.Fatal("expected exchange create schema to include canonical secret_key")
}
}
func TestActiveSessionExtractedDataFiltersToAllowedSchema(t *testing.T) {
session := ActiveSkillSession{
SkillName: "exchange_management",
ActionName: "create",
CollectedFields: map[string]any{
"exchange_type": "okx",
},
}
filtered := filterExtractedDataForActiveSession(session, map[string]any{
"account_name": "呢呢",
"api_key": "api",
"secret": "wrong-key",
"secret_key": "canonical-secret",
"passphrase": "pass",
}, "zh")
if _, ok := filtered["secret"]; ok {
t.Fatalf("expected central brain alias key to be filtered, got: %+v", filtered)
}
for _, key := range []string{"account_name", "api_key", "secret_key", "passphrase"} {
if _, ok := filtered[key]; !ok {
t.Fatalf("expected canonical key %q to remain, got: %+v", key, filtered)
}
}
}
func TestBrainUserPromptIncludesActiveAllowedFieldSchema(t *testing.T) {
prompt := buildBrainUserPrompt(
"zh",
"密钥是abc123456",
"要创建交易所配置还缺这些字段Secret。",
"",
"",
ActiveSkillSession{SkillName: "exchange_management", ActionName: "create"},
true,
)
if !strings.Contains(prompt, "allowed_field_spec_json") || !strings.Contains(prompt, `"secret_key"`) {
t.Fatalf("expected brain prompt to expose canonical field schema, got:\n%s", prompt)
}
}

View File

@@ -511,7 +511,7 @@ func looksLikeCompoundTraderIntent(text string) bool {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasBindingsOrConfig := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "杠杆", "提示词"})
hasBindingsOrConfig := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasLifecycle := containsAny(lower, []string{"启动", "开始", "start", "停止", "stop"})
return (hasCreate && (hasBindingsOrConfig || hasLifecycle)) ||
(hasBindingsOrConfig && hasLifecycle)
@@ -554,7 +554,7 @@ Each task must include:
- request
- depends_on (array, may be empty)
Rules:
- Prefer atomic actions such as create, update_name, update_bindings, configure_strategy, configure_exchange, configure_model, update_status, update_endpoint, update_config, update_prompt, activate, duplicate, start, stop, delete, query_list, query_detail.
- Prefer atomic actions such as create, update_bindings, configure_strategy, configure_exchange, configure_model, update_status, update_endpoint, update_config, update_prompt, activate, duplicate, start, stop, delete, query_list, query_detail.
- If one request contains create plus follow-up edits in the same skill, split them into multiple tasks.
- If later tasks need an entity created earlier, make the dependency explicit in depends_on.
- Keep each request user-readable and self-contained enough for a single skill handler to execute.
@@ -717,7 +717,7 @@ func classifyContextualStrategyWorkflowTasks(text string) []WorkflowTask {
func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask {
lower := strings.ToLower(strings.TrimSpace(text))
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "改名", "重命名"})
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"})
hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"})
if !hasUpdate && !hasStart && !hasStop {
@@ -725,12 +725,7 @@ func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask {
}
var tasks []WorkflowTask
if hasUpdate {
action := "update_bindings"
if containsAny(lower, []string{"改名", "重命名", "rename", "name"}) &&
!containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) {
action = "update_name"
}
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text})
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text})
}
if hasStart {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text})
@@ -804,7 +799,7 @@ func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask {
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "改名", "重命名"})
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"})
hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"})
@@ -813,12 +808,7 @@ func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "create", Request: text})
}
if hasUpdate {
action := "update_bindings"
if containsAny(lower, []string{"改名", "重命名", "rename", "name"}) &&
!containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) {
action = "update_name"
}
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text})
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text})
}
if hasStart {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text})
@@ -847,9 +837,6 @@ func classifyCompoundModelWorkflowTasks(text string) []WorkflowTask {
}
if hasConfig {
action := "update_endpoint"
if extractModelNameValue(text) != "" || extractCredentialValue(text, []string{"api key", "apikey", "api_key"}) != "" {
action = "update_endpoint"
}
tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: action, Request: text})
}
if hasStatus {
@@ -925,12 +912,10 @@ func classifyWorkflowTask(text string) (WorkflowTask, bool) {
action = "stop"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}):
case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"}):
action = "update_bindings"
case containsAny(lower, []string{"改名", "重命名", "rename", "名字", "名称", "name"}):
action = "update_name"
case containsAny(lower, []string{"修改", "更新", "改"}):
action = "update"
action = "update_bindings"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):