mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Simplify agent skill routing and config updates
This commit is contained in:
@@ -10,16 +10,16 @@ import (
|
||||
// ActiveSkillSession is the minimal session for the central brain architecture.
|
||||
// It replaces the old skillSession + ExecutionState combo for management skill flows.
|
||||
type ActiveSkillSession struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
SkillName string `json:"skill_name"`
|
||||
ActionName string `json:"action_name"`
|
||||
LegacyPhase string `json:"legacy_phase,omitempty"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
PendingHint *PendingHint `json:"pending_hint,omitempty"`
|
||||
CollectedFields map[string]any `json:"collected_fields,omitempty"`
|
||||
LocalHistory []chatMessage `json:"local_history,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
SessionID string `json:"session_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
SkillName string `json:"skill_name"`
|
||||
ActionName string `json:"action_name"`
|
||||
LegacyPhase string `json:"legacy_phase,omitempty"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
PendingHint *PendingHint `json:"pending_hint,omitempty"`
|
||||
CollectedFields map[string]any `json:"collected_fields,omitempty"`
|
||||
LocalHistory []chatMessage `json:"local_history,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PendingHint struct {
|
||||
@@ -207,6 +207,19 @@ func activeSessionHasField(s ActiveSkillSession, slot string) bool {
|
||||
}
|
||||
}
|
||||
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:
|
||||
value, ok := s.CollectedFields[slot]
|
||||
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
|
||||
|
||||
@@ -92,6 +92,7 @@ Rules:
|
||||
- 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"}.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
@@ -531,7 +534,7 @@ func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
|
||||
Fields: make(map[string]string),
|
||||
}
|
||||
for k, v := range s.CollectedFields {
|
||||
str := strings.TrimSpace(fmt.Sprint(v))
|
||||
str := activeFieldString(v)
|
||||
if str == "" || str == "<nil>" {
|
||||
continue
|
||||
}
|
||||
@@ -559,6 +562,23 @@ func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
|
||||
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 {
|
||||
next := base
|
||||
next.LegacyPhase = strings.TrimSpace(legacy.Phase)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
type llmFlowExtractionTask struct {
|
||||
@@ -65,77 +62,6 @@ Rules:
|
||||
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 {
|
||||
out, ok := parseRawFlowExtractionEnvelope(raw)
|
||||
if !ok {
|
||||
@@ -240,6 +166,23 @@ func filterLLMFlowExtractionFields(result llmFlowExtractionResult, specs []llmFl
|
||||
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) {
|
||||
currentStep, _ := currentSkillDAGStep(session)
|
||||
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, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false)
|
||||
case "strategy_management":
|
||||
if session.Action == "create" || session.Action == "update_config" {
|
||||
add(&out, "config_patch", "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use this for strategy requirements such as target coins, trend style, short/long bias, indicators, risk, timeframes, and prompt sections.", false)
|
||||
}
|
||||
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" {
|
||||
add(&out, "config_field", strategyConfigFieldDisplayName("config_field", 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" {
|
||||
switch session.Action {
|
||||
case "configure_strategy":
|
||||
if fieldValue(session, "strategy_id") == "" && fieldValue(session, "strategy_name") == "" {
|
||||
if fieldValue(session, "strategy_id") == "" {
|
||||
missing = append(missing, "strategy_name")
|
||||
}
|
||||
break
|
||||
case "configure_exchange":
|
||||
if fieldValue(session, "exchange_id") == "" && fieldValue(session, "exchange_name") == "" {
|
||||
if fieldValue(session, "exchange_id") == "" {
|
||||
missing = append(missing, "exchange_name")
|
||||
}
|
||||
break
|
||||
case "configure_model":
|
||||
if fieldValue(session, "model_id") == "" && fieldValue(session, "model_name") == "" {
|
||||
if fieldValue(session, "model_id") == "" {
|
||||
missing = append(missing, "model_name")
|
||||
}
|
||||
break
|
||||
@@ -434,13 +384,13 @@ func missingFieldKeysForSkillSession(session skillSession) []string {
|
||||
if fieldValue(session, "name") == "" {
|
||||
missing = append(missing, "name")
|
||||
}
|
||||
if fieldValue(session, "exchange_id") == "" && fieldValue(session, "exchange_name") == "" {
|
||||
if fieldValue(session, "exchange_id") == "" {
|
||||
missing = append(missing, "exchange_name")
|
||||
}
|
||||
if fieldValue(session, "model_id") == "" && fieldValue(session, "model_name") == "" {
|
||||
if fieldValue(session, "model_id") == "" {
|
||||
missing = append(missing, "model_name")
|
||||
}
|
||||
if fieldValue(session, "strategy_id") == "" && fieldValue(session, "strategy_name") == "" {
|
||||
if fieldValue(session, "strategy_id") == "" {
|
||||
missing = append(missing, "strategy_name")
|
||||
}
|
||||
}
|
||||
@@ -449,16 +399,29 @@ func missingFieldKeysForSkillSession(session skillSession) []string {
|
||||
missing = append(missing, "target_ref")
|
||||
}
|
||||
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":
|
||||
if fieldValue(session, "config_patch") != "" {
|
||||
break
|
||||
}
|
||||
if fieldValue(session, "config_field") == "" {
|
||||
missing = append(missing, "config_field")
|
||||
} else if fieldValue(session, "config_value") == "" {
|
||||
missing = append(missing, "config_value")
|
||||
}
|
||||
default:
|
||||
case "create":
|
||||
if fieldValue(session, "name") == "" {
|
||||
missing = append(missing, "name")
|
||||
}
|
||||
default:
|
||||
missing = append(missing, "update_field")
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -10,20 +10,10 @@ import (
|
||||
)
|
||||
|
||||
type llmSkillRouteDecision struct {
|
||||
Intent string `json:"intent,omitempty"`
|
||||
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
|
||||
TargetSkill string `json:"target_skill,omitempty"`
|
||||
ExtractedFields map[string]any `json:"extracted_fields,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"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
TargetSnapshotID string `json:"target_snapshot_id,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) {
|
||||
@@ -46,29 +36,19 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
|
||||
if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) {
|
||||
return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
if _, has := a.getActiveSkillSession(userID); has {
|
||||
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
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
case "cancel":
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
a.clearSkillSession(userID)
|
||||
a.clearWorkflowSession(userID)
|
||||
a.clearExecutionState(userID)
|
||||
a.clearActiveSkillSession(userID)
|
||||
a.clearAnyActiveContext(userID)
|
||||
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
case "resume_snapshot":
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) {
|
||||
if _, has := a.getActiveSkillSession(userID); has {
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
return "", false, nil
|
||||
case "instant_reply":
|
||||
@@ -82,126 +62,14 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
|
||||
return answer, true, err
|
||||
}
|
||||
|
||||
interruptedActiveContext := false
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
a.clearPendingProposalSession(userID)
|
||||
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)
|
||||
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 a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
|
||||
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
|
||||
@@ -228,73 +96,12 @@ func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
|
||||
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
|
||||
decision.Intent = strings.TrimSpace(strings.ToLower(decision.Intent))
|
||||
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 {
|
||||
decision.Confidence = 0
|
||||
}
|
||||
if 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
|
||||
}
|
||||
|
||||
@@ -331,7 +138,6 @@ func (a *Agent) buildTopLevelRouterPrompt(userID int64, lang, text string) (stri
|
||||
snapshotJSON, _ := json.Marshal(snapshots)
|
||||
|
||||
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
|
||||
managementSummary := buildManagementSkillRoutingContextWithSession(lang, &activeSkill)
|
||||
recentConversation := a.buildRecentConversationContext(userID, text)
|
||||
if strings.TrimSpace(recentConversation) == "" {
|
||||
recentConversation = "(empty)"
|
||||
@@ -342,11 +148,11 @@ func (a *Agent) buildTopLevelRouterPrompt(userID int64, lang, text string) (stri
|
||||
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.
|
||||
|
||||
You are deciding what the current user turn should do at the top level.
|
||||
You must classify every message into exactly one of these intents before any execution layer takes over.
|
||||
Your only job is to decide whether the current user turn continues the current topic/state, starts a new topic, resumes a suspended topic, cancels the current topic, or is a direct conversational reply.
|
||||
Do not perform business intent recognition. Do not choose skills, actions, tasks, or fields. The central brain will do that after you return.
|
||||
|
||||
Valid intents:
|
||||
- "continue_active": the user is still working on the current active flow
|
||||
@@ -355,11 +161,6 @@ Valid intents:
|
||||
- "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
|
||||
|
||||
Valid routes when intent=start_new:
|
||||
- "skill"
|
||||
- "workflow"
|
||||
- "planner"
|
||||
|
||||
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.
|
||||
@@ -367,47 +168,41 @@ Rules:
|
||||
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
|
||||
- If the user explicitly refers to a suspended task like "刚才那个", "恢复刚才那个", choose "resume_snapshot" and fill target_snapshot_id.
|
||||
- If the user is only greeting, thanking, social chatting, or asking a concept question without changing task state, choose "instant_reply".
|
||||
- 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:
|
||||
{"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 {
|
||||
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.
|
||||
|
||||
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:
|
||||
- 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 asks a read-only management query while an active flow is open, output intent "start_new"; the central brain will choose the query tool.
|
||||
- If the user starts a multi-step, multi-domain, batch, or condition-based management request while an active flow is open, output intent "start_new"; the central brain will decompose it.
|
||||
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
|
||||
- Examples of forced switch: "不是交易员,是策略", "不是这个", "换个任务", "I mean the strategy, not the trader".
|
||||
- If the user refers to a suspended task and one snapshot clearly matches, use "resume_snapshot".
|
||||
- If the user cancels the current task, use "cancel".
|
||||
- If the user only greets, thanks, chats, or asks for explanation without changing state, use "instant_reply".
|
||||
- Short greetings or acknowledgements like "你好", "hi", "hello", "谢谢", "收到", "好的" should default to "instant_reply" unless they clearly contain task data.
|
||||
- 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":"","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,
|
||||
text,
|
||||
defaultIfEmpty(previousAssistantReply, "(empty)"),
|
||||
defaultIfEmpty(managementSummary, "(empty)"),
|
||||
defaultIfEmpty(buildManagementDomainPrimer(lang), "(empty)"),
|
||||
currentRefs,
|
||||
activeFlowSummary,
|
||||
defaultIfEmpty(string(snapshotJSON), "[]"),
|
||||
@@ -480,45 +275,6 @@ func countPendingWorkflowTasks(session WorkflowSession) int {
|
||||
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 {
|
||||
if refs == nil {
|
||||
if lang == "zh" {
|
||||
@@ -606,11 +362,18 @@ func hasAnyActiveContext(a *Agent, userID int64) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := a.getActiveSkillSession(userID); ok {
|
||||
return true
|
||||
}
|
||||
return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID))
|
||||
}
|
||||
|
||||
func (a *Agent) clearAnyActiveContext(userID int64) bool {
|
||||
cleared := false
|
||||
if _, ok := a.getActiveSkillSession(userID); ok {
|
||||
a.clearActiveSkillSession(userID)
|
||||
cleared = true
|
||||
}
|
||||
if a.hasActiveSkillSession(userID) {
|
||||
a.clearSkillSession(userID)
|
||||
cleared = true
|
||||
|
||||
@@ -895,6 +895,9 @@ func (a *Agent) hasActiveSkillSession(userID int64) bool {
|
||||
}
|
||||
|
||||
func (a *Agent) hasAnyActiveContext(userID int64) bool {
|
||||
if _, ok := a.getActiveSkillSession(userID); ok {
|
||||
return true
|
||||
}
|
||||
if a.hasActiveSkillSession(userID) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
switch strings.TrimSpace(skillName) {
|
||||
case "trader_management":
|
||||
return hasExplicitCreateIntentForDomain(text, "trader") || detectTraderManagementIntent(text) || hasExplicitDiagnosisIntentForDomain(text, "trader")
|
||||
return hasExplicitCreateIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "trader")
|
||||
case "strategy_management":
|
||||
return detectStrategyManagementIntent(text) || hasExplicitDiagnosisIntentForDomain(text, "strategy")
|
||||
return hasExplicitDiagnosisIntentForDomain(text, "strategy")
|
||||
case "model_management":
|
||||
return detectModelManagementIntent(text) ||
|
||||
hasExplicitDiagnosisIntentForDomain(text, "model")
|
||||
return hasExplicitDiagnosisIntentForDomain(text, "model")
|
||||
case "exchange_management":
|
||||
return detectExchangeManagementIntent(text) ||
|
||||
hasExplicitDiagnosisIntentForDomain(text, "exchange")
|
||||
return hasExplicitDiagnosisIntentForDomain(text, "exchange")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -2474,10 +2478,6 @@ func looksLikeNewTopLevelIntent(text string) bool {
|
||||
return true
|
||||
}
|
||||
if hasExplicitCreateIntentForDomain(text, "trader") ||
|
||||
detectTraderManagementIntent(text) ||
|
||||
detectExchangeManagementIntent(text) ||
|
||||
detectModelManagementIntent(text) ||
|
||||
detectStrategyManagementIntent(text) ||
|
||||
hasExplicitDiagnosisIntentForDomain(text, "trader") ||
|
||||
hasExplicitDiagnosisIntentForDomain(text, "exchange") ||
|
||||
hasExplicitDiagnosisIntentForDomain(text, "model") ||
|
||||
|
||||
@@ -366,50 +366,6 @@ func (a *Agent) tryHardSkill(ctx context.Context, storeUserID string, userID int
|
||||
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") {
|
||||
answer := a.handleModelDiagnosisSkill(storeUserID, lang, text)
|
||||
a.recordSkillInteraction(userID, text, answer)
|
||||
@@ -602,31 +558,6 @@ func renderSkillMissingLabels(lang string, missing []string) []string {
|
||||
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 {
|
||||
missing := missingFieldKeysForSkillSession(session)
|
||||
missingLabels := strings.Join(renderSkillMissingLabels(lang, missing), "、")
|
||||
|
||||
@@ -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 {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
@@ -120,31 +70,6 @@ func detectCatalogField(text string, catalog []entityFieldMeta) string {
|
||||
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 {
|
||||
switch field {
|
||||
case "name":
|
||||
@@ -1180,21 +1105,6 @@ func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) er
|
||||
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 {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
switch {
|
||||
@@ -2503,7 +2413,7 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64
|
||||
return "已删除策略。"
|
||||
}
|
||||
return "Deleted strategy."
|
||||
case "update", "update_name", "update_config", "update_prompt":
|
||||
case "update_name", "update_config", "update_prompt":
|
||||
if session.Action == "update_prompt" {
|
||||
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("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:
|
||||
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()
|
||||
}
|
||||
|
||||
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 generated := fieldValue(session, "custom_prompt"); generated != "" {
|
||||
setField(&session, "config_field", "custom_prompt")
|
||||
|
||||
@@ -13,10 +13,6 @@ import (
|
||||
|
||||
var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`)
|
||||
|
||||
func detectTraderManagementIntent(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func hasExplicitCreateIntentForDomain(text, domain string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" || !hasExplicitManagementDomainCue(text, domain) {
|
||||
@@ -25,18 +21,6 @@ func hasExplicitCreateIntentForDomain(text, domain string) bool {
|
||||
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 {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
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 {
|
||||
return strings.TrimSpace(urlPattern.FindString(text))
|
||||
}
|
||||
@@ -209,20 +189,6 @@ func hasStrictOptionMention(text string, options []traderSkillOption) bool {
|
||||
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 {
|
||||
switch strings.TrimSpace(action) {
|
||||
case "update", "update_name", "update_status", "update_endpoint", "update_bindings",
|
||||
@@ -298,82 +264,6 @@ func (a *Agent) buildSimpleEntityConversationResources(storeUserID string, sessi
|
||||
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 {
|
||||
refs := a.semanticCurrentReferences(userID)
|
||||
if refs == nil {
|
||||
@@ -475,6 +365,7 @@ func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64,
|
||||
}
|
||||
|
||||
const strategyCreateDraftConfigField = "strategy_create_draft_config"
|
||||
const strategyCreateConfigPatchField = "config_patch"
|
||||
|
||||
func applyStrategyCreateIntentToConfig(cfg *store.StrategyConfig, text, lang string) []string {
|
||||
return nil
|
||||
@@ -499,6 +390,25 @@ func unmarshalStrategyCreateDraft(raw, lang string) store.StrategyConfig {
|
||||
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 {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
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'."
|
||||
}
|
||||
_, 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")
|
||||
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)
|
||||
resp := a.toolManageStrategy(storeUserID, string(raw))
|
||||
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.rememberReferencesFromToolResult(userID, "manage_strategy", resp)
|
||||
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) {
|
||||
|
||||
@@ -46,9 +46,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
|
||||
return action
|
||||
case "query_binding":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_bindings"
|
||||
case "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
|
||||
case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
|
||||
return action
|
||||
}
|
||||
case "exchange_management":
|
||||
@@ -57,9 +55,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
|
||||
return "query_list"
|
||||
case "query_detail":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_name"
|
||||
case "update_name", "update_status":
|
||||
case "update", "update_name", "update_status":
|
||||
return action
|
||||
}
|
||||
case "model_management":
|
||||
@@ -68,9 +64,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
|
||||
return "query_list"
|
||||
case "query_detail":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_name"
|
||||
case "update_name", "update_endpoint", "update_status":
|
||||
case "update", "update_name", "update_endpoint", "update_status":
|
||||
return action
|
||||
}
|
||||
case "strategy_management":
|
||||
@@ -79,9 +73,7 @@ func normalizeAtomicSkillAction(skill, action string) string {
|
||||
return "query_list"
|
||||
case "query_detail":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_name"
|
||||
case "update_name", "update_config", "update_prompt":
|
||||
case "update", "update_name", "update_config", "update_prompt":
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,12 +371,116 @@ func TestBuildTraderCreateMissingPromptListsAllMissingSlots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerContextModeFollowsRouterContextSwitch(t *testing.T) {
|
||||
if got := plannerContextModeFromRouteDecision(llmSkillRouteDecision{ContextSwitch: true}); got != "fresh_context" {
|
||||
t.Fatalf("expected fresh context mode, got %q", got)
|
||||
func TestTraderCreateRequiresResolvedResourceIDs(t *testing.T) {
|
||||
session := skillSession{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,37 +175,6 @@ func supportedWorkflowSkill(skill, action string) bool {
|
||||
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) {
|
||||
if isExplicitFlowAbort(text) {
|
||||
a.clearSkillSession(userID)
|
||||
|
||||
Reference in New Issue
Block a user