Simplify agent skill routing and config updates

This commit is contained in:
lky-spec
2026-04-26 22:22:12 +08:00
parent cfd91069d3
commit ce3a8582af
12 changed files with 325 additions and 925 deletions

View File

@@ -10,16 +10,16 @@ import (
// ActiveSkillSession is the minimal session for the central brain architecture. // ActiveSkillSession is the minimal session for the central brain architecture.
// It replaces the old skillSession + ExecutionState combo for management skill flows. // It replaces the old skillSession + ExecutionState combo for management skill flows.
type ActiveSkillSession struct { type ActiveSkillSession struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
SkillName string `json:"skill_name"` SkillName string `json:"skill_name"`
ActionName string `json:"action_name"` ActionName string `json:"action_name"`
LegacyPhase string `json:"legacy_phase,omitempty"` LegacyPhase string `json:"legacy_phase,omitempty"`
Goal string `json:"goal,omitempty"` Goal string `json:"goal,omitempty"`
PendingHint *PendingHint `json:"pending_hint,omitempty"` PendingHint *PendingHint `json:"pending_hint,omitempty"`
CollectedFields map[string]any `json:"collected_fields,omitempty"` CollectedFields map[string]any `json:"collected_fields,omitempty"`
LocalHistory []chatMessage `json:"local_history,omitempty"` LocalHistory []chatMessage `json:"local_history,omitempty"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
type PendingHint struct { type PendingHint struct {
@@ -207,6 +207,19 @@ func activeSessionHasField(s ActiveSkillSession, slot string) bool {
} }
} }
return false return false
case "exchange":
value, ok := s.CollectedFields["exchange_id"]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
case "model":
for _, key := range []string{"model_id", "ai_model_id"} {
if value, ok := s.CollectedFields[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
return true
}
}
return false
case "strategy":
value, ok := s.CollectedFields["strategy_id"]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
default: default:
value, ok := s.CollectedFields[slot] value, ok := s.CollectedFields[slot]
return ok && strings.TrimSpace(fmt.Sprint(value)) != "" return ok && strings.TrimSpace(fmt.Sprint(value)) != ""

View File

@@ -92,6 +92,7 @@ Rules:
- extracted_data should include any concrete facts from the user's message. - 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. - 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"}. - If the user clearly means a bulk destructive operation like "删除所有策略" or "全部删除策略", put the intent signal into extracted_data too. Example: {"bulk_scope":"all"}.
- For strategy changes, do not use the generic "strategy_management:update" action. Use "strategy_management:update_name" for renaming, "strategy_management:update_prompt" for prompt changes, or "strategy_management:update_config" for parameter/config changes. For strategy_management:update_config, extracted_data may include a StrategyConfig-shaped "config_patch".
- reply_to_user should be concise and in the user's language. - 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". - For NEW_TASK, target_skill format must be "skill_name:action", for example "strategy_management:create".
@@ -417,6 +418,8 @@ Rules:
- Ask naturally. Do not say raw slot names like target_ref unless the user explicitly asks for internal details. - Ask naturally. Do not say raw slot names like target_ref unless the user explicitly asks for internal details.
- If the user clearly means a bulk destructive operation like "删除所有策略", "全部删除策略", "all strategies", set extracted_data to {"bulk_scope":"all"} and choose "execute_skill". Do not ask for target_ref. - If the user clearly means a bulk destructive operation like "删除所有策略", "全部删除策略", "all strategies", set extracted_data to {"bulk_scope":"all"} and choose "execute_skill". Do not ask for target_ref.
- If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it. - If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it.
- For trader bindings, exchange/model/strategy must resolve to an ID from Relevant disclosed resources before execution. Never invent a resource name or use a generic venue type like Binance/OKX as the bound exchange unless it appears as an actual disclosed resource.
- For strategy_management:create or strategy_management:update_config, when the user describes strategy intent, output config_patch as a partial StrategyConfig JSON object instead of leaving the default template unchanged. Example: "BTC趋势做空" should set coin_source to static BTCUSDT and add prompt/risk/entry rules for BTC trend-following short bias.
- If there are multiple targets and the user did not disambiguate, ask a natural question with the available names. - If 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. - 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. - extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys.
@@ -531,7 +534,7 @@ func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
Fields: make(map[string]string), Fields: make(map[string]string),
} }
for k, v := range s.CollectedFields { for k, v := range s.CollectedFields {
str := strings.TrimSpace(fmt.Sprint(v)) str := activeFieldString(v)
if str == "" || str == "<nil>" { if str == "" || str == "<nil>" {
continue continue
} }
@@ -559,6 +562,23 @@ func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
return legacy return legacy
} }
func activeFieldString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case map[string]any, []any, map[string]string, []string:
raw, err := json.Marshal(v)
if err != nil {
return ""
}
return strings.TrimSpace(string(raw))
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func activeSessionFromLegacy(base ActiveSkillSession, legacy skillSession) ActiveSkillSession { func activeSessionFromLegacy(base ActiveSkillSession, legacy skillSession) ActiveSkillSession {
next := base next := base
next.LegacyPhase = strings.TrimSpace(legacy.Phase) next.LegacyPhase = strings.TrimSpace(legacy.Phase)

View File

@@ -1,13 +1,10 @@
package agent package agent
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"nofx/mcp"
) )
type llmFlowExtractionTask struct { type llmFlowExtractionTask struct {
@@ -65,77 +62,6 @@ Rules:
return systemPrompt, strings.Join(sections, "\n") return systemPrompt, strings.Join(sections, "\n")
} }
func (a *Agent) extractSkillSessionFieldsWithLLM(ctx context.Context, userID int64, lang, text string, session skillSession) llmFlowExtractionResult {
if a == nil || a.aiClient == nil {
return llmFlowExtractionResult{}
}
text = strings.TrimSpace(text)
if text == "" {
return llmFlowExtractionResult{}
}
flowSummary, fieldSpecs, currentValues, missingFields := skillSessionExtractionContext(session, lang)
recentConversationCtx := a.buildRecentConversationContext(userID, text)
state := a.getExecutionState(userID)
currentRefs := state.CurrentReferences
if currentRefs == nil {
currentRefs = a.semanticCurrentReferences(userID)
}
skillContext := buildCurrentSkillExecutionContext(lang, session)
systemPrompt, userPrompt := buildActiveFlowExtractionPrompt(
lang,
"skill_session",
flowSummary,
text,
recentConversationCtx,
currentRefs,
a.SnapshotManager(userID).List(),
[]string{
skillContext,
fmt.Sprintf("Allowed field spec JSON: %s", mustMarshalJSON(fieldSpecs)),
fmt.Sprintf("Current flow field values JSON: %s", mustMarshalJSON(currentValues)),
fmt.Sprintf("Current missing fields JSON: %s", mustMarshalJSON(missingFields)),
},
)
waitingHint := ""
if len(missingFields) > 0 {
waitingHint = fmt.Sprintf("\n- The flow is currently waiting for the user to provide: [%s]. Before deciding \"switch\", first check whether the user message can fill any of these fields — even without an explicit prefix or keyword.", strings.Join(missingFields, ", "))
}
systemPrompt += `
- 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.
- When the user clearly wants to create a new dependency resource (e.g. "选2", "新建策略", "创建交易所"), set inline_sub_intent="create_sub_resource".
- When the user clearly wants to edit/update an existing dependency resource (e.g. "编辑策略", "改一下模型"), set inline_sub_intent="edit_sub_resource".
- For "switch", you may return tasks for the new request if they are clear enough.
- If no field can be safely extracted for the current flow, return "switch" or "instant_reply", not fake fields.` + waitingHint + `
Return JSON with this shape:
{"intent":"continue|switch|cancel|instant_reply","target_snapshot_id":"","inline_sub_intent":"","tasks":[{"skill":"","action":"","fields":{}}],"reason":""}`
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return llmFlowExtractionResult{}
}
return filterLLMFlowExtractionFields(parseLLMFlowExtractionResult(raw), fieldSpecs)
}
func parseLLMFlowExtractionResult(raw string) llmFlowExtractionResult { func parseLLMFlowExtractionResult(raw string) llmFlowExtractionResult {
out, ok := parseRawFlowExtractionEnvelope(raw) out, ok := parseRawFlowExtractionEnvelope(raw)
if !ok { if !ok {
@@ -240,6 +166,23 @@ func filterLLMFlowExtractionFields(result llmFlowExtractionResult, specs []llmFl
return result return result
} }
func formatConversationMissingFields(lang string, missingFields []string) string {
if len(missingFields) == 0 {
if lang == "zh" {
return "当前没有缺失槽位。"
}
return "There are currently no missing slots."
}
display := make([]string, 0, len(missingFields))
for _, field := range missingFields {
display = append(display, slotDisplayName(field, lang))
}
if lang == "zh" {
return "当前仍缺这些槽位:" + strings.Join(display, "、")
}
return "Current missing slots: " + strings.Join(display, ", ")
}
func skillSessionExtractionContext(session skillSession, lang string) (string, []llmFlowFieldSpec, map[string]string, []string) { func skillSessionExtractionContext(session skillSession, lang string) (string, []llmFlowFieldSpec, map[string]string, []string) {
currentStep, _ := currentSkillDAGStep(session) currentStep, _ := currentSkillDAGStep(session)
fieldSpecs := allowedFieldSpecsForSkillSession(session, lang) fieldSpecs := allowedFieldSpecsForSkillSession(session, lang)
@@ -308,6 +251,13 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
add(&out, "is_cross_margin", displayCatalogFieldName("is_cross_margin", lang), false) add(&out, "is_cross_margin", displayCatalogFieldName("is_cross_margin", lang), false)
add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false) add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false)
case "strategy_management": case "strategy_management":
if session.Action == "create" || session.Action == "update_config" {
add(&out, "config_patch", "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use this for strategy requirements such as target coins, trend style, short/long bias, indicators, risk, timeframes, and prompt sections.", false)
}
if session.Action == "update_prompt" {
add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false)
add(&out, "custom_prompt", strategyConfigFieldDisplayName("custom_prompt", lang), false)
}
if session.Action == "update_config" { if session.Action == "update_config" {
add(&out, "config_field", strategyConfigFieldDisplayName("config_field", lang), false) add(&out, "config_field", strategyConfigFieldDisplayName("config_field", lang), false)
add(&out, "config_value", strategyConfigFieldDisplayName("config_value", lang), false) add(&out, "config_value", strategyConfigFieldDisplayName("config_value", lang), false)
@@ -403,17 +353,17 @@ func missingFieldKeysForSkillSession(session skillSession) []string {
if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" { if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" {
switch session.Action { switch session.Action {
case "configure_strategy": case "configure_strategy":
if fieldValue(session, "strategy_id") == "" && fieldValue(session, "strategy_name") == "" { if fieldValue(session, "strategy_id") == "" {
missing = append(missing, "strategy_name") missing = append(missing, "strategy_name")
} }
break break
case "configure_exchange": case "configure_exchange":
if fieldValue(session, "exchange_id") == "" && fieldValue(session, "exchange_name") == "" { if fieldValue(session, "exchange_id") == "" {
missing = append(missing, "exchange_name") missing = append(missing, "exchange_name")
} }
break break
case "configure_model": case "configure_model":
if fieldValue(session, "model_id") == "" && fieldValue(session, "model_name") == "" { if fieldValue(session, "model_id") == "" {
missing = append(missing, "model_name") missing = append(missing, "model_name")
} }
break break
@@ -434,13 +384,13 @@ func missingFieldKeysForSkillSession(session skillSession) []string {
if fieldValue(session, "name") == "" { if fieldValue(session, "name") == "" {
missing = append(missing, "name") missing = append(missing, "name")
} }
if fieldValue(session, "exchange_id") == "" && fieldValue(session, "exchange_name") == "" { if fieldValue(session, "exchange_id") == "" {
missing = append(missing, "exchange_name") missing = append(missing, "exchange_name")
} }
if fieldValue(session, "model_id") == "" && fieldValue(session, "model_name") == "" { if fieldValue(session, "model_id") == "" {
missing = append(missing, "model_name") missing = append(missing, "model_name")
} }
if fieldValue(session, "strategy_id") == "" && fieldValue(session, "strategy_name") == "" { if fieldValue(session, "strategy_id") == "" {
missing = append(missing, "strategy_name") missing = append(missing, "strategy_name")
} }
} }
@@ -449,16 +399,29 @@ func missingFieldKeysForSkillSession(session skillSession) []string {
missing = append(missing, "target_ref") missing = append(missing, "target_ref")
} }
switch session.Action { switch session.Action {
case "update_name":
if fieldValue(session, "name") == "" {
missing = append(missing, "name")
}
case "update_prompt":
if fieldValue(session, "prompt") == "" && fieldValue(session, "custom_prompt") == "" {
missing = append(missing, "prompt")
}
case "update_config": case "update_config":
if fieldValue(session, "config_patch") != "" {
break
}
if fieldValue(session, "config_field") == "" { if fieldValue(session, "config_field") == "" {
missing = append(missing, "config_field") missing = append(missing, "config_field")
} else if fieldValue(session, "config_value") == "" { } else if fieldValue(session, "config_value") == "" {
missing = append(missing, "config_value") missing = append(missing, "config_value")
} }
default: case "create":
if fieldValue(session, "name") == "" { if fieldValue(session, "name") == "" {
missing = append(missing, "name") missing = append(missing, "name")
} }
default:
missing = append(missing, "update_field")
} }
} }
sort.Strings(missing) sort.Strings(missing)

View File

@@ -1,237 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
type skillConversationResult struct {
Ready bool `json:"ready"`
Question string `json:"question,omitempty"`
Extracted map[string]string `json:"extracted,omitempty"`
DraftGeneratedFields map[string]string `json:"draft_generated_fields,omitempty"`
RequiresConfirmationBeforeApply bool `json:"requires_confirmation_before_apply,omitempty"`
UserRejectedFlow bool `json:"user_rejected_flow,omitempty"`
Cancel bool `json:"cancel,omitempty"`
NeedsClarification bool `json:"needs_clarification,omitempty"`
}
// llmSkillConversationDriver replaces rule-based field collection.
// It gives the LLM the skill schema, current collected fields, available resources,
// and the current waiting fields — then lets LLM decide what to ask or whether to proceed.
func (a *Agent) llmSkillConversationDriver(
ctx context.Context,
storeUserID string,
userID int64,
lang, text string,
session skillSession,
availableResources map[string]any,
) skillConversationResult {
if a == nil || a.aiClient == nil {
return skillConversationResult{}
}
currentFields := currentFieldValuesForSkillSession(session)
missingFields := missingFieldKeysForSkillSession(session)
recentCtx := a.buildRecentConversationContext(userID, text)
skillJSON := loadSkillJSON(session.Name)
skillContext := buildCurrentSkillExecutionContext(lang, session)
relevantResources := filterConversationResourcesForSession(session, missingFields, availableResources)
missingSummary := formatConversationMissingFields(lang, missingFields)
domainPrimer := buildSkillDomainPrimer(lang, session.Name)
resourcesJSON, _ := json.Marshal(relevantResources)
currentFieldsJSON, _ := json.Marshal(currentFields)
waitingHint := ""
if len(missingFields) > 0 {
waitingHint = fmt.Sprintf("\nCurrently waiting for: [%s]. The user's message may be answering one of these fields directly — recognize it even without a keyword prefix.", strings.Join(missingFields, ", "))
}
systemPrompt := fmt.Sprintf(`You are the conversation driver for NOFXi skill: %s / %s.
Your job: first understand what the user means in this exact turn, then decide how to continue the current skill action.
You are not a keyword matcher. Infer whether the user is filling a slot, choosing an existing resource, asking to create/enable a dependency, clarifying an earlier answer, or cancelling.
Active skill/action contract:
%s
Skill schema JSON (field constraints and action definitions):
%s
Skill domain primer:
%s
Only the currently relevant resource groups are disclosed below. Use them only when they help resolve the current missing slots. Do not assume omitted resource groups are unavailable globally.
Available resources (each resource includes an ID and display name; return the ID when you can resolve it):
%s
Current collected fields:
%s
%s
Rules:
- Highest-priority safety rule: before extracting any field, first judge whether the user is rejecting, correcting, or denying the current task itself.
- If the current flow is wrong, the user is saying things like "不是交易员,是策略", "弄错了", "不是这个", "I mean the strategy, not the trader", or the core entity has clearly crossed into another domain, do NOT extract any field.
- In those rejection/correction/cross-domain cases, immediately return {"user_rejected_flow":true,"ready":false,"question":"","extracted":{}}.
- Any user-facing question or reply must be simple, clear, and beginner-friendly.
- Treat the user like a trading beginner, not a developer.
- Prefer short sentences and plain language.
- Do not expose internal field names, JSON keys, tool names, or backend terminology to the user unless the user explicitly asks.
- If the user is cancelling, return {"cancel":true}
- If the user answer is ambiguous, return {"ready":false,"needs_clarification":true,"question":"<clarifying question in %s>","extracted":{...any newly extracted fields...}}
- If disclosed resources include an ambiguity/conflict list for the current target, do not repeat a robotic stock phrase. Use the disclosed distinguishing details to ask a natural clarifying question.
- If the user clearly delegates content generation to you (for example: "交给你", "你帮我写", "你自己设计", "you decide", "draft it for me", "所有字段都由你来定", "你帮我配置好"), do not mechanically ask for the same text again.
- In those delegation cases, when the missing slot is a text-like field such as custom_prompt, role_definition, trading_frequency, entry_standards, decision_process, description, or name, you should draft a strong candidate yourself, put that draft into draft_generated_fields, keep ready false if confirmation is still needed, set requires_confirmation_before_apply=true, and use question to show the draft and ask for confirmation.
- When the user delegates ALL fields (e.g. "所有字段都由你来定", "你帮我全部配好", "all fields up to you"), also infer reasonable values for structured fields (such as static_coins, primary_timeframe, selected_timeframes, btceth_max_leverage, altcoin_max_leverage, min_confidence, source_type, etc.) based on the strategy name and stated goal. Put all inferred structured values into draft_generated_fields as well. Present a concise summary of ALL drafted fields in the question and ask for one confirmation before applying.
- If all required fields are collected and there is no ambiguity, return {"ready":true,"extracted":{...all newly resolved fields for this turn...}}
- Otherwise, return {"ready":false,"question":"<natural language next question in %s>","extracted":{...any newly extracted fields...}}
- Extract fields from the user message even without keyword prefixes
- When asking for a field that has available options, list them concisely in the question
- Never ask for fields that are already collected
- For entity refs (exchange, model, strategy): if the user clearly means one option from available resources, use its ID and put it in extracted as exchange_id/ai_model_id/strategy_id
- For target object selection: if the user clearly means one option from available targets, return target_ref_id and target_ref_name
- If the user says to use an existing/current/already-configured resource and there is exactly one usable option in the disclosed resource group, resolve it automatically to that ID
- If multiple disclosed options fit and the user did not disambiguate, ask a clarifying question instead of guessing
- "ready" must stay false if any DAG-required slot is still missing or ambiguous. Current missing field summary: %s
- Distinguish between user-supplied values (put in extracted) and AI-drafted proposal values (put in draft_generated_fields). Do not pretend AI-generated drafts were literal user input.
Return JSON only. No markdown.`,
session.Name, session.Action,
defaultIfEmpty(skillContext, "No active contract available."),
skillJSON,
defaultIfEmpty(domainPrimer, "No extra domain primer."),
defaultIfEmpty(string(resourcesJSON), "{}"),
string(currentFieldsJSON),
waitingHint,
lang,
lang,
missingSummary,
)
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s", lang, text, recentCtx)
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return skillConversationResult{}
}
return parseSkillConversationResult(raw)
}
func filterConversationResourcesForSession(session skillSession, missingFields []string, availableResources map[string]any) map[string]any {
if len(availableResources) == 0 {
return nil
}
need := map[string]bool{}
for _, field := range missingFields {
switch strings.TrimSpace(field) {
case "target_ref":
need["targets"] = true
case "exchange", "exchange_id", "exchange_name":
need["exchanges"] = true
case "model", "model_id", "model_name", "ai_model_id":
need["models"] = true
case "strategy", "strategy_id", "strategy_name":
need["strategies"] = true
}
}
if len(need) == 0 {
switch session.Action {
case "configure_exchange":
need["exchanges"] = true
case "configure_model":
need["models"] = true
case "configure_strategy":
need["strategies"] = true
}
}
if len(need) == 0 {
return nil
}
filtered := make(map[string]any, len(need))
for key := range need {
if value, ok := availableResources[key]; ok {
filtered[key] = value
}
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func formatConversationMissingFields(lang string, missingFields []string) string {
if len(missingFields) == 0 {
if lang == "zh" {
return "当前没有缺失槽位。"
}
return "There are currently no missing slots."
}
display := make([]string, 0, len(missingFields))
for _, field := range missingFields {
display = append(display, slotDisplayName(field, lang))
}
if lang == "zh" {
return "当前仍缺这些槽位:" + strings.Join(display, "、")
}
return "Current missing slots: " + strings.Join(display, ", ")
}
func parseSkillConversationResult(raw string) skillConversationResult {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out skillConversationResult
if err := json.Unmarshal([]byte(raw), &out); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
json.Unmarshal([]byte(raw[start:end+1]), &out)
}
}
if !out.Cancel && !out.UserRejectedFlow && !out.Ready && out.Question == "" && len(out.Extracted) == 0 && len(out.DraftGeneratedFields) == 0 {
var flow llmFlowExtractionResult
if err := json.Unmarshal([]byte(raw), &flow); err == nil {
if strings.TrimSpace(flow.Intent) == "continue" {
if len(flow.Fields) > 0 {
out.Extracted = flow.Fields
} else if len(flow.Tasks) > 0 {
out.Extracted = flow.Tasks[0].Fields
}
if len(out.Extracted) > 0 {
out.Ready = true
}
}
}
}
out.Question = strings.TrimSpace(out.Question)
return out
}
// loadSkillJSON returns the raw skill JSON bytes for the given skill name.
func loadSkillJSON(skillName string) string {
data, err := embeddedSkillDefinitions.ReadFile("skills/" + skillName + ".json")
if err != nil {
return "{}"
}
return string(data)
}

View File

@@ -10,20 +10,10 @@ import (
) )
type llmSkillRouteDecision struct { type llmSkillRouteDecision struct {
Intent string `json:"intent,omitempty"` Intent string `json:"intent,omitempty"`
TargetSnapshotID string `json:"target_snapshot_id,omitempty"` TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
TargetSkill string `json:"target_skill,omitempty"` ContextSwitch bool `json:"context_switch,omitempty"`
ExtractedFields map[string]any `json:"extracted_fields,omitempty"` Confidence float64 `json:"confidence,omitempty"`
NeedPlannerHelp bool `json:"need_planner_help,omitempty"`
Route string `json:"route"`
Track string `json:"track,omitempty"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Filter string `json:"filter,omitempty"`
InlineSubIntent string `json:"inline_sub_intent,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
ContextSwitch bool `json:"context_switch,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
} }
func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) { func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
@@ -46,29 +36,19 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) { if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) {
return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent) return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent)
} }
if _, has := a.getActiveSkillSession(userID); has { return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
if a.hasAnyActiveContext(userID) {
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
}
return "", false, nil
case "cancel": case "cancel":
a.clearPendingProposalSession(userID) a.clearPendingProposalSession(userID)
if a.hasAnyActiveContext(userID) { if a.hasAnyActiveContext(userID) {
a.clearSkillSession(userID) a.clearActiveSkillSession(userID)
a.clearWorkflowSession(userID) a.clearAnyActiveContext(userID)
a.clearExecutionState(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
} }
return "", false, nil return "", false, nil
case "resume_snapshot": case "resume_snapshot":
a.clearPendingProposalSession(userID) a.clearPendingProposalSession(userID)
if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) { if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) {
if _, has := a.getActiveSkillSession(userID); has { return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
} }
return "", false, nil return "", false, nil
case "instant_reply": case "instant_reply":
@@ -82,126 +62,14 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
return answer, true, err return answer, true, err
} }
interruptedActiveContext := false
if a.hasAnyActiveContext(userID) { if a.hasAnyActiveContext(userID) {
a.clearPendingProposalSession(userID) a.clearPendingProposalSession(userID)
if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, decision.TargetSnapshotID) { if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, decision.TargetSnapshotID) {
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) return a.tryMinimalBrain(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)
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.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.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, plannerContextModeFromRouteDecision(decision), onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, true, execErr
} }
} }
return "", false, nil return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
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 {
return "", false, nil
}
if isReadOnlyAtomicSkillAction(outcome.Skill, outcome.Action) {
answer := strings.TrimSpace(outcome.UserMessage)
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_intent_plan"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
emitStreamText(onEvent, answer)
}
return answer, true, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return "", false, nil
}
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
}
if review.Route == "replan" {
answer, planErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("Original user request:\n%s\n\nPrevious skill outcome JSON:\n%s", text, mustMarshalJSON(outcome)), onEvent)
return answer, true, planErr
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_intent_plan"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
emitStreamText(onEvent, answer)
}
return answer, true, nil
}
func isReadOnlyAtomicSkillAction(skill, action string) bool {
action = strings.TrimSpace(strings.ToLower(action))
switch action {
case "query", "query_list", "query_detail", "query_running", "query_strategy_binding", "query_exchange_binding", "query_model_binding":
return true
}
return false
} }
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) { func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
@@ -228,73 +96,12 @@ func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision { func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
decision.Intent = strings.TrimSpace(strings.ToLower(decision.Intent)) decision.Intent = strings.TrimSpace(strings.ToLower(decision.Intent))
decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID) decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID)
decision.TargetSkill = strings.TrimSpace(strings.ToLower(decision.TargetSkill))
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Track = strings.TrimSpace(strings.ToLower(decision.Track))
decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill))
decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter))
decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
if decision.Confidence < 0 { if decision.Confidence < 0 {
decision.Confidence = 0 decision.Confidence = 0
} }
if decision.Confidence > 1 { if decision.Confidence > 1 {
decision.Confidence = 1 decision.Confidence = 1
} }
if decision.Route == "" {
switch {
case len(decision.Tasks) > 1:
decision.Route = "workflow"
case decision.TargetSkill != "":
decision.Route = "skill"
case decision.Skill != "" || decision.Action != "":
decision.Route = "skill"
case decision.Track == "planning_track":
decision.Route = "planner"
}
}
if decision.Track == "" {
switch decision.Route {
case "skill", "workflow":
decision.Track = "fast_track"
case "planner":
decision.Track = "planning_track"
}
}
if decision.Intent == "" {
switch {
case decision.Route == "instant_reply":
decision.Intent = "instant_reply"
case decision.TargetSnapshotID != "" && decision.Route == "" && decision.Skill == "" && decision.Action == "" && len(decision.Tasks) == 0:
decision.Intent = "resume_snapshot"
case decision.Route != "" || decision.Track != "" || decision.Skill != "" || decision.Action != "" || decision.TargetSkill != "" || len(decision.Tasks) > 0:
decision.Intent = "start_new"
}
}
if decision.Skill == "" && decision.Action == "" && decision.TargetSkill != "" {
decision.Skill, decision.Action = parseTargetSkill(decision.TargetSkill)
}
if decision.Route == "" && decision.NeedPlannerHelp {
decision.Route = "planner"
}
if decision.Route == "workflow" {
decision.Skill = ""
decision.Action = ""
decision.Filter = ""
return decision
}
if decision.Route != "skill" {
decision.Action = ""
decision.Skill = ""
decision.Filter = ""
decision.Tasks = nil
return decision
}
decision.Tasks = nil
if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" {
decision.Action = "query_running"
} else {
decision.Action = normalizeAtomicSkillAction(decision.Skill, decision.Action)
}
return decision return decision
} }
@@ -331,7 +138,6 @@ func (a *Agent) buildTopLevelRouterPrompt(userID int64, lang, text string) (stri
snapshotJSON, _ := json.Marshal(snapshots) snapshotJSON, _ := json.Marshal(snapshots)
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)) currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
managementSummary := buildManagementSkillRoutingContextWithSession(lang, &activeSkill)
recentConversation := a.buildRecentConversationContext(userID, text) recentConversation := a.buildRecentConversationContext(userID, text)
if strings.TrimSpace(recentConversation) == "" { if strings.TrimSpace(recentConversation) == "" {
recentConversation = "(empty)" recentConversation = "(empty)"
@@ -342,11 +148,11 @@ func (a *Agent) buildTopLevelRouterPrompt(userID int64, lang, text string) (stri
activeFlowSummary = "none" activeFlowSummary = "none"
} }
systemPrompt := prependNOFXiAdvisorPreamble(`You are the lightweight intent planner for NOFXi. systemPrompt := prependNOFXiAdvisorPreamble(`You are the lightweight topic router for NOFXi.
Return JSON only. Return JSON only.
You are deciding what the current user turn should do at the top level. Your only job is to decide whether the current user turn continues the current topic/state, starts a new topic, resumes a suspended topic, cancels the current topic, or is a direct conversational reply.
You must classify every message into exactly one of these intents before any execution layer takes over. Do not perform business intent recognition. Do not choose skills, actions, tasks, or fields. The central brain will do that after you return.
Valid intents: Valid intents:
- "continue_active": the user is still working on the current active flow - "continue_active": the user is still working on the current active flow
@@ -355,11 +161,6 @@ Valid intents:
- "cancel": the user wants to cancel the current active flow - "cancel": the user wants to cancel the current active flow
- "instant_reply": the user is greeting, chatting, thanking, or asking for a direct explanation without changing task state - "instant_reply": the user is greeting, chatting, thanking, or asking for a direct explanation without changing task state
Valid routes when intent=start_new:
- "skill"
- "workflow"
- "planner"
Rules: Rules:
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question. - Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question.
- If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks. - If 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.
@@ -367,47 +168,41 @@ Rules:
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active". - If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
- If the user explicitly refers to a suspended task like "刚才那个", "恢复刚才那个", choose "resume_snapshot" and fill target_snapshot_id. - If the user explicitly refers to a suspended task like "刚才那个", "恢复刚才那个", choose "resume_snapshot" and fill target_snapshot_id.
- If the user is only greeting, thanking, social chatting, or asking a concept question without changing task state, choose "instant_reply". - If the user is only greeting, thanking, social chatting, or asking a concept question without changing task state, choose "instant_reply".
- 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. - 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. - Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON.
Return JSON with this exact shape: Return JSON with this exact shape:
{"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,"need_planner_help":false,"confidence":0.0}`) {"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","context_switch":false,"confidence":0.0}`)
if strings.TrimSpace(activeSkill.Name) != "" || hasActiveTask || hasPendingProposal { if strings.TrimSpace(activeSkill.Name) != "" || hasActiveTask || hasPendingProposal {
systemPrompt = prependNOFXiAdvisorPreamble(`You are the one-pass semantic gateway for NOFXi. systemPrompt = prependNOFXiAdvisorPreamble(`You are the one-pass topic gateway for NOFXi.
Return JSON only. Return JSON only.
You are deciding whether the user is continuing the current active flow, switching to a new task, resuming a suspended snapshot, cancelling, or simply asking for a direct reply. Your only job is topic-state routing: continuing the active flow, switching to a new topic, resuming a suspended snapshot, cancelling, or giving a direct conversational reply.
Do not perform business intent recognition. Do not choose skills, actions, tasks, or fields. The central brain will do that after you return.
Rules: Rules:
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question. - Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question.
- If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks. - If 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. - 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 asks a read-only management query while an active flow is open, output intent "start_new"; the central brain will choose the query tool.
- If the user starts a multi-step, multi-domain, batch, or condition-based management request while an active flow is open, output intent "start_new", 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 starts a multi-step, multi-domain, batch, or condition-based management request while an active flow is open, output intent "start_new"; the central brain will decompose it.
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active". - 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". - 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 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 cancels the current task, use "cancel".
- If the user only greets, thanks, chats, or asks for explanation without changing state, use "instant_reply". - 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. - 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. - Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON.
Return JSON with this exact shape: Return JSON with this exact shape:
{"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}`) {"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","context_switch":false,"confidence":0.0}`)
} }
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\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", userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n",
lang, lang,
text, text,
defaultIfEmpty(previousAssistantReply, "(empty)"), defaultIfEmpty(previousAssistantReply, "(empty)"),
defaultIfEmpty(managementSummary, "(empty)"),
defaultIfEmpty(buildManagementDomainPrimer(lang), "(empty)"),
currentRefs, currentRefs,
activeFlowSummary, activeFlowSummary,
defaultIfEmpty(string(snapshotJSON), "[]"), defaultIfEmpty(string(snapshotJSON), "[]"),
@@ -480,45 +275,6 @@ func countPendingWorkflowTasks(session WorkflowSession) int {
return count return count
} }
func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) {
session := skillSession{Name: decision.Skill, Action: decision.Action, Phase: "collecting"}
applyExtractedFieldsToSkillSession(&session, decision.ExtractedFields, "llm_router")
return a.executeAtomicSkillTaskOutcomeWithSession(storeUserID, userID, lang, text, session, nil)
}
func applyExtractedFieldsToSkillSession(session *skillSession, values map[string]any, source string) {
if session == nil || len(values) == 0 {
return
}
ensureSkillFields(session)
for key, raw := range values {
value := strings.TrimSpace(fmt.Sprint(raw))
if value == "" {
continue
}
switch key {
case "target_ref_id":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.ID = value
if source != "" {
session.TargetRef.Source = source
}
case "target_ref_name":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.Name = value
if source != "" {
session.TargetRef.Source = source
}
default:
setField(session, key, value)
}
}
}
func buildCurrentReferenceSummary(lang string, refs *CurrentReferences) string { func buildCurrentReferenceSummary(lang string, refs *CurrentReferences) string {
if refs == nil { if refs == nil {
if lang == "zh" { if lang == "zh" {
@@ -606,11 +362,18 @@ func hasAnyActiveContext(a *Agent, userID int64) bool {
if a == nil { if a == nil {
return false return false
} }
if _, ok := a.getActiveSkillSession(userID); ok {
return true
}
return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID)) return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID))
} }
func (a *Agent) clearAnyActiveContext(userID int64) bool { func (a *Agent) clearAnyActiveContext(userID int64) bool {
cleared := false cleared := false
if _, ok := a.getActiveSkillSession(userID); ok {
a.clearActiveSkillSession(userID)
cleared = true
}
if a.hasActiveSkillSession(userID) { if a.hasActiveSkillSession(userID) {
a.clearSkillSession(userID) a.clearSkillSession(userID)
cleared = true cleared = true

View File

@@ -895,6 +895,9 @@ func (a *Agent) hasActiveSkillSession(userID int64) bool {
} }
func (a *Agent) hasAnyActiveContext(userID int64) bool { func (a *Agent) hasAnyActiveContext(userID int64) bool {
if _, ok := a.getActiveSkillSession(userID); ok {
return true
}
if a.hasActiveSkillSession(userID) { if a.hasActiveSkillSession(userID) {
return true return true
} }
@@ -1285,6 +1288,9 @@ func (a *Agent) replyToActiveFlowInstantReply(ctx context.Context, userID int64,
func (a *Agent) handoffFromActiveFlow(ctx context.Context, storeUserID string, userID int64, lang, text, targetSnapshotID string, onEvent func(event, data string)) (string, bool, error) { func (a *Agent) handoffFromActiveFlow(ctx context.Context, storeUserID string, userID int64, lang, text, targetSnapshotID string, onEvent func(event, data string)) (string, bool, error) {
if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, targetSnapshotID) { if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, targetSnapshotID) {
if a.aiClient != nil {
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent) return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
} }
if answer, ok, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { if answer, ok, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil {
@@ -2451,15 +2457,13 @@ func isExplicitFlowAbort(text string) bool {
func belongsToSkillDomain(skillName, text string) bool { func belongsToSkillDomain(skillName, text string) bool {
switch strings.TrimSpace(skillName) { switch strings.TrimSpace(skillName) {
case "trader_management": case "trader_management":
return hasExplicitCreateIntentForDomain(text, "trader") || detectTraderManagementIntent(text) || hasExplicitDiagnosisIntentForDomain(text, "trader") return hasExplicitCreateIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "trader")
case "strategy_management": case "strategy_management":
return detectStrategyManagementIntent(text) || hasExplicitDiagnosisIntentForDomain(text, "strategy") return hasExplicitDiagnosisIntentForDomain(text, "strategy")
case "model_management": case "model_management":
return detectModelManagementIntent(text) || return hasExplicitDiagnosisIntentForDomain(text, "model")
hasExplicitDiagnosisIntentForDomain(text, "model")
case "exchange_management": case "exchange_management":
return detectExchangeManagementIntent(text) || return hasExplicitDiagnosisIntentForDomain(text, "exchange")
hasExplicitDiagnosisIntentForDomain(text, "exchange")
default: default:
return false return false
} }
@@ -2474,10 +2478,6 @@ func looksLikeNewTopLevelIntent(text string) bool {
return true return true
} }
if hasExplicitCreateIntentForDomain(text, "trader") || if hasExplicitCreateIntentForDomain(text, "trader") ||
detectTraderManagementIntent(text) ||
detectExchangeManagementIntent(text) ||
detectModelManagementIntent(text) ||
detectStrategyManagementIntent(text) ||
hasExplicitDiagnosisIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "trader") ||
hasExplicitDiagnosisIntentForDomain(text, "exchange") || hasExplicitDiagnosisIntentForDomain(text, "exchange") ||
hasExplicitDiagnosisIntentForDomain(text, "model") || hasExplicitDiagnosisIntentForDomain(text, "model") ||

View File

@@ -366,50 +366,6 @@ func (a *Agent) tryHardSkill(ctx context.Context, storeUserID string, userID int
return answer, true return answer, true
} }
} }
if detectTraderManagementIntent(text) {
answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, emptySession)
if handled {
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:trader_management")
emitStreamText(onEvent, answer)
}
return answer, true
}
}
if detectExchangeManagementIntent(text) {
answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, emptySession)
if handled {
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:exchange_management")
emitStreamText(onEvent, answer)
}
return answer, true
}
}
if detectModelManagementIntent(text) {
answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, emptySession)
if handled {
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:model_management")
emitStreamText(onEvent, answer)
}
return answer, true
}
}
if detectStrategyManagementIntent(text) {
answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, emptySession)
if handled {
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:strategy_management")
emitStreamText(onEvent, answer)
}
return answer, true
}
}
if hasExplicitDiagnosisIntentForDomain(text, "model") { if hasExplicitDiagnosisIntentForDomain(text, "model") {
answer := a.handleModelDiagnosisSkill(storeUserID, lang, text) answer := a.handleModelDiagnosisSkill(storeUserID, lang, text)
a.recordSkillInteraction(userID, text, answer) a.recordSkillInteraction(userID, text, answer)
@@ -602,31 +558,6 @@ func renderSkillMissingLabels(lang string, missing []string) []string {
return out return out
} }
func (a *Agent) fallbackTraderCreateConversation(storeUserID, lang, text string, session skillSession, availableResources map[string]any) skillConversationResult {
result := skillConversationResult{Extracted: map[string]string{}}
text = strings.TrimSpace(text)
if text == "" {
result.Question = a.buildTraderCreateMissingPrompt(storeUserID, lang, session, availableResources)
return result
}
if isCancelSkillReply(text) {
result.Cancel = true
return result
}
probe := session
for k, v := range result.Extracted {
setField(&probe, k, v)
}
a.hydrateCreateTraderSlotReferences(storeUserID, &probe)
if missing := missingFieldKeysForSkillSession(probe); len(missing) > 0 {
result.Question = a.buildTraderCreateMissingPrompt(storeUserID, lang, probe, a.buildTraderCreateConversationResources(storeUserID, probe))
return result
}
result.Ready = true
result.Question = formatTraderCreateDraftSummary(lang, probe)
return result
}
func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session skillSession, availableResources map[string]any) string { func (a *Agent) buildTraderCreateMissingPrompt(storeUserID, lang string, session skillSession, availableResources map[string]any) string {
missing := missingFieldKeysForSkillSession(session) missing := missingFieldKeysForSkillSession(session)
missingLabels := strings.Join(renderSkillMissingLabels(lang, missing), "、") missingLabels := strings.Join(renderSkillMissingLabels(lang, missing), "、")

View File

@@ -41,56 +41,6 @@ func clearGeneratedDraftConfirmation(session *skillSession, keys ...string) {
} }
} }
func parseStandaloneInteger(text string) (int, bool) {
return 0, false
}
func parseStandaloneFloat(text string) (float64, bool) {
return 0, false
}
func parseEnabledValue(text string) (bool, bool) {
return false, false
}
func parseFlagValue(text string, keywords []string) (bool, bool) {
return false, false
}
func parseScanIntervalMinutes(text string) (int, bool) {
return 0, false
}
type entityFieldPatch struct {
Field string
Value string
}
func entityFieldPatchesFromCatalog(catalog []entityFieldMeta, values map[string]string) []entityFieldPatch {
patches := make([]entityFieldPatch, 0, len(values))
for _, meta := range catalog {
value := strings.TrimSpace(values[meta.Key])
if value == "" {
continue
}
patches = append(patches, entityFieldPatch{Field: meta.Key, Value: value})
}
return patches
}
func fieldMetaByKey(catalog []entityFieldMeta, key string) (entityFieldMeta, bool) {
for _, meta := range catalog {
if meta.Key == key {
return meta, true
}
}
return entityFieldMeta{}, false
}
func parseFieldValueFromMeta(text string, meta entityFieldMeta) (string, bool) {
return "", false
}
func detectCatalogField(text string, catalog []entityFieldMeta) string { func detectCatalogField(text string, catalog []entityFieldMeta) string {
lower := strings.ToLower(strings.TrimSpace(text)) lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" { if lower == "" {
@@ -120,31 +70,6 @@ func detectCatalogField(text string, catalog []entityFieldMeta) string {
return bestKey return bestKey
} }
func looksLikeBareFieldSelection(text string, keywords []string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
trimNoise := func(s string) string {
s = strings.TrimSpace(s)
for _, prefix := range []string{"改", "改一下", "改下", "修改", "更新", "设置", "设成", "设为", "change", "update", "set"} {
s = strings.TrimSpace(strings.TrimPrefix(s, prefix))
}
for _, suffix := range []string{"呢", "吧", "呀", "一下", "这个", "字段", "配置"} {
s = strings.TrimSpace(strings.TrimSuffix(s, suffix))
}
return strings.Trim(s, "“”\"':,。.;")
}
normalizedText := trimNoise(lower)
for _, keyword := range keywords {
normalizedKeyword := trimNoise(strings.ToLower(keyword))
if normalizedKeyword != "" && normalizedText == normalizedKeyword {
return true
}
}
return false
}
func displayCatalogFieldName(field, lang string) string { func displayCatalogFieldName(field, lang string) string {
switch field { switch field {
case "name": case "name":
@@ -1180,21 +1105,6 @@ func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) er
return nil return nil
} }
func extractLabeledFloat(text string, labels []string) (float64, bool) {
lower := strings.ToLower(text)
for _, label := range labels {
idx := strings.Index(lower, strings.ToLower(label))
if idx < 0 {
continue
}
sub := text[idx+len(label):]
if value, ok := parseStandaloneFloat(sub); ok {
return value, true
}
}
return 0, false
}
func parseSourceTypeValue(text string) string { func parseSourceTypeValue(text string) string {
lower := strings.ToLower(strings.TrimSpace(text)) lower := strings.ToLower(strings.TrimSpace(text))
switch { switch {
@@ -2503,7 +2413,7 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64
return "已删除策略。" return "已删除策略。"
} }
return "Deleted strategy." return "Deleted strategy."
case "update", "update_name", "update_config", "update_prompt": case "update_name", "update_config", "update_prompt":
if session.Action == "update_prompt" { if session.Action == "update_prompt" {
return a.executeStrategyPromptUpdate(storeUserID, userID, lang, text, session) return a.executeStrategyPromptUpdate(storeUserID, userID, lang, text, session)
} }
@@ -2542,6 +2452,12 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64
return fmt.Sprintf("已将策略改名为“%s”。", newName) return fmt.Sprintf("已将策略改名为“%s”。", newName)
} }
return fmt.Sprintf("Renamed strategy to %q.", newName) return fmt.Sprintf("Renamed strategy to %q.", newName)
case "update":
a.clearSkillSession(userID)
if lang == "zh" {
return "我需要先明确你要改策略的哪一部分:名称、提示词,还是策略参数。"
}
return "I need to know which part of the strategy to update: name, prompt, or config."
default: default:
return "" return ""
} }
@@ -2647,6 +2563,37 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
return "I could not load that strategy just now: " + err.Error() return "I could not load that strategy just now: " + err.Error()
} }
if patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField)); patchRaw != "" {
var patch map[string]any
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
setSkillDAGStep(&session, "resolve_config_field")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "策略配置 patch 不是合法 JSON" + err.Error()
}
return "The strategy config patch is not valid JSON: " + err.Error()
}
merged, err := store.MergeStrategyConfig(cfg, patch)
if err != nil {
setSkillDAGStep(&session, "resolve_config_field")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "策略配置 patch 无法应用:" + err.Error()
}
return "The strategy config patch could not be applied: " + err.Error()
}
beforeClamp := merged
merged.ClampLimits()
msgZH := "已更新策略配置。"
msgEN := "Updated strategy config."
setSkillDAGStep(&session, "apply_field_update")
if warnings := store.StrategyClampWarnings(beforeClamp, merged, lang); len(warnings) > 0 {
return a.deferStrategyRiskControlledUpdate(userID, lang, &session, merged, warnings, msgZH, msgEN)
}
setSkillDAGStep(&session, "execute_update")
return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, merged, msgZH, msgEN)
}
if generatedDraftRequiresConfirmation(session) && fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" { if generatedDraftRequiresConfirmation(session) && fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" {
if generated := fieldValue(session, "custom_prompt"); generated != "" { if generated := fieldValue(session, "custom_prompt"); generated != "" {
setField(&session, "config_field", "custom_prompt") setField(&session, "config_field", "custom_prompt")

View File

@@ -13,10 +13,6 @@ import (
var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`) var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`)
func detectTraderManagementIntent(text string) bool {
return false
}
func hasExplicitCreateIntentForDomain(text, domain string) bool { func hasExplicitCreateIntentForDomain(text, domain string) bool {
lower := strings.ToLower(strings.TrimSpace(text)) lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" || !hasExplicitManagementDomainCue(text, domain) { if lower == "" || !hasExplicitManagementDomainCue(text, domain) {
@@ -25,18 +21,6 @@ func hasExplicitCreateIntentForDomain(text, domain string) bool {
return containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "create", "new"}) return containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "create", "new"})
} }
func detectExchangeManagementIntent(text string) bool {
return false
}
func detectModelManagementIntent(text string) bool {
return false
}
func detectStrategyManagementIntent(text string) bool {
return false
}
func hasExplicitDiagnosisIntentForDomain(text, domain string) bool { func hasExplicitDiagnosisIntentForDomain(text, domain string) bool {
lower := strings.ToLower(strings.TrimSpace(text)) lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" || !hasExplicitManagementDomainCue(text, domain) { if lower == "" || !hasExplicitManagementDomainCue(text, domain) {
@@ -56,10 +40,6 @@ func hasExplicitDiagnosisIntentForDomain(text, domain string) bool {
} }
} }
func inferExplicitManagementAction(text string, domain string) string {
return ""
}
func extractURL(text string) string { func extractURL(text string) string {
return strings.TrimSpace(urlPattern.FindString(text)) return strings.TrimSpace(urlPattern.FindString(text))
} }
@@ -209,20 +189,6 @@ func hasStrictOptionMention(text string, options []traderSkillOption) bool {
return false return false
} }
func shouldUsePatchFallbackForTargetedUpdate(text string, options []traderSkillOption, existing *EntityReference) bool {
if existing != nil && strings.TrimSpace(existing.ID) != "" {
return true
}
if hasStrictOptionMention(text, options) {
return true
}
lower := strings.ToLower(strings.TrimSpace(text))
if containsAny(lower, []string{"这个", "当前", "该", "this", "current"}) {
return true
}
return len(options) == 1
}
func isSimpleEntityMutationAction(action string) bool { func isSimpleEntityMutationAction(action string) bool {
switch strings.TrimSpace(action) { switch strings.TrimSpace(action) {
case "update", "update_name", "update_status", "update_endpoint", "update_bindings", case "update", "update_name", "update_status", "update_endpoint", "update_bindings",
@@ -298,82 +264,6 @@ func (a *Agent) buildSimpleEntityConversationResources(storeUserID string, sessi
return resources return resources
} }
func applySkillConversationResultToSession(session *skillSession, result skillConversationResult) {
if session == nil {
return
}
ensureSkillFields(session)
mergeFieldSet := func(values map[string]string, source string) {
for key, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
switch key {
case "target_ref_id":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.ID = value
if source != "" {
session.TargetRef.Source = source
}
case "target_ref_name":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.Name = value
if source != "" {
session.TargetRef.Source = source
}
default:
setField(session, key, value)
}
}
}
mergeFieldSet(result.Extracted, "llm_conversation")
mergeFieldSet(result.DraftGeneratedFields, "llm_generated_draft")
if result.RequiresConfirmationBeforeApply {
setField(session, "_requires_generated_confirmation", "true")
} else if fieldValue(*session, "_requires_generated_confirmation") != "" {
delete(session.Fields, "_requires_generated_confirmation")
}
if session.TargetRef != nil && session.TargetRef.Source == "" {
session.TargetRef.Source = "llm_conversation"
}
}
func shouldUseLLMConversationForSimpleEntity(skillName, action string) bool {
switch skillName {
case "trader_management":
switch action {
case "update", "update_name", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return true
}
case "exchange_management":
switch action {
case "update", "update_name", "update_status":
return true
}
case "model_management":
switch action {
case "update", "update_name", "update_status", "update_endpoint":
return true
}
case "strategy_management":
switch action {
case "update", "update_name", "update_prompt", "update_config", "activate", "duplicate":
return true
}
}
switch action {
case "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return true
default:
return false
}
}
func (a *Agent) inferredCurrentReferenceForSkill(userID int64, skillName string) *EntityReference { func (a *Agent) inferredCurrentReferenceForSkill(userID int64, skillName string) *EntityReference {
refs := a.semanticCurrentReferences(userID) refs := a.semanticCurrentReferences(userID)
if refs == nil { if refs == nil {
@@ -475,6 +365,7 @@ func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64,
} }
const strategyCreateDraftConfigField = "strategy_create_draft_config" const strategyCreateDraftConfigField = "strategy_create_draft_config"
const strategyCreateConfigPatchField = "config_patch"
func applyStrategyCreateIntentToConfig(cfg *store.StrategyConfig, text, lang string) []string { func applyStrategyCreateIntentToConfig(cfg *store.StrategyConfig, text, lang string) []string {
return nil return nil
@@ -499,6 +390,25 @@ func unmarshalStrategyCreateDraft(raw, lang string) store.StrategyConfig {
return cfg return cfg
} }
func strategyCreateConfigFromSession(session skillSession, lang string) (store.StrategyConfig, map[string]any, []string, error) {
cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang)
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
var patch map[string]any
if patchRaw != "" {
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
return cfg, nil, nil, fmt.Errorf("策略配置 patch 不是合法 JSON%w", err)
}
merged, err := store.MergeStrategyConfig(cfg, patch)
if err != nil {
return cfg, nil, nil, fmt.Errorf("策略配置 patch 无法应用:%w", err)
}
cfg = merged
}
beforeClamp := cfg
cfg.ClampLimits()
return cfg, patch, store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language), nil
}
func strategyCreateConfirmationReply(text string) bool { func strategyCreateConfirmationReply(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text)) lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" { if lower == "" {
@@ -1721,8 +1631,25 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
} }
return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'." return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'."
} }
_, patch, warnings, cfgErr := strategyCreateConfigFromSession(session, lang)
if cfgErr != nil {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建策略失败:" + cfgErr.Error()
}
return "That strategy config could not be prepared: " + cfgErr.Error()
}
setSkillDAGStep(&session, "execute_create") setSkillDAGStep(&session, "execute_create")
args := map[string]any{"action": "create", "name": name, "lang": "zh"} args := map[string]any{
"action": "create",
"name": name,
"lang": defaultIfEmpty(lang, "zh"),
"allow_clamped_update": true,
}
if len(patch) > 0 {
args["config"] = patch
}
raw, _ := json.Marshal(args) raw, _ := json.Marshal(args)
resp := a.toolManageStrategy(storeUserID, string(raw)) resp := a.toolManageStrategy(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) { if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
@@ -1735,9 +1662,17 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
a.clearSkillSession(userID) a.clearSkillSession(userID)
a.rememberReferencesFromToolResult(userID, "manage_strategy", resp) a.rememberReferencesFromToolResult(userID, "manage_strategy", resp)
if lang == "zh" { if lang == "zh" {
return fmt.Sprintf("已创建策略“%s”。默认配置已就绪,你后续可以继续让我帮你改细节。", name) reply := fmt.Sprintf("已创建策略“%s”,并已按你的需求生成配置。", name)
if len(warnings) > 0 {
reply += "\n有些值超出安全范围系统已自动收敛\n- " + strings.Join(warnings, "\n- ")
}
return reply
} }
return fmt.Sprintf("Created strategy %q with the default configuration.", name) reply := fmt.Sprintf("Created strategy %q with a config generated from your requirements.", name)
if len(warnings) > 0 {
reply += "\nSome values were clamped to product safety limits:\n- " + strings.Join(warnings, "\n- ")
}
return reply
} }
func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) { func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) {

View File

@@ -46,9 +46,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
return action return action
case "query_binding": case "query_binding":
return "query_detail" return "query_detail"
case "update": case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return "update_bindings"
case "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return action return action
} }
case "exchange_management": case "exchange_management":
@@ -57,9 +55,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
return "query_list" return "query_list"
case "query_detail": case "query_detail":
return "query_detail" return "query_detail"
case "update": case "update", "update_name", "update_status":
return "update_name"
case "update_name", "update_status":
return action return action
} }
case "model_management": case "model_management":
@@ -68,9 +64,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
return "query_list" return "query_list"
case "query_detail": case "query_detail":
return "query_detail" return "query_detail"
case "update": case "update", "update_name", "update_endpoint", "update_status":
return "update_name"
case "update_name", "update_endpoint", "update_status":
return action return action
} }
case "strategy_management": case "strategy_management":
@@ -79,9 +73,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
return "query_list" return "query_list"
case "query_detail": case "query_detail":
return "query_detail" return "query_detail"
case "update": case "update", "update_name", "update_config", "update_prompt":
return "update_name"
case "update_name", "update_config", "update_prompt":
return action return action
} }
} }

