mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Revert "Revert "Trim agent planning tools and validate strategy patches""
This reverts commit 3619f82796.
This commit is contained in:
@@ -246,6 +246,55 @@ func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolManageStrategyReportsChangedAndRejectedFields(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "strategy-change-summary.db")
|
||||||
|
st, err := store.New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create store: %v", err)
|
||||||
|
}
|
||||||
|
a := New(nil, st, DefaultConfig(), slog.Default())
|
||||||
|
|
||||||
|
resp := a.toolManageStrategy("default", `{"action":"create","name":"高频-短线ETH","config":{"coin_source":{"source_type":"static","static_coins":["ETHUSDT"]},"indicators":{"klines":{"primary_timeframe":"1m","selected_timeframes":["1m","3m"]}},"order_execution_speed":"fast"}}`)
|
||||||
|
if strings.Contains(resp, `"error"`) {
|
||||||
|
t.Fatalf("expected create to succeed with rejected unknown fields, got: %s", resp)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"created_strategy_id"`,
|
||||||
|
`"changed_fields"`,
|
||||||
|
`coin_source.source_type`,
|
||||||
|
`indicators.klines.primary_timeframe`,
|
||||||
|
`"rejected_fields"`,
|
||||||
|
`order_execution_speed (not in current strategy config)`,
|
||||||
|
`"unchanged_defaults"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(resp, want) {
|
||||||
|
t.Fatalf("expected response to contain %q, got: %s", want, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strategies, err := st.Strategy().List("default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list strategies: %v", err)
|
||||||
|
}
|
||||||
|
var created *store.Strategy
|
||||||
|
for _, strategy := range strategies {
|
||||||
|
if strategy.Name == "高频-短线ETH" {
|
||||||
|
created = strategy
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if created == nil {
|
||||||
|
t.Fatalf("expected strategy to be created")
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(created.Config), &cfg); err != nil {
|
||||||
|
t.Fatalf("parse config: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := cfg["order_execution_speed"]; ok {
|
||||||
|
t.Fatalf("unknown field should not be persisted: %s", created.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
|
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
|
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
|
||||||
st, err := store.New(dbPath)
|
st, err := store.New(dbPath)
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
|
|||||||
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" {
|
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)
|
add(&out, "config_patch", strategyConfigPatchFieldDescription(lang), false)
|
||||||
}
|
}
|
||||||
if session.Action == "update_prompt" {
|
if session.Action == "update_prompt" {
|
||||||
add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false)
|
add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false)
|
||||||
@@ -270,6 +270,10 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strategyConfigPatchFieldDescription(lang string) string {
|
||||||
|
return "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."
|
||||||
|
}
|
||||||
|
|
||||||
func currentFieldValuesForSkillSession(session skillSession) map[string]string {
|
func currentFieldValuesForSkillSession(session skillSession) map[string]string {
|
||||||
values := map[string]string{}
|
values := map[string]string{}
|
||||||
for key, value := range session.Fields {
|
for key, value := range session.Fields {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type unifiedTurnDecision struct {
|
|||||||
TopicIntent string `json:"topic_intent,omitempty"`
|
TopicIntent string `json:"topic_intent,omitempty"`
|
||||||
BusinessAction string `json:"business_action,omitempty"`
|
BusinessAction string `json:"business_action,omitempty"`
|
||||||
TargetSkill string `json:"target_skill,omitempty"`
|
TargetSkill string `json:"target_skill,omitempty"`
|
||||||
|
Tasks []WorkflowTask `json:"tasks,omitempty"`
|
||||||
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
|
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
|
||||||
ContextMode string `json:"context_mode,omitempty"`
|
ContextMode string `json:"context_mode,omitempty"`
|
||||||
ExtractedData map[string]any `json:"extracted_data,omitempty"`
|
ExtractedData map[string]any `json:"extracted_data,omitempty"`
|
||||||
@@ -117,6 +118,7 @@ func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecis
|
|||||||
decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID)
|
decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID)
|
||||||
decision.ContextMode = strings.TrimSpace(strings.ToLower(decision.ContextMode))
|
decision.ContextMode = strings.TrimSpace(strings.ToLower(decision.ContextMode))
|
||||||
decision.ReplyToUser = strings.TrimSpace(decision.ReplyToUser)
|
decision.ReplyToUser = strings.TrimSpace(decision.ReplyToUser)
|
||||||
|
decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
|
||||||
if decision.ExtractedData == nil {
|
if decision.ExtractedData == nil {
|
||||||
decision.ExtractedData = map[string]any{}
|
decision.ExtractedData = map[string]any{}
|
||||||
}
|
}
|
||||||
@@ -134,7 +136,7 @@ func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecis
|
|||||||
decision.TopicIntent = ""
|
decision.TopicIntent = ""
|
||||||
}
|
}
|
||||||
switch decision.BusinessAction {
|
switch decision.BusinessAction {
|
||||||
case "direct_answer", "new_skill", "continue_skill", "planned_agent", "none":
|
case "direct_answer", "new_skill", "skill_tasks", "continue_skill", "planned_agent", "none":
|
||||||
default:
|
default:
|
||||||
decision.BusinessAction = ""
|
decision.BusinessAction = ""
|
||||||
}
|
}
|
||||||
@@ -157,8 +159,13 @@ func (d unifiedTurnDecision) reliable() bool {
|
|||||||
case "direct_answer":
|
case "direct_answer":
|
||||||
return strings.TrimSpace(d.ReplyToUser) != ""
|
return strings.TrimSpace(d.ReplyToUser) != ""
|
||||||
case "new_skill":
|
case "new_skill":
|
||||||
|
if len(d.Tasks) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
skill, _ := parseTargetSkill(d.TargetSkill)
|
skill, _ := parseTargetSkill(d.TargetSkill)
|
||||||
return skill != ""
|
return skill != ""
|
||||||
|
case "skill_tasks":
|
||||||
|
return len(d.Tasks) > 0
|
||||||
case "continue_skill":
|
case "continue_skill":
|
||||||
return d.TopicIntent == "continue_active"
|
return d.TopicIntent == "continue_active"
|
||||||
case "planned_agent", "none":
|
case "planned_agent", "none":
|
||||||
@@ -234,12 +241,20 @@ topic_intent values:
|
|||||||
|
|
||||||
business_action values:
|
business_action values:
|
||||||
- "direct_answer": reply_to_user is the final answer; do not change state
|
- "direct_answer": reply_to_user is the final answer; do not change state
|
||||||
- "new_skill": start a management/diagnosis skill; target_skill is required
|
- "skill_tasks": start one or more management/diagnosis skill tasks; tasks is required
|
||||||
|
- "new_skill": legacy single-skill route; target_skill is required if tasks is empty
|
||||||
- "continue_skill": continue the active skill session
|
- "continue_skill": continue the active skill session
|
||||||
- "planned_agent": hand off to the execution planner/tools
|
- "planned_agent": hand off to the execution planner/tools
|
||||||
- "none": only valid with cancel when no more action is needed
|
- "none": only valid with cancel when no more action is needed
|
||||||
|
|
||||||
target_skill format for new_skill:
|
tasks format for skill_tasks:
|
||||||
|
- id: "task_1", "task_2", ...
|
||||||
|
- skill: one available skill name
|
||||||
|
- action: one available action
|
||||||
|
- request: the self-contained user-readable subtask
|
||||||
|
- depends_on: array of task ids, empty when independent
|
||||||
|
|
||||||
|
target_skill format for legacy new_skill:
|
||||||
skill_name:action, for example "trader_management:create".
|
skill_name:action, for example "trader_management:create".
|
||||||
Available skills:
|
Available skills:
|
||||||
trader_management, exchange_management, model_management, strategy_management,
|
trader_management, exchange_management, model_management, strategy_management,
|
||||||
@@ -262,7 +277,9 @@ Rules:
|
|||||||
- If the user answers the previous assistant question, choose continue_active.
|
- If the user answers the previous assistant question, choose continue_active.
|
||||||
- If the user only says "你好", "hi", "谢谢", "收到", choose instant_reply + direct_answer unless it clearly answers a pending task.
|
- If the user only says "你好", "hi", "谢谢", "收到", choose instant_reply + direct_answer unless it clearly answers a pending task.
|
||||||
- If the user asks a read-only management query, prefer planned_agent unless the answer is already fully available in the provided context.
|
- If the user asks a read-only management query, prefer planned_agent unless the answer is already fully available in the provided context.
|
||||||
- Use new_skill for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy.
|
- Use skill_tasks for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy.
|
||||||
|
- If the user request contains multiple management operations, include multiple tasks and depends_on where a later task needs an earlier result.
|
||||||
|
- If the request contains exactly one management operation, include exactly one task.
|
||||||
- Use planned_agent for multi-step, tool-heavy, market/account, diagnosis, or ambiguous tasks.
|
- Use planned_agent for multi-step, tool-heavy, market/account, diagnosis, or ambiguous tasks.
|
||||||
- For model_management, "provider" means AI vendor, never an exchange.
|
- For model_management, "provider" means AI vendor, never an exchange.
|
||||||
- Current references are context only. Do not copy them into extracted_data unless the user explicitly says this/current/that previous one.
|
- Current references are context only. Do not copy them into extracted_data unless the user explicitly says this/current/that previous one.
|
||||||
@@ -271,7 +288,7 @@ Rules:
|
|||||||
- confidence should reflect how safe it is to execute this decision without the old router fallback.
|
- confidence should reflect how safe it is to execute this decision without the old router fallback.
|
||||||
|
|
||||||
Return JSON with this exact shape:
|
Return JSON with this exact shape:
|
||||||
{"topic_intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","business_action":"direct_answer|new_skill|continue_skill|planned_agent|none","target_skill":"","target_snapshot_id":"","context_mode":"use_current|fresh_context|resume_snapshot","extracted_data":{},"reply_to_user":"","confidence":0.0}`)
|
{"topic_intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","business_action":"direct_answer|skill_tasks|new_skill|continue_skill|planned_agent|none","target_skill":"","tasks":[{"id":"task_1","skill":"","action":"","request":"","depends_on":[]}],"target_snapshot_id":"","context_mode":"use_current|fresh_context|resume_snapshot","extracted_data":{},"reply_to_user":"","confidence":0.0}`)
|
||||||
|
|
||||||
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n\nManagement domain primer:\n%s\n\nActive task details:\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\nManagement domain primer:\n%s\n\nActive task details:\n%s\n",
|
||||||
lang,
|
lang,
|
||||||
@@ -334,6 +351,9 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri
|
|||||||
a.runPostResponseMaintenanceAsync(userID)
|
a.runPostResponseMaintenanceAsync(userID)
|
||||||
return decision.ReplyToUser, true, nil
|
return decision.ReplyToUser, true, nil
|
||||||
case "new_skill":
|
case "new_skill":
|
||||||
|
if len(decision.Tasks) > 0 {
|
||||||
|
return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent)
|
||||||
|
}
|
||||||
skill, action := parseTargetSkill(decision.TargetSkill)
|
skill, action := parseTargetSkill(decision.TargetSkill)
|
||||||
if skill == "" {
|
if skill == "" {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
@@ -351,6 +371,8 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri
|
|||||||
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
|
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
|
||||||
mergeExtractedData(&session, decision.ExtractedData)
|
mergeExtractedData(&session, decision.ExtractedData)
|
||||||
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
|
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
|
||||||
|
case "skill_tasks":
|
||||||
|
return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent)
|
||||||
case "continue_skill":
|
case "continue_skill":
|
||||||
activeSession, hasActive := a.getActiveSkillSession(userID)
|
activeSession, hasActive := a.getActiveSkillSession(userID)
|
||||||
if !hasActive {
|
if !hasActive {
|
||||||
@@ -373,6 +395,39 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) {
|
||||||
|
tasks := normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" {
|
||||||
|
if !a.suspendActiveContexts(userID, lang) {
|
||||||
|
a.clearSkillSession(userID)
|
||||||
|
a.clearWorkflowSession(userID)
|
||||||
|
a.clearExecutionState(userID)
|
||||||
|
}
|
||||||
|
a.clearActiveSkillSession(userID)
|
||||||
|
}
|
||||||
|
if len(tasks) == 1 {
|
||||||
|
task := tasks[0]
|
||||||
|
session := newActiveSkillSession(userID, task.Skill, task.Action)
|
||||||
|
session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text))
|
||||||
|
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
|
||||||
|
mergeExtractedData(&session, decision.ExtractedData)
|
||||||
|
return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent)
|
||||||
|
}
|
||||||
|
session := normalizeWorkflowSession(WorkflowSession{
|
||||||
|
UserID: userID,
|
||||||
|
OriginalRequest: strings.TrimSpace(text),
|
||||||
|
Tasks: tasks,
|
||||||
|
})
|
||||||
|
if len(session.Tasks) == 0 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
a.saveWorkflowSession(userID, session)
|
||||||
|
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
|
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
raw = strings.TrimPrefix(raw, "```json")
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
|||||||
@@ -2823,7 +2823,7 @@ type nextStepDecision struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) decideNextStep(ctx context.Context, userID int64, lang string, state ExecutionState) (nextStepDecision, error) {
|
func (a *Agent) decideNextStep(ctx context.Context, userID int64, lang string, state ExecutionState) (nextStepDecision, error) {
|
||||||
toolDefs, _ := json.Marshal(agentTools())
|
toolDefs, _ := json.Marshal(plannerToolsForText(state.Goal))
|
||||||
obsJSON, _ := json.Marshal(buildObservationContext(state))
|
obsJSON, _ := json.Marshal(buildObservationContext(state))
|
||||||
recentlyFetchedJSON, _ := json.Marshal(buildRecentlyFetchedData(state, time.Now().UTC()))
|
recentlyFetchedJSON, _ := json.Marshal(buildRecentlyFetchedData(state, time.Now().UTC()))
|
||||||
currentTurnCtx := a.buildCurrentTurnContext(userID, lang, state.Goal)
|
currentTurnCtx := a.buildCurrentTurnContext(userID, lang, state.Goal)
|
||||||
@@ -3010,7 +3010,7 @@ func (a *Agent) buildRecentConversationContext(userID int64, currentUserText str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) createExecutionPlan(ctx context.Context, userID int64, lang, userText string, state ExecutionState) (executionPlan, error) {
|
func (a *Agent) createExecutionPlan(ctx context.Context, userID int64, lang, userText string, state ExecutionState) (executionPlan, error) {
|
||||||
toolDefs, _ := json.Marshal(agentTools())
|
toolDefs, _ := json.Marshal(plannerToolsForText(userText))
|
||||||
currentTurnCtx := a.buildCurrentTurnContext(userID, lang, userText)
|
currentTurnCtx := a.buildCurrentTurnContext(userID, lang, userText)
|
||||||
activeTaskCtx := a.buildActiveTaskStateContext(userID, lang)
|
activeTaskCtx := a.buildActiveTaskStateContext(userID, lang)
|
||||||
currentReferenceSummary := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
|
currentReferenceSummary := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
|
||||||
|
|||||||
84
agent/planner_tools_test.go
Normal file
84
agent/planner_tools_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nofx/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlannerToolsForMarketIntentAreTrimmed(t *testing.T) {
|
||||||
|
tools := plannerToolsForText("看一下 BTCUSDT 行情和 K线")
|
||||||
|
names := toolNamesForTest(tools)
|
||||||
|
|
||||||
|
for _, expected := range []string{"get_market_snapshot", "get_market_price", "get_kline"} {
|
||||||
|
if !containsString(names, expected) {
|
||||||
|
t.Fatalf("expected market tool %q in %v", expected, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unexpected := range []string{"manage_strategy", "manage_trader", "manage_exchange_config", "manage_model_config"} {
|
||||||
|
if containsString(names, unexpected) {
|
||||||
|
t.Fatalf("did not expect management tool %q in market tools %v", unexpected, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerToolsForExchangeIntentAreTrimmed(t *testing.T) {
|
||||||
|
tools := plannerToolsForText("帮我添加 okx 交易所 API key")
|
||||||
|
names := toolNamesForTest(tools)
|
||||||
|
|
||||||
|
if len(names) != 2 {
|
||||||
|
t.Fatalf("expected two exchange tools, got %v", names)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"get_exchange_configs", "manage_exchange_config"} {
|
||||||
|
if !containsString(names, expected) {
|
||||||
|
t.Fatalf("expected exchange tool %q in %v", expected, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerToolsUseCompactManageStrategyForReadIntent(t *testing.T) {
|
||||||
|
tools := plannerToolsForText("列出我的策略")
|
||||||
|
tool := findToolForTest(tools, "manage_strategy")
|
||||||
|
if tool == nil {
|
||||||
|
t.Fatalf("expected manage_strategy in strategy tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(tool.Function.Parameters)
|
||||||
|
if len(raw) > 900 {
|
||||||
|
t.Fatalf("expected compact strategy schema, got %d bytes", len(raw))
|
||||||
|
}
|
||||||
|
if string(raw) == "" || !json.Valid(raw) {
|
||||||
|
t.Fatalf("expected valid strategy schema JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannerToolsKeepFullManageStrategyForMutationIntent(t *testing.T) {
|
||||||
|
tools := plannerToolsForText("创建一个 BTC 网格策略")
|
||||||
|
tool := findToolForTest(tools, "manage_strategy")
|
||||||
|
if tool == nil {
|
||||||
|
t.Fatalf("expected manage_strategy in strategy tools")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(tool.Function.Parameters)
|
||||||
|
if len(raw) < 1500 {
|
||||||
|
t.Fatalf("expected full strategy schema for mutation intent, got %d bytes", len(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolNamesForTest(tools []mcp.Tool) []string {
|
||||||
|
names := make([]string, 0, len(tools))
|
||||||
|
for _, tool := range tools {
|
||||||
|
names = append(names, tool.Function.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func findToolForTest(tools []mcp.Tool, name string) *mcp.Tool {
|
||||||
|
for i := range tools {
|
||||||
|
if tools[i].Function.Name == name {
|
||||||
|
return &tools[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -2836,12 +2836,52 @@ func (a *Agent) persistStrategyConfigUpdate(storeUserID string, userID int64, la
|
|||||||
enMsg += "\n\nAdjusted to stay within safe limits:\n- " + strings.Join(warnings, "\n- ")
|
enMsg += "\n\nAdjusted to stay within safe limits:\n- " + strings.Join(warnings, "\n- ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if summary := parseStrategyToolChangeSummary(resp); len(summary.ChangedFields) > 0 || len(summary.RejectedFields) > 0 || len(summary.UnchangedDefaults) > 0 {
|
||||||
|
if lang == "zh" {
|
||||||
|
if len(summary.ChangedFields) > 0 {
|
||||||
|
zhMsg += "\n- 实际写入配置:" + strings.Join(summary.ChangedFields, "、")
|
||||||
|
}
|
||||||
|
if len(summary.RejectedFields) > 0 {
|
||||||
|
zhMsg += "\n- 未写入字段:" + strings.Join(summary.RejectedFields, "、")
|
||||||
|
}
|
||||||
|
if len(summary.UnchangedDefaults) > 0 {
|
||||||
|
zhMsg += "\n- 仍使用默认值:" + strings.Join(summary.UnchangedDefaults, "、")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(summary.ChangedFields) > 0 {
|
||||||
|
enMsg += "\n- Config fields written: " + strings.Join(summary.ChangedFields, ", ")
|
||||||
|
}
|
||||||
|
if len(summary.RejectedFields) > 0 {
|
||||||
|
enMsg += "\n- Rejected fields: " + strings.Join(summary.RejectedFields, ", ")
|
||||||
|
}
|
||||||
|
if len(summary.UnchangedDefaults) > 0 {
|
||||||
|
enMsg += "\n- Defaults still in use: " + strings.Join(summary.UnchangedDefaults, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if lang == "zh" {
|
if lang == "zh" {
|
||||||
return zhMsg
|
return zhMsg
|
||||||
}
|
}
|
||||||
return enMsg
|
return enMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type strategyToolChangeSummary struct {
|
||||||
|
CreatedStrategyID string `json:"created_strategy_id"`
|
||||||
|
StrategyID string `json:"strategy_id"`
|
||||||
|
ChangedFields []string `json:"changed_fields"`
|
||||||
|
UnchangedDefaults []string `json:"unchanged_defaults"`
|
||||||
|
RejectedFields []string `json:"rejected_fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStrategyToolChangeSummary(raw string) strategyToolChangeSummary {
|
||||||
|
var payload strategyToolChangeSummary
|
||||||
|
_ = json.Unmarshal([]byte(raw), &payload)
|
||||||
|
payload.ChangedFields = cleanStringList(payload.ChangedFields)
|
||||||
|
payload.UnchangedDefaults = cleanStringList(payload.UnchangedDefaults)
|
||||||
|
payload.RejectedFields = cleanStringList(payload.RejectedFields)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
func parseToolWarnings(raw string) []string {
|
func parseToolWarnings(raw string) []string {
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ Rules:
|
|||||||
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
|
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
|
||||||
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
|
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
|
||||||
- If you choose "complete", produce the final user-facing answer in the user's language.
|
- If you choose "complete", produce the final user-facing answer in the user's language.
|
||||||
|
- For strategy_management create/update outcomes, only mention config fields present in changed_fields, unchanged_defaults, rejected_fields, warnings, or user_message. Do not add strategy settings that are not in the structured skill outcome.
|
||||||
|
- If a strategy field is not in the current StrategyConfig or appears in rejected_fields, say it was not written / is not in the current strategy config. Do not use trading common sense to invent fields.
|
||||||
- ` + cleanUserFacingReplyInstruction + `
|
- ` + cleanUserFacingReplyInstruction + `
|
||||||
|
|
||||||
Return JSON with this exact shape:
|
Return JSON with this exact shape:
|
||||||
|
|||||||
287
agent/tools.go
287
agent/tools.go
@@ -43,6 +43,243 @@ var (
|
|||||||
// agentTools returns the tools available to the LLM for autonomous action.
|
// agentTools returns the tools available to the LLM for autonomous action.
|
||||||
func agentTools() []mcp.Tool { return cachedTools }
|
func agentTools() []mcp.Tool { return cachedTools }
|
||||||
|
|
||||||
|
func plannerToolsForText(text string) []mcp.Tool {
|
||||||
|
domain := plannerToolDomainForText(text)
|
||||||
|
compactStrategy := !looksLikeStrategyMutationIntent(text)
|
||||||
|
names := plannerToolNamesForDomain(domain)
|
||||||
|
return toolsByName(names, compactStrategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannerToolDomainForText(text string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
if lower == "" {
|
||||||
|
return "general"
|
||||||
|
}
|
||||||
|
if containsAny(lower, []string{"诊断", "排查", "为什么", "为啥", "失败", "报错", "异常", "停止", "没下单", "failed", "error", "diagnose", "debug", "logs", "stopped", "not trading"}) {
|
||||||
|
return "diagnosis"
|
||||||
|
}
|
||||||
|
if hasExplicitManagementDomainCue(text, "exchange") || containsAny(lower, []string{"交易所", "exchange", "apikey", "secret", "passphrase", "wallet address", "api凭证"}) {
|
||||||
|
return "exchange"
|
||||||
|
}
|
||||||
|
if hasExplicitManagementDomainCue(text, "model") || containsAny(lower, []string{"ai model", "模型", "provider", "api key", "custom_model", "custom api"}) {
|
||||||
|
return "model"
|
||||||
|
}
|
||||||
|
if hasExplicitManagementDomainCue(text, "strategy") || containsAny(lower, []string{"策略", "strategy", "选币", "止盈", "止损", "杠杆", "风控", "risk control"}) {
|
||||||
|
return "strategy"
|
||||||
|
}
|
||||||
|
if hasExplicitManagementDomainCue(text, "trader") || containsAny(lower, []string{"交易员", "trader", "启动", "停止交易员", "扫描间隔", "竞技场"}) {
|
||||||
|
return "trader"
|
||||||
|
}
|
||||||
|
if containsAny(lower, []string{"余额", "资产", "仓位", "持仓", "订单", "成交", "交易历史", "balance", "position", "positions", "trade history", "account"}) {
|
||||||
|
return "account"
|
||||||
|
}
|
||||||
|
if containsAny(lower, []string{"行情", "价格", "k线", "kline", "market", "price", "btc", "eth", "sol", "usdt", "股票", "stock"}) {
|
||||||
|
return "market"
|
||||||
|
}
|
||||||
|
return "general"
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannerToolNamesForDomain(domain string) []string {
|
||||||
|
switch domain {
|
||||||
|
case "market":
|
||||||
|
return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock"}
|
||||||
|
case "account":
|
||||||
|
return []string{"get_balance", "get_positions", "get_trade_history"}
|
||||||
|
case "trader":
|
||||||
|
return []string{"get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"}
|
||||||
|
case "model":
|
||||||
|
return []string{"get_model_configs", "manage_model_config"}
|
||||||
|
case "exchange":
|
||||||
|
return []string{"get_exchange_configs", "manage_exchange_config"}
|
||||||
|
case "strategy":
|
||||||
|
return []string{"get_strategies", "manage_strategy"}
|
||||||
|
case "diagnosis":
|
||||||
|
return []string{"get_backend_logs", "get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"}
|
||||||
|
default:
|
||||||
|
return []string{
|
||||||
|
"get_preferences", "manage_preferences",
|
||||||
|
"get_backend_logs",
|
||||||
|
"get_exchange_configs", "manage_exchange_config",
|
||||||
|
"get_model_configs", "manage_model_config",
|
||||||
|
"get_strategies", "manage_strategy",
|
||||||
|
"manage_trader",
|
||||||
|
"get_balance", "get_positions", "get_trade_history",
|
||||||
|
"get_market_snapshot", "get_market_price", "get_kline", "search_stock",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolsByName(names []string, compactStrategy bool) []mcp.Tool {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
byName := make(map[string]mcp.Tool, len(cachedTools))
|
||||||
|
for _, tool := range cachedTools {
|
||||||
|
byName[tool.Function.Name] = tool
|
||||||
|
}
|
||||||
|
out := make([]mcp.Tool, 0, len(names))
|
||||||
|
seen := make(map[string]bool, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
if seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
tool, ok := byName[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if compactStrategy && name == "manage_strategy" {
|
||||||
|
tool = compactManageStrategyTool(tool)
|
||||||
|
}
|
||||||
|
out = append(out, tool)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactManageStrategyTool(tool mcp.Tool) mcp.Tool {
|
||||||
|
tool.Function.Description = "List, query, delete, activate, duplicate, create, or update strategy templates. Planning schema is compact; use action plus strategy_id/name/description/lang/is_public/config_visible, and include config only when the user explicitly provides strategy config fields."
|
||||||
|
tool.Function.Parameters = map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"action": map[string]any{"type": "string", "enum": []string{"list", "create", "update", "delete", "activate", "duplicate", "get_default_config"}},
|
||||||
|
"strategy_id": map[string]any{"type": "string"},
|
||||||
|
"name": map[string]any{"type": "string"},
|
||||||
|
"description": map[string]any{"type": "string"},
|
||||||
|
"lang": map[string]any{"type": "string", "enum": []string{"zh", "en"}},
|
||||||
|
"is_public": map[string]any{"type": "boolean"},
|
||||||
|
"config_visible": map[string]any{"type": "boolean"},
|
||||||
|
"config": map[string]any{"type": "object", "description": "Strategy config patch. Use precise StrategyConfig field paths/objects from the user request; grid risk fields such as max_drawdown_pct, stop_loss_pct, and daily_loss_limit_pct belong under grid_config. Omit when listing/querying/deleting/activating/duplicating."},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
return tool
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeStrategyMutationIntent(text string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
return hasExplicitManagementDomainCue(text, "strategy") &&
|
||||||
|
containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "修改", "更新", "编辑", "调整", "配置", "create", "new", "update", "edit", "configure"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type strategyConfigPatchValidation struct {
|
||||||
|
Config map[string]any
|
||||||
|
ChangedFields []string
|
||||||
|
UnchangedDefaults []string
|
||||||
|
RejectedFields []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStrategyConfigPatch(config map[string]any) strategyConfigPatchValidation {
|
||||||
|
out := strategyConfigPatchValidation{
|
||||||
|
Config: map[string]any{},
|
||||||
|
}
|
||||||
|
if len(config) == 0 {
|
||||||
|
out.UnchangedDefaults = defaultStrategyConfigSections()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
schema := strategyConfigSchema()
|
||||||
|
props, _ := schema["properties"].(map[string]any)
|
||||||
|
for key, value := range config {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prop, ok := props[key]
|
||||||
|
if !ok {
|
||||||
|
out.RejectedFields = append(out.RejectedFields, key+" (not in current strategy config)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned, changed, rejected := sanitizeStrategyConfigValue(key, value, prop)
|
||||||
|
out.RejectedFields = append(out.RejectedFields, rejected...)
|
||||||
|
if len(changed) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Config[key] = cleaned
|
||||||
|
out.ChangedFields = append(out.ChangedFields, changed...)
|
||||||
|
}
|
||||||
|
out.UnchangedDefaults = unchangedStrategyDefaults(out.ChangedFields)
|
||||||
|
sort.Strings(out.ChangedFields)
|
||||||
|
sort.Strings(out.UnchangedDefaults)
|
||||||
|
sort.Strings(out.RejectedFields)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeStrategyConfigValue(path string, value any, schema any) (any, []string, []string) {
|
||||||
|
schemaMap, _ := schema.(map[string]any)
|
||||||
|
if schemaMap == nil {
|
||||||
|
return value, []string{path}, nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(fmt.Sprint(schemaMap["type"])), "object") {
|
||||||
|
props, _ := schemaMap["properties"].(map[string]any)
|
||||||
|
if len(props) == 0 {
|
||||||
|
return value, []string{path}, nil
|
||||||
|
}
|
||||||
|
valueMap, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
if typed, ok := value.(map[string]string); ok {
|
||||||
|
valueMap = make(map[string]any, len(typed))
|
||||||
|
for k, v := range typed {
|
||||||
|
valueMap[k] = v
|
||||||
|
}
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, []string{path + " (expected object)"}
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(valueMap))
|
||||||
|
var changed []string
|
||||||
|
var rejected []string
|
||||||
|
for key, nestedValue := range valueMap {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nestedPath := path + "." + key
|
||||||
|
prop, ok := props[key]
|
||||||
|
if !ok {
|
||||||
|
rejected = append(rejected, nestedPath+" (not in current strategy config)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned, nestedChanged, nestedRejected := sanitizeStrategyConfigValue(nestedPath, nestedValue, prop)
|
||||||
|
rejected = append(rejected, nestedRejected...)
|
||||||
|
if len(nestedChanged) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = cleaned
|
||||||
|
changed = append(changed, nestedChanged...)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil, rejected
|
||||||
|
}
|
||||||
|
return out, changed, rejected
|
||||||
|
}
|
||||||
|
return value, []string{path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultStrategyConfigSections() []string {
|
||||||
|
return []string{"strategy_type", "language", "coin_source", "indicators", "custom_prompt", "risk_control", "prompt_sections", "grid_config"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unchangedStrategyDefaults(changedFields []string) []string {
|
||||||
|
changedTop := make(map[string]bool, len(changedFields))
|
||||||
|
for _, field := range changedFields {
|
||||||
|
top := strings.TrimSpace(field)
|
||||||
|
if idx := strings.Index(top, "."); idx >= 0 {
|
||||||
|
top = top[:idx]
|
||||||
|
}
|
||||||
|
if top != "" {
|
||||||
|
changedTop[top] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(defaultStrategyConfigSections()))
|
||||||
|
for _, section := range defaultStrategyConfigSections() {
|
||||||
|
if !changedTop[section] {
|
||||||
|
out = append(out, section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func normalizedEntityName(value string) string {
|
func normalizedEntityName(value string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(value))
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
@@ -1775,14 +2012,15 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
|||||||
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
|
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
|
||||||
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
|
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
|
||||||
}
|
}
|
||||||
|
validation := validateStrategyConfigPatch(args.Config)
|
||||||
if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil {
|
if err := a.ensureUniqueStrategyName(storeUserID, name, ""); err != nil {
|
||||||
return fmt.Sprintf(`{"error":"%s"}`, err)
|
return fmt.Sprintf(`{"error":"%s"}`, err)
|
||||||
}
|
}
|
||||||
defaultConfig := store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang))
|
defaultConfig := store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang))
|
||||||
var cfg any = defaultConfig
|
var cfg any = defaultConfig
|
||||||
var warnings []string
|
var warnings []string
|
||||||
if len(args.Config) > 0 {
|
if len(validation.Config) > 0 {
|
||||||
merged, err := store.MergeStrategyConfig(defaultConfig, args.Config)
|
merged, err := store.MergeStrategyConfig(defaultConfig, validation.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err)
|
return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err)
|
||||||
}
|
}
|
||||||
@@ -1813,10 +2051,14 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
|||||||
return fmt.Sprintf(`{"error":"failed to create strategy: %s"}`, err)
|
return fmt.Sprintf(`{"error":"failed to create strategy: %s"}`, err)
|
||||||
}
|
}
|
||||||
payload, _ := json.Marshal(map[string]any{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"action": "create",
|
"action": "create",
|
||||||
"strategy": safeStrategyForTool(record),
|
"created_strategy_id": record.ID,
|
||||||
"warnings": warnings,
|
"strategy": safeStrategyForTool(record),
|
||||||
|
"changed_fields": validation.ChangedFields,
|
||||||
|
"unchanged_defaults": validation.UnchangedDefaults,
|
||||||
|
"rejected_fields": validation.RejectedFields,
|
||||||
|
"warnings": warnings,
|
||||||
})
|
})
|
||||||
return string(payload)
|
return string(payload)
|
||||||
case "update":
|
case "update":
|
||||||
@@ -1827,6 +2069,7 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
|||||||
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
|
if lockedField, ok := strategyConfigContainsLockedField(args.Config); ok {
|
||||||
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
|
return fmt.Sprintf(`{"error":"%s"}`, strategyLockedFieldError("zh", lockedField))
|
||||||
}
|
}
|
||||||
|
validation := validateStrategyConfigPatch(args.Config)
|
||||||
existing, err := a.store.Strategy().Get(storeUserID, strategyID)
|
existing, err := a.store.Strategy().Get(storeUserID, strategyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err)
|
return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err)
|
||||||
@@ -1855,16 +2098,29 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
|||||||
if args.ConfigVisible != nil {
|
if args.ConfigVisible != nil {
|
||||||
configVisible = *args.ConfigVisible
|
configVisible = *args.ConfigVisible
|
||||||
}
|
}
|
||||||
|
metadataChanged := make([]string, 0, 4)
|
||||||
|
if !sameEntityName(name, existing.Name) {
|
||||||
|
metadataChanged = append(metadataChanged, "name")
|
||||||
|
}
|
||||||
|
if description != existing.Description {
|
||||||
|
metadataChanged = append(metadataChanged, "description")
|
||||||
|
}
|
||||||
|
if isPublic != existing.IsPublic {
|
||||||
|
metadataChanged = append(metadataChanged, "is_public")
|
||||||
|
}
|
||||||
|
if configVisible != existing.ConfigVisible {
|
||||||
|
metadataChanged = append(metadataChanged, "config_visible")
|
||||||
|
}
|
||||||
configJSON := existing.Config
|
configJSON := existing.Config
|
||||||
var warnings []string
|
var warnings []string
|
||||||
if len(args.Config) > 0 {
|
if len(validation.Config) > 0 {
|
||||||
var existingConfig store.StrategyConfig
|
var existingConfig store.StrategyConfig
|
||||||
if strings.TrimSpace(existing.Config) != "" {
|
if strings.TrimSpace(existing.Config) != "" {
|
||||||
if err := json.Unmarshal([]byte(existing.Config), &existingConfig); err != nil {
|
if err := json.Unmarshal([]byte(existing.Config), &existingConfig); err != nil {
|
||||||
return fmt.Sprintf(`{"error":"failed to load existing strategy config: %s"}`, err)
|
return fmt.Sprintf(`{"error":"failed to load existing strategy config: %s"}`, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
merged, err := store.MergeStrategyConfig(existingConfig, args.Config)
|
merged, err := store.MergeStrategyConfig(existingConfig, validation.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err)
|
return fmt.Sprintf(`{"error":"invalid strategy config: %s"}`, err)
|
||||||
}
|
}
|
||||||
@@ -1896,11 +2152,18 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Sprintf(`{"error":"strategy updated but failed to reload: %s"}`, err)
|
return fmt.Sprintf(`{"error":"strategy updated but failed to reload: %s"}`, err)
|
||||||
}
|
}
|
||||||
|
changedFields := append([]string{}, metadataChanged...)
|
||||||
|
changedFields = append(changedFields, validation.ChangedFields...)
|
||||||
|
sort.Strings(changedFields)
|
||||||
payload, _ := json.Marshal(map[string]any{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"action": "update",
|
"action": "update",
|
||||||
"strategy": safeStrategyForTool(updated),
|
"strategy_id": updated.ID,
|
||||||
"warnings": warnings,
|
"strategy": safeStrategyForTool(updated),
|
||||||
|
"changed_fields": changedFields,
|
||||||
|
"unchanged_defaults": validation.UnchangedDefaults,
|
||||||
|
"rejected_fields": validation.RejectedFields,
|
||||||
|
"warnings": warnings,
|
||||||
})
|
})
|
||||||
return string(payload)
|
return string(payload)
|
||||||
case "delete":
|
case "delete":
|
||||||
|
|||||||
@@ -34,6 +34,53 @@ func TestParseUnifiedTurnDecisionNormalizesContextPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseUnifiedTurnDecisionAcceptsSkillTaskList(t *testing.T) {
|
||||||
|
raw := `{
|
||||||
|
"topic_intent": "start_new",
|
||||||
|
"business_action": "skill_tasks",
|
||||||
|
"context_mode": "fresh_context",
|
||||||
|
"tasks": [
|
||||||
|
{"id":"task_1","skill":"strategy_management","action":"create","request":"创建高频交易策略","depends_on":[]},
|
||||||
|
{"id":"task_2","skill":"trader_management","action":"configure_strategy","request":"绑定到交易员","depends_on":["task_1"]}
|
||||||
|
],
|
||||||
|
"confidence": 0.86
|
||||||
|
}`
|
||||||
|
|
||||||
|
decision, err := parseUnifiedTurnDecision(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse unified decision: %v", err)
|
||||||
|
}
|
||||||
|
if decision.BusinessAction != "skill_tasks" {
|
||||||
|
t.Fatalf("expected skill_tasks, got %q", decision.BusinessAction)
|
||||||
|
}
|
||||||
|
if len(decision.Tasks) != 2 {
|
||||||
|
t.Fatalf("expected 2 tasks, got %+v", decision.Tasks)
|
||||||
|
}
|
||||||
|
if decision.Tasks[0].Skill != "strategy_management" || decision.Tasks[0].Action != "create" {
|
||||||
|
t.Fatalf("unexpected first task: %+v", decision.Tasks[0])
|
||||||
|
}
|
||||||
|
if !decision.reliable() {
|
||||||
|
t.Fatalf("expected task-list decision to be reliable: %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnifiedTurnDecisionNewSkillCanUseSingleTask(t *testing.T) {
|
||||||
|
decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{
|
||||||
|
TopicIntent: "start_new",
|
||||||
|
BusinessAction: "new_skill",
|
||||||
|
ContextMode: "fresh_context",
|
||||||
|
Tasks: []WorkflowTask{{
|
||||||
|
Skill: "strategy_management",
|
||||||
|
Action: "create",
|
||||||
|
Request: "创建高频交易策略",
|
||||||
|
}},
|
||||||
|
Confidence: 0.9,
|
||||||
|
})
|
||||||
|
if !decision.reliable() {
|
||||||
|
t.Fatalf("expected new_skill with task list to be reliable: %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnifiedTurnDecisionRejectsLowConfidenceAndIncompleteDirectAnswer(t *testing.T) {
|
func TestUnifiedTurnDecisionRejectsLowConfidenceAndIncompleteDirectAnswer(t *testing.T) {
|
||||||
lowConfidence := unifiedTurnDecision{
|
lowConfidence := unifiedTurnDecision{
|
||||||
TopicIntent: "start_new",
|
TopicIntent: "start_new",
|
||||||
@@ -99,6 +146,8 @@ func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) {
|
|||||||
"context_mode values",
|
"context_mode values",
|
||||||
"fresh_context",
|
"fresh_context",
|
||||||
"downstream modules",
|
"downstream modules",
|
||||||
|
"tasks format",
|
||||||
|
"skill_tasks",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(systemPrompt, want) {
|
if !strings.Contains(systemPrompt, want) {
|
||||||
t.Fatalf("expected system prompt to contain %q", want)
|
t.Fatalf("expected system prompt to contain %q", want)
|
||||||
|
|||||||
Reference in New Issue
Block a user