View File

@@ -371,12 +371,116 @@ func TestBuildTraderCreateMissingPromptListsAllMissingSlots(t *testing.T) {
} }
} }
func TestPlannerContextModeFollowsRouterContextSwitch(t *testing.T) { func TestTraderCreateRequiresResolvedResourceIDs(t *testing.T) {
if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{ContextSwitch: true}); got != "fresh_context" { session := skillSession{
t.Fatalf("expected fresh context mode, got %q", got) Name: "trader_management",
Action: "create",
Fields: map[string]string{
"name": "凯茵",
"exchange_name": "Binance",
"model_name": "deepseek",
"strategy_name": "BTC趋势做空",
},
} }
if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{}); got != "" {
t.Fatalf("expected default context mode, got %q", got) missing := missingFieldKeysForSkillSession(session)
for _, want := range []string{"exchange_name", "model_name", "strategy_name"} {
if !containsString(missing, want) {
t.Fatalf("expected unresolved %s to remain missing, got %v", want, missing)
}
}
active := ActiveSkillSession{
SkillName: "trader_management",
ActionName: "create",
CollectedFields: map[string]any{
"name": "凯茵",
"exchange_name": "Binance",
"model_name": "deepseek",
"strategy_name": "BTC趋势做空",
},
}
activeMissing := missingRequiredFields(active)
for _, want := range []string{"exchange", "model", "strategy"} {
if !containsString(activeMissing, want) {
t.Fatalf("expected unresolved active slot %s to remain missing, got %v", want, activeMissing)
}
}
}
func TestStrategyCreateUsesConfigPatch(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-create-config-patch.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
patch := map[string]any{
"coin_source": map[string]any{
"source_type": "static",
"static_coins": []any{"BTCUSDT"},
"use_ai500": false,
"use_oi_low": true,
"oi_low_limit": 1,
},
"risk_control": map[string]any{
"max_positions": 1,
"min_confidence": 80,
},
"prompt_sections": map[string]any{
"entry_standards": "只在 BTC 下跌趋势确认时考虑做空,禁止把做多作为主方向。",
},
"custom_prompt": "BTC 趋势做空策略:仅关注 BTCUSDT趋势向下且反弹受阻时才考虑开空。",
}
rawPatch, _ := json.Marshal(patch)
session := skillSession{
Name: "strategy_management",
Action: "create",
Fields: map[string]string{
"name": "BTC趋势做空",
strategyCreateConfigPatchField: string(rawPatch),
},
}
reply := a.handleStrategyCreateSkill("default", 1, "zh", "BTC趋势做空", session)
if !strings.Contains(reply, "已创建策略") {
t.Fatalf("expected created reply, got: %s", reply)
}
strategies, err := st.Strategy().List("default")
if err != nil {
t.Fatalf("list strategies: %v", err)
}
var created *store.Strategy
for _, strategy := range strategies {
if strategy.Name == "BTC趋势做空" {
created = strategy
break
}
}
if created == nil {
t.Fatalf("expected strategy to be created")
}
var cfg store.StrategyConfig
if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
if cfg.CoinSource.SourceType != "static" || len(cfg.CoinSource.StaticCoins) != 1 || cfg.CoinSource.StaticCoins[0] != "BTCUSDT" {
t.Fatalf("expected BTC static coin source, got %+v", cfg.CoinSource)
}
if cfg.CoinSource.UseAI500 {
t.Fatalf("expected AI500 disabled for explicit BTC strategy")
}
if !cfg.CoinSource.UseOILow {
t.Fatalf("expected OI low enabled for short-biased strategy")
}
if cfg.RiskControl.MaxPositions != 1 || cfg.RiskControl.MinConfidence != 80 {
t.Fatalf("expected risk patch to apply, got %+v", cfg.RiskControl)
}
if !strings.Contains(cfg.CustomPrompt, "BTC 趋势做空") || !strings.Contains(cfg.PromptSections.EntryStandards, "做空") {
t.Fatalf("expected prompt patch to apply, got custom=%q entry=%q", cfg.CustomPrompt, cfg.PromptSections.EntryStandards)
} }
} }

View File

@@ -175,37 +175,6 @@ func supportedWorkflowSkill(skill, action string) bool {
return false return false
} }
func (a *Agent) tryWorkflowIntent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if session := a.getWorkflowSession(userID); hasActiveWorkflowSession(session) {
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
decomposition, err := a.decomposeWorkflowIntent(ctx, userID, lang, text)
if err != nil || len(decomposition.Tasks) <= 1 {
return "", false, err
}
session := WorkflowSession{
UserID: userID,
OriginalRequest: text,
Tasks: decomposition.Tasks,
}
a.saveWorkflowSession(userID, session)
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
func (a *Agent) executeWorkflowDecomposition(ctx context.Context, storeUserID string, userID int64, lang, text string, decomposition workflowDecomposition, onEvent func(event, data string)) (string, bool, error) {
if len(decomposition.Tasks) <= 1 {
return "", false, nil
}
session := WorkflowSession{
UserID: userID,
OriginalRequest: text,
Tasks: decomposition.Tasks,
}
a.saveWorkflowSession(userID, session)
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) { func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
if isExplicitFlowAbort(text) { if isExplicitFlowAbort(text) {
a.clearSkillSession(userID) a.clearSkillSession(userID)