mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
1836 lines
59 KiB
Go
1836 lines
59 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"nofx/mcp"
|
||
)
|
||
|
||
const (
|
||
plannerMaxSteps = 8
|
||
plannerMaxIterations = 12
|
||
observationMaxLength = 400
|
||
)
|
||
|
||
var (
|
||
plannerCreateTimeout = 36 * time.Second
|
||
plannerReplanTimeout = 24 * time.Second
|
||
plannerReasonTimeout = 30 * time.Second
|
||
plannerFinalTimeout = 36 * time.Second
|
||
directReplyTimeout = 8 * time.Second
|
||
)
|
||
|
||
type replannerDecision struct {
|
||
Action string `json:"action"`
|
||
Goal string `json:"goal,omitempty"`
|
||
Steps []PlanStep `json:"steps,omitempty"`
|
||
Instruction string `json:"instruction,omitempty"`
|
||
Question string `json:"question,omitempty"`
|
||
}
|
||
|
||
type readFastPathRequest struct {
|
||
Kind string
|
||
ArgsJSON string
|
||
}
|
||
|
||
type directReplyDecision struct {
|
||
Action string `json:"action"`
|
||
Answer string `json:"answer,omitempty"`
|
||
}
|
||
|
||
func latestAskedQuestion(state ExecutionState) string {
|
||
if state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" {
|
||
return strings.TrimSpace(state.Waiting.Question)
|
||
}
|
||
for i := len(state.Steps) - 1; i >= 0; i-- {
|
||
step := state.Steps[i]
|
||
if step.Type == planStepTypeAskUser {
|
||
if q := strings.TrimSpace(step.Instruction); q != "" {
|
||
return q
|
||
}
|
||
if q := strings.TrimSpace(step.OutputSummary); q != "" {
|
||
return q
|
||
}
|
||
}
|
||
}
|
||
if state.Status == executionStatusWaitingUser {
|
||
return strings.TrimSpace(state.FinalAnswer)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func buildWaitingState(state ExecutionState, step PlanStep, question string) *WaitingState {
|
||
waiting := &WaitingState{
|
||
Question: strings.TrimSpace(question),
|
||
Intent: inferWaitingIntent(state.Goal, step, question),
|
||
PendingFields: inferPendingFields(step, question),
|
||
ConfirmationTarget: inferConfirmationTarget(state.Goal, step, question),
|
||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
return normalizeWaitingState(waiting)
|
||
}
|
||
|
||
func inferWaitingIntent(goal string, step PlanStep, question string) string {
|
||
lowerGoal := strings.ToLower(strings.TrimSpace(goal))
|
||
lowerQuestion := strings.ToLower(strings.TrimSpace(question))
|
||
switch {
|
||
case step.RequiresConfirmation || strings.Contains(lowerQuestion, "需要我") || strings.Contains(lowerQuestion, "confirm") || strings.Contains(lowerQuestion, "确认"):
|
||
return "confirm_action"
|
||
case strings.Contains(lowerGoal, "交易员") || strings.Contains(lowerGoal, "trader"):
|
||
return "complete_trader_setup"
|
||
case strings.Contains(lowerGoal, "交易所") || strings.Contains(lowerGoal, "exchange"):
|
||
return "complete_exchange_config"
|
||
case strings.Contains(lowerGoal, "模型") || strings.Contains(lowerGoal, "model"):
|
||
return "complete_model_config"
|
||
default:
|
||
return "provide_missing_information"
|
||
}
|
||
}
|
||
|
||
func inferPendingFields(step PlanStep, question string) []string {
|
||
source := strings.ToLower(strings.TrimSpace(question))
|
||
if source == "" {
|
||
sourceBytes, _ := json.Marshal(step.ToolArgs)
|
||
source = strings.ToLower(string(sourceBytes))
|
||
}
|
||
candidates := []struct {
|
||
key string
|
||
patterns []string
|
||
}{
|
||
{key: "ai_model_id", patterns: []string{"ai_model_id", "model id", "模型id", "模型 id"}},
|
||
{key: "exchange_id", patterns: []string{"exchange_id", "exchange id", "交易所id", "交易所 id"}},
|
||
{key: "strategy_id", patterns: []string{"strategy_id", "strategy id", "策略id", "策略 id"}},
|
||
{key: "name", patterns: []string{"trader name", "name", "名字", "名称"}},
|
||
{key: "api_key", patterns: []string{"api key", "apikey", "api_key"}},
|
||
{key: "secret_key", patterns: []string{"secret key", "secret_key", "密钥"}},
|
||
{key: "passphrase", patterns: []string{"passphrase", "密码短语"}},
|
||
}
|
||
fields := make([]string, 0, len(candidates))
|
||
for _, candidate := range candidates {
|
||
for _, pattern := range candidate.patterns {
|
||
if strings.Contains(source, pattern) {
|
||
fields = append(fields, candidate.key)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return cleanStringList(fields)
|
||
}
|
||
|
||
func inferConfirmationTarget(goal string, step PlanStep, question string) string {
|
||
if step.RequiresConfirmation {
|
||
if step.ToolName != "" {
|
||
return step.ToolName
|
||
}
|
||
}
|
||
lowerGoal := strings.ToLower(strings.TrimSpace(goal))
|
||
lowerQuestion := strings.ToLower(strings.TrimSpace(question))
|
||
switch {
|
||
case strings.Contains(lowerGoal, "交易员") || strings.Contains(lowerQuestion, "交易员") || strings.Contains(lowerGoal, "trader"):
|
||
return "trader"
|
||
case strings.Contains(lowerGoal, "交易所") || strings.Contains(lowerQuestion, "交易所") || strings.Contains(lowerGoal, "exchange"):
|
||
return "exchange_config"
|
||
case strings.Contains(lowerGoal, "模型") || strings.Contains(lowerQuestion, "模型") || strings.Contains(lowerGoal, "model"):
|
||
return "model_config"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func isConfigOrTraderIntent(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
keywords := []string{
|
||
"交易员", "trader", "exchange", "交易所", "模型", "model", "api key", "apikey",
|
||
"绑定", "配置", "setup", "configure", "deepseek", "openai", "claude", "gemini",
|
||
"okx", "binance", "bybit", "gate", "kucoin", "hyperliquid", "aster", "lighter",
|
||
}
|
||
for _, kw := range keywords {
|
||
if strings.Contains(lower, kw) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isStrategyIntent(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
keywords := []string{
|
||
"策略", "strategy", "template", "模板", "激进", "趋势跟踪", "网格策略",
|
||
"量化策略", "策略模板", "strategy studio",
|
||
}
|
||
for _, kw := range keywords {
|
||
if strings.Contains(lower, kw) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isRealtimeAccountIntent(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
keywords := []string{
|
||
"余额", "balance", "equity", "净值", "available", "available balance",
|
||
"持仓", "position", "positions", "仓位", "unrealized pnl", "浮盈", "浮亏",
|
||
"交易历史", "trade history", "history", "closed trades", "recent trades",
|
||
"订单", "order", "orders", "成交", "pnl", "profit", "loss",
|
||
}
|
||
for _, kw := range keywords {
|
||
if strings.Contains(lower, kw) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func snapshotKindsForIntent(userText string) []string {
|
||
kinds := make([]string, 0, 6)
|
||
if isConfigOrTraderIntent(userText) {
|
||
kinds = append(kinds,
|
||
"current_model_configs",
|
||
"current_exchange_configs",
|
||
"current_traders",
|
||
)
|
||
}
|
||
if isStrategyIntent(userText) {
|
||
kinds = append(kinds, "current_strategies")
|
||
}
|
||
return uniqueStrings(kinds)
|
||
}
|
||
|
||
func uniqueStrings(values []string) []string {
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(values))
|
||
seen := make(map[string]struct{}, len(values))
|
||
for _, value := range values {
|
||
if _, ok := seen[value]; ok {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
out = append(out, value)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func withPlannerStageTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||
if timeout <= 0 {
|
||
return context.WithCancel(ctx)
|
||
}
|
||
if deadline, ok := ctx.Deadline(); ok {
|
||
remaining := time.Until(deadline)
|
||
if remaining <= timeout {
|
||
return context.WithCancel(ctx)
|
||
}
|
||
}
|
||
return context.WithTimeout(ctx, timeout)
|
||
}
|
||
|
||
func isPlannerTimeoutError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return errors.Is(err, context.DeadlineExceeded)
|
||
}
|
||
|
||
func plannerTimeoutMessage(lang string) string {
|
||
if lang == "zh" {
|
||
return "⏱️ 当前请求处理超时,请重试一次。若持续出现,请把问题拆小一点。"
|
||
}
|
||
return "⏱️ This request timed out. Please try again, or break it into a smaller request."
|
||
}
|
||
|
||
func shouldResetExecutionStateForNewAttempt(text string, state ExecutionState) bool {
|
||
if state.SessionID == "" {
|
||
return false
|
||
}
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
retrySignals := []string{
|
||
"再试", "重试", "重新", "继续", "继续创建", "我已经配置好了", "已经配置好了", "我配好了",
|
||
"我已经弄好了", "已经弄好了", "好了", "retry", "try again", "continue", "resume",
|
||
"i configured it", "i've configured it", "i already configured", "configured already",
|
||
}
|
||
for _, signal := range retrySignals {
|
||
if strings.Contains(lower, signal) {
|
||
return true
|
||
}
|
||
}
|
||
if isConfigOrTraderIntent(lower) && (state.Status == executionStatusFailed || state.Status == executionStatusCompleted) {
|
||
return true
|
||
}
|
||
if isConfigOrTraderIntent(lower) && state.Status == executionStatusWaitingUser {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func ensureCurrentReferences(state *ExecutionState) {
|
||
if state.CurrentReferences == nil {
|
||
state.CurrentReferences = &CurrentReferences{}
|
||
}
|
||
}
|
||
|
||
func preferReference(current **EntityReference, id, name string) {
|
||
id = strings.TrimSpace(id)
|
||
name = strings.TrimSpace(name)
|
||
if id == "" && name == "" {
|
||
return
|
||
}
|
||
if *current == nil {
|
||
*current = &EntityReference{}
|
||
}
|
||
if id != "" {
|
||
(*current).ID = id
|
||
}
|
||
if name != "" {
|
||
(*current).Name = name
|
||
}
|
||
}
|
||
|
||
func matchEntityReference(text string, candidates []EntityReference) *EntityReference {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return nil
|
||
}
|
||
var matched *EntityReference
|
||
for _, candidate := range candidates {
|
||
id := strings.ToLower(strings.TrimSpace(candidate.ID))
|
||
name := strings.ToLower(strings.TrimSpace(candidate.Name))
|
||
if id == "" && name == "" {
|
||
continue
|
||
}
|
||
if (id != "" && strings.Contains(lower, id)) || (name != "" && strings.Contains(lower, name)) {
|
||
if matched != nil {
|
||
return nil
|
||
}
|
||
copy := candidate
|
||
matched = ©
|
||
}
|
||
}
|
||
return matched
|
||
}
|
||
|
||
func (a *Agent) refreshCurrentReferencesForUserText(storeUserID, text string, state *ExecutionState) {
|
||
if a.store == nil || strings.TrimSpace(text) == "" {
|
||
return
|
||
}
|
||
ensureCurrentReferences(state)
|
||
|
||
if strategies, err := a.store.Strategy().List(storeUserID); err == nil {
|
||
candidates := make([]EntityReference, 0, len(strategies))
|
||
for _, strategy := range strategies {
|
||
candidates = append(candidates, EntityReference{ID: strategy.ID, Name: strategy.Name})
|
||
}
|
||
if ref := matchEntityReference(text, candidates); ref != nil {
|
||
preferReference(&state.CurrentReferences.Strategy, ref.ID, ref.Name)
|
||
}
|
||
}
|
||
if traders, err := a.store.Trader().List(storeUserID); err == nil {
|
||
candidates := make([]EntityReference, 0, len(traders))
|
||
for _, trader := range traders {
|
||
candidates = append(candidates, EntityReference{ID: trader.ID, Name: trader.Name})
|
||
}
|
||
if ref := matchEntityReference(text, candidates); ref != nil {
|
||
preferReference(&state.CurrentReferences.Trader, ref.ID, ref.Name)
|
||
}
|
||
}
|
||
if models, err := a.store.AIModel().List(storeUserID); err == nil {
|
||
candidates := make([]EntityReference, 0, len(models))
|
||
for _, model := range models {
|
||
name := model.Name
|
||
if name == "" {
|
||
name = model.CustomModelName
|
||
}
|
||
if name == "" {
|
||
name = model.Provider
|
||
}
|
||
candidates = append(candidates, EntityReference{ID: model.ID, Name: name})
|
||
}
|
||
if ref := matchEntityReference(text, candidates); ref != nil {
|
||
preferReference(&state.CurrentReferences.Model, ref.ID, ref.Name)
|
||
}
|
||
}
|
||
if exchanges, err := a.store.Exchange().List(storeUserID); err == nil {
|
||
candidates := make([]EntityReference, 0, len(exchanges))
|
||
for _, exchange := range exchanges {
|
||
name := exchange.AccountName
|
||
if name == "" {
|
||
name = exchange.ExchangeType
|
||
}
|
||
candidates = append(candidates, EntityReference{ID: exchange.ID, Name: name})
|
||
}
|
||
if ref := matchEntityReference(text, candidates); ref != nil {
|
||
preferReference(&state.CurrentReferences.Exchange, ref.ID, ref.Name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func updateCurrentReferencesFromToolResult(state *ExecutionState, toolName, raw string) bool {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return false
|
||
}
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||
return false
|
||
}
|
||
ensureCurrentReferences(state)
|
||
before, _ := json.Marshal(state.CurrentReferences)
|
||
|
||
switch toolName {
|
||
case "manage_strategy":
|
||
if item, ok := payload["strategy"].(map[string]any); ok {
|
||
preferReference(&state.CurrentReferences.Strategy, asString(item["id"]), asString(item["name"]))
|
||
}
|
||
case "manage_trader":
|
||
if item, ok := payload["trader"].(map[string]any); ok {
|
||
preferReference(&state.CurrentReferences.Trader, asString(item["id"]), asString(item["name"]))
|
||
preferReference(&state.CurrentReferences.Model, asString(item["ai_model_id"]), "")
|
||
preferReference(&state.CurrentReferences.Exchange, asString(item["exchange_id"]), "")
|
||
preferReference(&state.CurrentReferences.Strategy, asString(item["strategy_id"]), "")
|
||
}
|
||
case "manage_model_config":
|
||
if item, ok := payload["model"].(map[string]any); ok {
|
||
name := asString(item["name"])
|
||
if name == "" {
|
||
name = asString(item["provider"])
|
||
}
|
||
preferReference(&state.CurrentReferences.Model, asString(item["id"]), name)
|
||
}
|
||
case "manage_exchange_config":
|
||
if item, ok := payload["exchange"].(map[string]any); ok {
|
||
name := asString(item["account_name"])
|
||
if name == "" {
|
||
name = asString(item["exchange_type"])
|
||
}
|
||
preferReference(&state.CurrentReferences.Exchange, asString(item["id"]), name)
|
||
}
|
||
case "get_strategies":
|
||
if items, ok := payload["strategies"].([]any); ok && len(items) == 1 {
|
||
if item, ok := items[0].(map[string]any); ok {
|
||
preferReference(&state.CurrentReferences.Strategy, asString(item["id"]), asString(item["name"]))
|
||
}
|
||
}
|
||
}
|
||
state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences)
|
||
after, _ := json.Marshal(state.CurrentReferences)
|
||
return string(before) != string(after)
|
||
}
|
||
|
||
func asString(v any) string {
|
||
s, _ := v.(string)
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
func containsAnyKeyword(text string, keywords []string) bool {
|
||
for _, keyword := range keywords {
|
||
if strings.Contains(text, keyword) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func detectReadFastPath(text string) *readFastPathRequest {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return nil
|
||
}
|
||
|
||
switch lower {
|
||
case "/traders":
|
||
return &readFastPathRequest{Kind: "list_traders"}
|
||
case "/strategies":
|
||
return &readFastPathRequest{Kind: "get_strategies"}
|
||
case "/models":
|
||
return &readFastPathRequest{Kind: "get_model_configs"}
|
||
case "/exchanges":
|
||
return &readFastPathRequest{Kind: "get_exchange_configs"}
|
||
case "/balance":
|
||
return &readFastPathRequest{Kind: "get_balance"}
|
||
case "/positions":
|
||
return &readFastPathRequest{Kind: "get_positions"}
|
||
case "/history", "/trades":
|
||
return &readFastPathRequest{Kind: "get_trade_history", ArgsJSON: `{"limit":10}`}
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func (a *Agent) tryReadFastPath(storeUserID string, userID int64, lang, text string) (string, bool) {
|
||
req := detectReadFastPath(text)
|
||
if req == nil {
|
||
return "", false
|
||
}
|
||
if a.history == nil {
|
||
a.history = newChatHistory(100)
|
||
}
|
||
|
||
a.history.Add(userID, "user", text)
|
||
raw := a.executeReadFastPath(storeUserID, userID, req)
|
||
answer := formatReadFastPathResponse(lang, req.Kind, raw)
|
||
a.history.Add(userID, "assistant", answer)
|
||
if !isEphemeralReadFastPathKind(req.Kind) {
|
||
a.maybeUpdateTaskStateIncrementally(context.Background(), userID)
|
||
a.maybeCompressHistory(context.Background(), userID)
|
||
}
|
||
return answer, true
|
||
}
|
||
|
||
func isEphemeralReadFastPathKind(kind string) bool {
|
||
switch kind {
|
||
case "get_balance", "get_positions", "get_trade_history":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (a *Agent) executeReadFastPath(storeUserID string, _ int64, req *readFastPathRequest) string {
|
||
switch req.Kind {
|
||
case "get_balance":
|
||
return a.toolGetBalance()
|
||
case "get_positions":
|
||
return a.toolGetPositions()
|
||
case "get_trade_history":
|
||
return a.toolGetTradeHistory(req.ArgsJSON)
|
||
case "get_strategies":
|
||
return a.toolGetStrategies(storeUserID)
|
||
case "list_traders":
|
||
return a.toolListTraders(storeUserID)
|
||
case "get_model_configs":
|
||
return a.toolGetModelConfigs(storeUserID)
|
||
case "get_exchange_configs":
|
||
return a.toolGetExchangeConfigs(storeUserID)
|
||
default:
|
||
return `{"error":"unsupported fast path"}`
|
||
}
|
||
}
|
||
|
||
func formatReadFastPathResponse(lang, kind, raw string) string {
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||
return summarizeObservation(raw)
|
||
}
|
||
if errMsg, _ := payload["error"].(string); strings.TrimSpace(errMsg) != "" {
|
||
return summarizeObservation(raw)
|
||
}
|
||
|
||
switch kind {
|
||
case "get_strategies":
|
||
items, _ := payload["strategies"].([]any)
|
||
if len(items) == 0 {
|
||
if lang == "zh" {
|
||
return "当前还没有策略。"
|
||
}
|
||
return "There are no strategies yet."
|
||
}
|
||
lines := []string{"Current strategies:"}
|
||
if lang == "zh" {
|
||
lines[0] = "当前策略:"
|
||
}
|
||
for _, item := range items {
|
||
entry, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
name := asString(entry["name"])
|
||
if name == "" {
|
||
name = asString(entry["id"])
|
||
}
|
||
meta := make([]string, 0, 2)
|
||
if active, _ := entry["is_active"].(bool); active {
|
||
meta = append(meta, "active")
|
||
}
|
||
if isDefault, _ := entry["is_default"].(bool); isDefault {
|
||
meta = append(meta, "default")
|
||
}
|
||
if len(meta) > 0 {
|
||
lines = append(lines, fmt.Sprintf("- %s (%s)", name, strings.Join(meta, ", ")))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||
}
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
case "list_traders":
|
||
items, _ := payload["traders"].([]any)
|
||
if len(items) == 0 {
|
||
if lang == "zh" {
|
||
return "当前还没有交易员。"
|
||
}
|
||
return "There are no traders yet."
|
||
}
|
||
lines := []string{"Current traders:"}
|
||
if lang == "zh" {
|
||
lines[0] = "当前交易员:"
|
||
}
|
||
for _, item := range items {
|
||
entry, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
name := asString(entry["name"])
|
||
line := fmt.Sprintf("- %s", name)
|
||
meta := cleanStringList([]string{asString(entry["exchange_type"]), asString(entry["ai_model_id"])})
|
||
if len(meta) > 0 {
|
||
line += fmt.Sprintf(" (%s)", strings.Join(meta, ", "))
|
||
}
|
||
lines = append(lines, line)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
case "get_model_configs":
|
||
items, _ := payload["model_configs"].([]any)
|
||
if len(items) == 0 {
|
||
if lang == "zh" {
|
||
return "当前还没有模型配置。"
|
||
}
|
||
return "There are no model configs yet."
|
||
}
|
||
lines := []string{"Current model configs:"}
|
||
if lang == "zh" {
|
||
lines[0] = "当前模型配置:"
|
||
}
|
||
for _, item := range items {
|
||
entry, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
name := asString(entry["name"])
|
||
if name == "" {
|
||
name = asString(entry["provider"])
|
||
}
|
||
meta := make([]string, 0, 2)
|
||
if enabled, _ := entry["enabled"].(bool); enabled {
|
||
meta = append(meta, "enabled")
|
||
}
|
||
if model := asString(entry["custom_model_name"]); model != "" {
|
||
meta = append(meta, model)
|
||
}
|
||
if len(meta) > 0 {
|
||
lines = append(lines, fmt.Sprintf("- %s (%s)", name, strings.Join(meta, ", ")))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||
}
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
case "get_exchange_configs":
|
||
items, _ := payload["exchange_configs"].([]any)
|
||
if len(items) == 0 {
|
||
if lang == "zh" {
|
||
return "当前还没有交易所配置。"
|
||
}
|
||
return "There are no exchange configs yet."
|
||
}
|
||
lines := []string{"Current exchange configs:"}
|
||
if lang == "zh" {
|
||
lines[0] = "当前交易所配置:"
|
||
}
|
||
for _, item := range items {
|
||
entry, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
name := asString(entry["account_name"])
|
||
if name == "" {
|
||
name = asString(entry["exchange_type"])
|
||
}
|
||
meta := cleanStringList([]string{asString(entry["exchange_type"])})
|
||
if enabled, _ := entry["enabled"].(bool); enabled {
|
||
meta = append(meta, "enabled")
|
||
}
|
||
if len(meta) > 0 {
|
||
lines = append(lines, fmt.Sprintf("- %s (%s)", name, strings.Join(meta, ", ")))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("- %s", name))
|
||
}
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
case "get_balance":
|
||
items, _ := payload["balances"].([]any)
|
||
if len(items) == 0 {
|
||
if lang == "zh" {
|
||
return "当前没有可用的余额数据。"
|
||
}
|
||
return "No balance data is available right now."
|
||
}
|
||
lines := []string{"Current balance overview:"}
|
||
if lang == "zh" {
|
||
lines[0] = "当前余额概览:"
|
||
}
|
||
var totalEquity float64
|
||
var totalAvailable float64
|
||
for _, item := range items {
|
||
entry, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
equity := toFloat(entry["total_equity"])
|
||
available := toFloat(entry["available"])
|
||
totalEquity += equity
|
||
totalAvailable += available
|
||
lines = append(lines, fmt.Sprintf("- %s (%s): equity %.4f, available %.4f",
|
||
asString(entry["name"]), asString(entry["exchange"]),
|
||
equity, available))
|
||
}
|
||
if len(items) > 1 {
|
||
if lang == "zh" {
|
||
lines = append(lines, fmt.Sprintf("汇总:equity %.4f, available %.4f", totalEquity, totalAvailable))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("Total: equity %.4f, available %.4f", totalEquity, totalAvailable))
|
||
}
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
case "get_positions":
|
||
items, _ := payload["positions"].([]any)
|
||
if len(items) == 0 {
|
||
if lang == "zh" {
|
||
return "当前没有持仓。"
|
||
}
|
||
return "There are no open positions right now."
|
||
}
|
||
lines := []string{"Current positions:"}
|
||
if lang == "zh" {
|
||
lines[0] = "当前持仓:"
|
||
}
|
||
for _, item := range items {
|
||
entry, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
lines = append(lines, fmt.Sprintf("- %s %s size %.4f, entry %.4f, pnl %.4f",
|
||
asString(entry["symbol"]), asString(entry["side"]),
|
||
toFloat(entry["size"]), toFloat(entry["entry_price"]), toFloat(entry["unrealized_pnl"])))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
case "get_trade_history":
|
||
items, _ := payload["trades"].([]any)
|
||
if len(items) == 0 {
|
||
if lang == "zh" {
|
||
return "当前没有已平仓交易历史。"
|
||
}
|
||
return "There is no closed trade history yet."
|
||
}
|
||
summary, _ := payload["summary"].(map[string]any)
|
||
head := fmt.Sprintf("Recent trades: %.0f total, win rate %s, total PnL %.4f",
|
||
toFloat(summary["total_trades"]), asString(summary["win_rate"]), toFloat(summary["total_pnl"]))
|
||
if lang == "zh" {
|
||
head = fmt.Sprintf("最近交易:共 %.0f 笔,胜率 %s,总 PnL %.4f",
|
||
toFloat(summary["total_trades"]), asString(summary["win_rate"]), toFloat(summary["total_pnl"]))
|
||
}
|
||
lines := []string{head}
|
||
for idx, item := range items {
|
||
if idx >= 5 {
|
||
break
|
||
}
|
||
entry, ok := item.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
lines = append(lines, fmt.Sprintf("- %s %s pnl %.4f (%s -> %s)",
|
||
asString(entry["symbol"]), asString(entry["side"]), toFloat(entry["pnl"]),
|
||
asString(entry["entry_time"]), asString(entry["exit_time"])))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
default:
|
||
return summarizeObservation(raw)
|
||
}
|
||
}
|
||
|
||
func (a *Agent) thinkAndAct(ctx context.Context, storeUserID string, userID int64, lang, text string) (string, error) {
|
||
if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, nil); ok || err != nil {
|
||
return answer, err
|
||
}
|
||
if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, nil); ok {
|
||
return answer, nil
|
||
}
|
||
if answer, ok := a.tryLLMSkillRoute(ctx, storeUserID, userID, lang, text, nil); ok {
|
||
return answer, nil
|
||
}
|
||
if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, nil); ok {
|
||
return answer, nil
|
||
}
|
||
if answer, ok := a.tryReadFastPath(storeUserID, userID, lang, text); ok {
|
||
return answer, nil
|
||
}
|
||
if a.aiClient == nil {
|
||
return a.noAIFallback(lang, text)
|
||
}
|
||
return a.runPlannedAgent(ctx, storeUserID, userID, lang, text, nil)
|
||
}
|
||
|
||
func (a *Agent) thinkAndActStream(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) {
|
||
if answer, ok, err := a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil {
|
||
return answer, err
|
||
}
|
||
if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, onEvent); ok {
|
||
return answer, nil
|
||
}
|
||
if answer, ok := a.tryLLMSkillRoute(ctx, storeUserID, userID, lang, text, onEvent); ok {
|
||
return answer, nil
|
||
}
|
||
if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok {
|
||
return answer, nil
|
||
}
|
||
if answer, ok := a.tryReadFastPath(storeUserID, userID, lang, text); ok {
|
||
if onEvent != nil {
|
||
onEvent(StreamEventTool, "read_fast_path")
|
||
onEvent(StreamEventDelta, answer)
|
||
}
|
||
return answer, nil
|
||
}
|
||
if a.aiClient == nil {
|
||
return a.noAIFallback(lang, text)
|
||
}
|
||
return a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
|
||
}
|
||
|
||
func (a *Agent) hasActiveSkillSession(userID int64) bool {
|
||
session := a.getSkillSession(userID)
|
||
return strings.TrimSpace(session.Name) != ""
|
||
}
|
||
|
||
func hasActiveExecutionState(state ExecutionState) bool {
|
||
if strings.TrimSpace(state.SessionID) == "" {
|
||
return false
|
||
}
|
||
switch strings.TrimSpace(state.Status) {
|
||
case executionStatusPlanning, executionStatusRunning, executionStatusWaitingUser:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
|
||
if a.hasActiveSkillSession(userID) {
|
||
if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok {
|
||
return answer, true, nil
|
||
}
|
||
}
|
||
|
||
state := a.getExecutionState(userID)
|
||
if hasActiveExecutionState(state) {
|
||
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
|
||
return answer, true, err
|
||
}
|
||
|
||
return "", false, nil
|
||
}
|
||
|
||
func (a *Agent) tryDirectAnswer(ctx context.Context, userID int64, lang, text string, onEvent func(event, data string)) (string, bool) {
|
||
if a.aiClient == nil {
|
||
return "", false
|
||
}
|
||
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return "", false
|
||
}
|
||
|
||
recentConversationCtx := a.buildRecentConversationContext(userID, text)
|
||
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
|
||
executionState := normalizeExecutionState(a.getExecutionState(userID))
|
||
executionJSON, _ := json.Marshal(executionState)
|
||
systemPrompt := `You are the first-pass router for NOFXi.
|
||
Decide whether the assistant can answer the user's message directly without using skills, tools, or planning.
|
||
Return JSON only. Do not return markdown.
|
||
|
||
Use "direct_answer" only when a concise, self-contained answer is sufficient.
|
||
Examples that often fit direct_answer:
|
||
- greetings, thanks, small talk
|
||
- concept explanations
|
||
- open-ended advice that does not require current system state
|
||
- trading education or opinion questions that can be answered from general reasoning
|
||
|
||
Use "defer" when the message likely needs:
|
||
- a management or diagnosis skill
|
||
- tool reads
|
||
- multi-step planning
|
||
- continuation of an active execution flow that needs stateful follow-up
|
||
|
||
Rules:
|
||
- Consider Recent conversation, Task state, and Execution state JSON before deciding.
|
||
- Default to direct_answer for greetings, thanks, identity questions, and other lightweight conversational turns unless there is a clearly unfinished operational flow that the user is continuing.
|
||
- If the user is clearly continuing an unfinished operational flow, choose defer.
|
||
- If you choose direct_answer, provide the final user-facing answer in the same language as the user.
|
||
- Prefer defer when uncertain.
|
||
|
||
Return JSON with this exact shape:
|
||
{"action":"direct_answer|defer","answer":""}`
|
||
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON))
|
||
|
||
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 "", false
|
||
}
|
||
|
||
decision, err := parseDirectReplyDecision(raw)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
if decision.Action != "direct_answer" {
|
||
return "", false
|
||
}
|
||
|
||
answer := strings.TrimSpace(decision.Answer)
|
||
if answer == "" {
|
||
return "", false
|
||
}
|
||
|
||
if a.history == nil {
|
||
a.history = newChatHistory(100)
|
||
}
|
||
a.history.Add(userID, "user", text)
|
||
a.history.Add(userID, "assistant", answer)
|
||
a.maybeUpdateTaskStateIncrementally(ctx, userID)
|
||
a.maybeCompressHistory(ctx, userID)
|
||
if onEvent != nil {
|
||
onEvent(StreamEventDelta, answer)
|
||
}
|
||
return answer, true
|
||
}
|
||
|
||
func parseDirectReplyDecision(raw string) (directReplyDecision, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
raw = strings.TrimPrefix(raw, "```json")
|
||
raw = strings.TrimPrefix(raw, "```")
|
||
raw = strings.TrimSuffix(raw, "```")
|
||
raw = strings.TrimSpace(raw)
|
||
|
||
var decision directReplyDecision
|
||
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
|
||
return normalizeDirectReplyDecision(decision), nil
|
||
}
|
||
start := strings.Index(raw, "{")
|
||
end := strings.LastIndex(raw, "}")
|
||
if start >= 0 && end > start {
|
||
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
|
||
return normalizeDirectReplyDecision(decision), nil
|
||
}
|
||
}
|
||
return directReplyDecision{}, fmt.Errorf("invalid direct reply decision json")
|
||
}
|
||
|
||
func normalizeDirectReplyDecision(decision directReplyDecision) directReplyDecision {
|
||
decision.Action = strings.TrimSpace(strings.ToLower(decision.Action))
|
||
decision.Answer = strings.TrimSpace(decision.Answer)
|
||
return decision
|
||
}
|
||
|
||
func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) {
|
||
a.history.Add(userID, "user", text)
|
||
if onEvent != nil {
|
||
onEvent(StreamEventPlanning, a.planningStatusText(lang))
|
||
}
|
||
|
||
state, err := a.prepareExecutionState(ctx, storeUserID, userID, lang, text)
|
||
if err != nil {
|
||
if isPlannerTimeoutError(err) {
|
||
msg := plannerTimeoutMessage(lang)
|
||
if onEvent != nil {
|
||
onEvent(StreamEventError, msg)
|
||
onEvent(StreamEventDelta, msg)
|
||
}
|
||
return msg, nil
|
||
}
|
||
a.logger.Warn("planner failed, falling back to legacy loop", "error", err, "user_id", userID)
|
||
return a.thinkAndActLegacy(ctx, userID, lang, text, onEvent)
|
||
}
|
||
|
||
answer, err := a.executePlan(ctx, storeUserID, userID, lang, &state, onEvent)
|
||
if err != nil {
|
||
if isPlannerTimeoutError(err) {
|
||
msg := plannerTimeoutMessage(lang)
|
||
if onEvent != nil {
|
||
onEvent(StreamEventError, msg)
|
||
onEvent(StreamEventDelta, msg)
|
||
}
|
||
return msg, nil
|
||
}
|
||
a.logger.Warn("plan execution failed, falling back to legacy loop", "error", err, "user_id", userID)
|
||
return a.thinkAndActLegacy(ctx, userID, lang, text, onEvent)
|
||
}
|
||
|
||
a.history.Add(userID, "assistant", answer)
|
||
a.maybeUpdateTaskStateIncrementally(ctx, userID)
|
||
a.maybeCompressHistory(ctx, userID)
|
||
return answer, nil
|
||
}
|
||
|
||
func (a *Agent) prepareExecutionState(ctx context.Context, storeUserID string, userID int64, lang, text string) (ExecutionState, error) {
|
||
existing := a.getExecutionState(userID)
|
||
if shouldResetExecutionStateForNewAttempt(text, existing) {
|
||
a.clearExecutionState(userID)
|
||
existing = ExecutionState{}
|
||
}
|
||
if existing.Status == executionStatusWaitingUser && existing.SessionID != "" {
|
||
a.refreshCurrentReferencesForUserText(storeUserID, text, &existing)
|
||
askedQuestion := latestAskedQuestion(existing)
|
||
replySummary := strings.TrimSpace(text)
|
||
if askedQuestion != "" {
|
||
replySummary = fmt.Sprintf("Answer to previous question [%s]: %s", askedQuestion, replySummary)
|
||
}
|
||
appendExecutionLog(&existing, Observation{
|
||
Kind: "user_reply",
|
||
Summary: replySummary,
|
||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||
})
|
||
existing.Status = executionStatusPlanning
|
||
existing.Waiting = nil
|
||
existing.FinalAnswer = ""
|
||
existing.LastError = ""
|
||
existing = a.refreshStateForDynamicRequests(storeUserID, text, existing)
|
||
plan, err := a.createExecutionPlan(ctx, userID, lang, text, existing)
|
||
if err != nil {
|
||
return ExecutionState{}, err
|
||
}
|
||
existing.Goal = plan.Goal
|
||
existing.Steps = plan.Steps
|
||
existing.CurrentStepID = ""
|
||
existing.Status = executionStatusRunning
|
||
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.saveExecutionState(existing); err != nil {
|
||
return ExecutionState{}, err
|
||
}
|
||
return existing, nil
|
||
}
|
||
|
||
state := newExecutionState(userID, text)
|
||
a.refreshCurrentReferencesForUserText(storeUserID, text, &state)
|
||
state = a.refreshStateForDynamicRequests(storeUserID, text, state)
|
||
plan, err := a.createExecutionPlan(ctx, userID, lang, text, state)
|
||
if err != nil {
|
||
return ExecutionState{}, err
|
||
}
|
||
state.Goal = plan.Goal
|
||
state.Steps = plan.Steps
|
||
state.Status = executionStatusRunning
|
||
if err := a.saveExecutionState(state); err != nil {
|
||
return ExecutionState{}, err
|
||
}
|
||
return state, nil
|
||
}
|
||
|
||
func (a *Agent) refreshStateForDynamicRequests(storeUserID, userText string, state ExecutionState) ExecutionState {
|
||
kinds := snapshotKindsForIntent(userText)
|
||
if len(kinds) == 0 {
|
||
return state
|
||
}
|
||
kindsToRefresh := make(map[string]struct{}, len(kinds))
|
||
for _, kind := range kinds {
|
||
kindsToRefresh[kind] = struct{}{}
|
||
}
|
||
|
||
fresh := make([]Observation, 0, len(state.DynamicSnapshots)+3)
|
||
for _, obs := range state.DynamicSnapshots {
|
||
if _, ok := kindsToRefresh[obs.Kind]; ok {
|
||
continue
|
||
}
|
||
fresh = append(fresh, obs)
|
||
}
|
||
|
||
appendSnapshot := func(kind, raw string) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return
|
||
}
|
||
fresh = append(fresh, Observation{
|
||
Kind: kind,
|
||
Summary: summarizeObservation(raw),
|
||
RawJSON: raw,
|
||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
for _, kind := range kinds {
|
||
switch kind {
|
||
case "current_model_configs":
|
||
appendSnapshot(kind, a.toolGetModelConfigs(storeUserID))
|
||
case "current_exchange_configs":
|
||
appendSnapshot(kind, a.toolGetExchangeConfigs(storeUserID))
|
||
case "current_traders":
|
||
appendSnapshot(kind, a.toolListTraders(storeUserID))
|
||
case "current_strategies":
|
||
appendSnapshot(kind, a.toolGetStrategies(storeUserID))
|
||
case "current_balances":
|
||
appendSnapshot(kind, a.toolGetBalance())
|
||
case "current_positions":
|
||
appendSnapshot(kind, a.toolGetPositions())
|
||
case "recent_trade_history":
|
||
appendSnapshot(kind, a.toolGetTradeHistory(`{"limit":10}`))
|
||
}
|
||
}
|
||
state.DynamicSnapshots = fresh
|
||
return state
|
||
}
|
||
|
||
func (a *Agent) buildRecentConversationContext(userID int64, currentUserText string) string {
|
||
if a.history == nil {
|
||
return ""
|
||
}
|
||
|
||
msgs := a.history.Get(userID)
|
||
if len(msgs) == 0 {
|
||
return ""
|
||
}
|
||
|
||
currentUserText = strings.TrimSpace(currentUserText)
|
||
if currentUserText != "" {
|
||
last := msgs[len(msgs)-1]
|
||
if last.Role == "user" && strings.TrimSpace(last.Content) == currentUserText {
|
||
msgs = msgs[:len(msgs)-1]
|
||
}
|
||
}
|
||
|
||
if len(msgs) == 0 {
|
||
return ""
|
||
}
|
||
if len(msgs) > recentConversationMessages {
|
||
msgs = msgs[len(msgs)-recentConversationMessages:]
|
||
}
|
||
|
||
transcript := formatChatMessagesForSummary(msgs)
|
||
if transcript == "" {
|
||
return ""
|
||
}
|
||
return transcript
|
||
}
|
||
|
||
func (a *Agent) createExecutionPlan(ctx context.Context, userID int64, lang, userText string, state ExecutionState) (executionPlan, error) {
|
||
toolDefs, _ := json.Marshal(agentTools())
|
||
stateJSON, _ := json.Marshal(normalizeExecutionState(state))
|
||
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
|
||
recentConversationCtx := a.buildRecentConversationContext(userID, userText)
|
||
if isConfigOrTraderIntent(userText) {
|
||
// Configuration and trader setup requests are especially sensitive to stale
|
||
// summaries like "this capability does not exist". Prefer fresh tool checks.
|
||
taskStateCtx = ""
|
||
}
|
||
|
||
systemPrompt := `You are the planning module for NOFXi.
|
||
Return JSON only. Do not return markdown.
|
||
|
||
Create a minimal safe execution plan using these step types only:
|
||
- tool
|
||
- reason
|
||
- ask_user
|
||
- respond
|
||
|
||
Rules:
|
||
- Use all available memory layers when planning: Execution state JSON, Recent conversation, and Task state.
|
||
- Memory priority order:
|
||
1. Execution state JSON = current operational truth for the active task.
|
||
2. Recent conversation = the best source for what was said in the last few turns.
|
||
3. Task state = compressed durable background only.
|
||
- If these memory layers conflict, prefer execution state first, then recent conversation. Do not let task state override fresher evidence.
|
||
- Do not ask the user to repeat a fact that is already explicit in execution state or recent conversation unless the inputs are contradictory.
|
||
- Use tool steps whenever fresh external data is required.
|
||
- Use ask_user if required parameters are missing.
|
||
- Never place a trade unless the user intent is explicit.
|
||
- For exchange binding or exchange credential requests, prefer get_exchange_configs/manage_exchange_config.
|
||
- For AI model binding or model credential requests, prefer get_model_configs/manage_model_config.
|
||
- For strategy template creation or editing requests, prefer get_strategies/manage_strategy.
|
||
- For trader creation or trader lifecycle requests, prefer manage_trader.
|
||
- A strategy template is independent and does not require exchange/model bindings unless the user explicitly asks to run or deploy it through a trader.
|
||
- If these tools exist, never answer that the system lacks exchange/model/trader management capability.
|
||
- When configuration, strategy, or trader creation is requested, gather missing required fields via ask_user, then call the appropriate tool.
|
||
- Before concluding that exchange/model/trader/strategy setup is impossible or missing, first inspect current state with the relevant tools.
|
||
- For high-volatility state such as balances, positions, recent trade history, or current config availability, prefer fresh tool reads over old observations.
|
||
- Keep the plan short and practical.
|
||
- End with either ask_user or respond.
|
||
- At most 8 steps.
|
||
- For tool steps, set tool_name exactly to one of the available tool names and provide tool_args as JSON object.
|
||
- For reason steps, put the reasoning task in instruction.
|
||
- For ask_user steps, put the exact follow-up question in instruction.
|
||
- For respond steps, put either a short instruction or leave instruction empty.
|
||
- If resuming after a waiting_user state, incorporate the new user reply and return a fresh full plan.
|
||
- Never invent tools.`
|
||
|
||
resumeContext := ""
|
||
if state.SessionID != "" {
|
||
if askedQuestion := latestAskedQuestion(state); askedQuestion != "" {
|
||
resumeContext = fmt.Sprintf("\n\nResume context:\n- The assistant was waiting for the user's answer to this exact question: %s\n- Interpret the new user message as the answer to that question unless the message clearly starts a new topic.", askedQuestion)
|
||
if state.Waiting != nil {
|
||
waitingJSON, _ := json.Marshal(state.Waiting)
|
||
resumeContext += fmt.Sprintf("\n- Structured waiting state JSON: %s", string(waitingJSON))
|
||
}
|
||
}
|
||
}
|
||
|
||
userPrompt := fmt.Sprintf("Language: %s\nUser request: %s%s\n\nRecent conversation:\n%s\n\nAvailable tools JSON:\n%s\n\nPersistent preferences:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s\n\nReturn JSON with this exact shape:\n{\"goal\":\"\",\"steps\":[{\"id\":\"step_1\",\"type\":\"tool|reason|ask_user|respond\",\"title\":\"\",\"tool_name\":\"\",\"tool_args\":{},\"instruction\":\"\",\"requires_confirmation\":false}]}", lang, userText, resumeContext, recentConversationCtx, string(toolDefs), a.buildPersistentPreferencesContext(userID), taskStateCtx, string(stateJSON))
|
||
|
||
stageCtx, cancel := withPlannerStageTimeout(ctx, plannerCreateTimeout)
|
||
defer cancel()
|
||
|
||
resp, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||
Messages: []mcp.Message{
|
||
mcp.NewSystemMessage(systemPrompt),
|
||
mcp.NewUserMessage(userPrompt),
|
||
},
|
||
Ctx: stageCtx,
|
||
})
|
||
if err != nil {
|
||
return executionPlan{}, err
|
||
}
|
||
|
||
plan, err := parseExecutionPlanJSON(resp)
|
||
if err != nil {
|
||
return executionPlan{}, err
|
||
}
|
||
if len(plan.Steps) == 0 {
|
||
return executionPlan{}, fmt.Errorf("empty execution plan")
|
||
}
|
||
if len(plan.Steps) > plannerMaxSteps {
|
||
plan.Steps = plan.Steps[:plannerMaxSteps]
|
||
}
|
||
for i := range plan.Steps {
|
||
if plan.Steps[i].ID == "" {
|
||
plan.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
|
||
}
|
||
if plan.Steps[i].Status == "" {
|
||
plan.Steps[i].Status = planStepStatusPending
|
||
}
|
||
if plan.Steps[i].Title == "" {
|
||
plan.Steps[i].Title = strings.ReplaceAll(plan.Steps[i].ID, "_", " ")
|
||
}
|
||
}
|
||
if strings.TrimSpace(plan.Goal) == "" {
|
||
plan.Goal = strings.TrimSpace(userText)
|
||
}
|
||
return plan, nil
|
||
}
|
||
|
||
func parseExecutionPlanJSON(raw string) (executionPlan, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
raw = strings.TrimPrefix(raw, "```json")
|
||
raw = strings.TrimPrefix(raw, "```")
|
||
raw = strings.TrimSuffix(raw, "```")
|
||
raw = strings.TrimSpace(raw)
|
||
|
||
var plan executionPlan
|
||
if err := json.Unmarshal([]byte(raw), &plan); err == nil {
|
||
return plan, nil
|
||
}
|
||
start := strings.Index(raw, "{")
|
||
end := strings.LastIndex(raw, "}")
|
||
if start >= 0 && end > start {
|
||
if err := json.Unmarshal([]byte(raw[start:end+1]), &plan); err == nil {
|
||
return plan, nil
|
||
}
|
||
}
|
||
return executionPlan{}, fmt.Errorf("invalid execution plan json")
|
||
}
|
||
|
||
func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int64, lang string, state *ExecutionState, onEvent func(event, data string)) (string, error) {
|
||
if onEvent != nil {
|
||
onEvent(StreamEventPlan, formatPlanStatus(*state, lang))
|
||
}
|
||
|
||
for i := 0; i < plannerMaxIterations; i++ {
|
||
stepIndex := nextPendingStepIndex(state.Steps)
|
||
if stepIndex < 0 {
|
||
finalText, err := a.generateFinalPlanResponse(ctx, userID, lang, *state, "")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
state.Status = executionStatusCompleted
|
||
state.FinalAnswer = finalText
|
||
state.CurrentStepID = ""
|
||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.saveExecutionState(*state); err != nil {
|
||
return "", err
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventDelta, finalText)
|
||
}
|
||
return finalText, nil
|
||
}
|
||
|
||
step := &state.Steps[stepIndex]
|
||
step.Status = planStepStatusRunning
|
||
state.Status = executionStatusRunning
|
||
state.CurrentStepID = step.ID
|
||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if onEvent != nil {
|
||
onEvent(StreamEventStepStart, formatStepStatus(*step, stepIndex, len(state.Steps), lang))
|
||
}
|
||
if err := a.saveExecutionState(*state); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
switch step.Type {
|
||
case planStepTypeTool:
|
||
if onEvent != nil {
|
||
onEvent(StreamEventTool, step.ToolName)
|
||
}
|
||
result := a.executePlanTool(ctx, storeUserID, userID, lang, *step)
|
||
summary := summarizeObservation(result)
|
||
referencesChanged := false
|
||
step.Status = planStepStatusCompleted
|
||
step.OutputSummary = summary
|
||
appendExecutionLog(state, Observation{
|
||
StepID: step.ID,
|
||
Kind: "tool_result",
|
||
Summary: summary,
|
||
RawJSON: result,
|
||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||
})
|
||
referencesChanged = updateCurrentReferencesFromToolResult(state, step.ToolName, result)
|
||
if shouldAttemptReplan(*state, *step, referencesChanged) {
|
||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.saveExecutionState(*state); err != nil {
|
||
return "", err
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang))
|
||
}
|
||
decision, err := a.replanAfterStep(ctx, userID, lang, *state, *step)
|
||
if err == nil && applyReplannerDecision(state, decision) {
|
||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.saveExecutionState(*state); err != nil {
|
||
return "", err
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventReplan, formatReplanStatus(decision, lang))
|
||
onEvent(StreamEventPlan, formatPlanStatus(*state, lang))
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
case planStepTypeReason:
|
||
reasoning, err := a.executeReasonStep(ctx, userID, lang, state.Goal, *state, *step)
|
||
if err != nil {
|
||
step.Status = planStepStatusFailed
|
||
step.Error = err.Error()
|
||
state.Status = executionStatusFailed
|
||
state.LastError = err.Error()
|
||
_ = a.saveExecutionState(*state)
|
||
return "", err
|
||
}
|
||
step.Status = planStepStatusCompleted
|
||
step.OutputSummary = reasoning
|
||
appendExecutionLog(state, Observation{
|
||
StepID: step.ID,
|
||
Kind: "reasoning",
|
||
Summary: reasoning,
|
||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||
})
|
||
case planStepTypeAskUser:
|
||
question := strings.TrimSpace(step.Instruction)
|
||
if question == "" {
|
||
if lang == "zh" {
|
||
question = "我还缺少一些信息,麻烦你补充一下。"
|
||
} else {
|
||
question = "I need a bit more information before I continue."
|
||
}
|
||
}
|
||
step.Status = planStepStatusCompleted
|
||
step.OutputSummary = question
|
||
state.Status = executionStatusWaitingUser
|
||
state.Waiting = buildWaitingState(*state, *step, question)
|
||
state.FinalAnswer = question
|
||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.saveExecutionState(*state); err != nil {
|
||
return "", err
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang))
|
||
onEvent(StreamEventDelta, question)
|
||
}
|
||
return question, nil
|
||
case planStepTypeRespond:
|
||
finalText, err := a.generateFinalPlanResponse(ctx, userID, lang, *state, step.Instruction)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
step.Status = planStepStatusCompleted
|
||
step.OutputSummary = finalText
|
||
state.Status = executionStatusCompleted
|
||
state.Waiting = nil
|
||
state.FinalAnswer = finalText
|
||
state.CurrentStepID = ""
|
||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.saveExecutionState(*state); err != nil {
|
||
return "", err
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang))
|
||
onEvent(StreamEventDelta, finalText)
|
||
}
|
||
return finalText, nil
|
||
default:
|
||
return "", fmt.Errorf("unsupported step type: %s", step.Type)
|
||
}
|
||
|
||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
if err := a.saveExecutionState(*state); err != nil {
|
||
return "", err
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventStepComplete, formatStepCompleteStatus(*step, lang))
|
||
}
|
||
}
|
||
|
||
return "", fmt.Errorf("plan execution exceeded iteration limit")
|
||
}
|
||
|
||
func (a *Agent) replanAfterStep(ctx context.Context, userID int64, lang string, state ExecutionState, completedStep PlanStep) (replannerDecision, error) {
|
||
obsJSON, _ := json.Marshal(buildObservationContext(state))
|
||
stepsJSON, _ := json.Marshal(state.Steps)
|
||
systemPrompt := `You are the replanning module for NOFXi.
|
||
Return JSON only.
|
||
|
||
Decide what to do after a plan step completed.
|
||
Allowed actions:
|
||
- continue
|
||
- replace_remaining
|
||
- ask_user
|
||
- finish
|
||
|
||
Rules:
|
||
- Use continue when the current remaining steps still make sense.
|
||
- Use replace_remaining when the observations materially change the remaining plan.
|
||
- Use ask_user when execution is blocked on missing user input.
|
||
- Use finish when there is enough information to answer and remaining steps are unnecessary.
|
||
- If action=replace_remaining, return a fresh list of remaining steps only.
|
||
- Keep plans short and safe.
|
||
- Never invent tools.`
|
||
|
||
userPrompt := fmt.Sprintf("Language: %s\nGoal: %s\nCompleted step: %s (%s)\nCompleted summary: %s\n\nCurrent steps JSON:\n%s\n\nObservations JSON:\n%s\n\nPersistent preferences:\n%s\n\nTask state:\n%s\n\nReturn JSON with this exact shape:\n{\"action\":\"continue|replace_remaining|ask_user|finish\",\"goal\":\"\",\"instruction\":\"\",\"question\":\"\",\"steps\":[{\"id\":\"step_x\",\"type\":\"tool|reason|ask_user|respond\",\"title\":\"\",\"tool_name\":\"\",\"tool_args\":{},\"instruction\":\"\",\"requires_confirmation\":false}]}", lang, state.Goal, completedStep.ID, completedStep.Type, completedStep.OutputSummary, string(stepsJSON), string(obsJSON), a.buildPersistentPreferencesContext(userID), buildTaskStateContext(a.getTaskState(userID)))
|
||
|
||
stageCtx, cancel := withPlannerStageTimeout(ctx, plannerReplanTimeout)
|
||
defer cancel()
|
||
|
||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||
Messages: []mcp.Message{
|
||
mcp.NewSystemMessage(systemPrompt),
|
||
mcp.NewUserMessage(userPrompt),
|
||
},
|
||
Ctx: stageCtx,
|
||
MaxTokens: intPtr(500),
|
||
})
|
||
if err != nil {
|
||
return replannerDecision{}, err
|
||
}
|
||
return parseReplannerDecisionJSON(raw)
|
||
}
|
||
|
||
func parseReplannerDecisionJSON(raw string) (replannerDecision, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
raw = strings.TrimPrefix(raw, "```json")
|
||
raw = strings.TrimPrefix(raw, "```")
|
||
raw = strings.TrimSuffix(raw, "```")
|
||
raw = strings.TrimSpace(raw)
|
||
|
||
var decision replannerDecision
|
||
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
|
||
return normalizeReplannerDecision(decision), nil
|
||
}
|
||
start := strings.Index(raw, "{")
|
||
end := strings.LastIndex(raw, "}")
|
||
if start >= 0 && end > start {
|
||
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
|
||
return normalizeReplannerDecision(decision), nil
|
||
}
|
||
}
|
||
return replannerDecision{}, fmt.Errorf("invalid replanner decision json")
|
||
}
|
||
|
||
func normalizeReplannerDecision(decision replannerDecision) replannerDecision {
|
||
decision.Action = strings.TrimSpace(decision.Action)
|
||
decision.Goal = strings.TrimSpace(decision.Goal)
|
||
decision.Instruction = strings.TrimSpace(decision.Instruction)
|
||
decision.Question = strings.TrimSpace(decision.Question)
|
||
for i := range decision.Steps {
|
||
if decision.Steps[i].ID == "" {
|
||
decision.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
|
||
}
|
||
if decision.Steps[i].Status == "" {
|
||
decision.Steps[i].Status = planStepStatusPending
|
||
}
|
||
decision.Steps[i].Type = strings.TrimSpace(decision.Steps[i].Type)
|
||
decision.Steps[i].Title = strings.TrimSpace(decision.Steps[i].Title)
|
||
decision.Steps[i].ToolName = strings.TrimSpace(decision.Steps[i].ToolName)
|
||
decision.Steps[i].Instruction = strings.TrimSpace(decision.Steps[i].Instruction)
|
||
}
|
||
return decision
|
||
}
|
||
|
||
func applyReplannerDecision(state *ExecutionState, decision replannerDecision) bool {
|
||
switch decision.Action {
|
||
case "", "continue":
|
||
return false
|
||
case "finish":
|
||
state.Steps = append(completedSteps(state.Steps), PlanStep{
|
||
ID: fmt.Sprintf("step_finish_%d", time.Now().UTC().UnixNano()),
|
||
Type: planStepTypeRespond,
|
||
Title: "final response",
|
||
Status: planStepStatusPending,
|
||
Instruction: decision.Instruction,
|
||
})
|
||
state.CurrentStepID = ""
|
||
if decision.Goal != "" {
|
||
state.Goal = decision.Goal
|
||
}
|
||
state.Waiting = nil
|
||
return true
|
||
case "ask_user":
|
||
question := decision.Question
|
||
if question == "" {
|
||
question = decision.Instruction
|
||
}
|
||
state.Steps = append(completedSteps(state.Steps), PlanStep{
|
||
ID: fmt.Sprintf("step_ask_%d", time.Now().UTC().UnixNano()),
|
||
Type: planStepTypeAskUser,
|
||
Title: "need user input",
|
||
Status: planStepStatusPending,
|
||
Instruction: question,
|
||
})
|
||
state.CurrentStepID = ""
|
||
if decision.Goal != "" {
|
||
state.Goal = decision.Goal
|
||
}
|
||
state.Waiting = buildWaitingState(*state, state.Steps[len(state.Steps)-1], question)
|
||
return true
|
||
case "replace_remaining":
|
||
if len(decision.Steps) == 0 {
|
||
return false
|
||
}
|
||
state.Steps = append(completedSteps(state.Steps), decision.Steps...)
|
||
state.CurrentStepID = ""
|
||
if decision.Goal != "" {
|
||
state.Goal = decision.Goal
|
||
}
|
||
state.Waiting = nil
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func shouldAttemptReplan(state ExecutionState, step PlanStep, referencesChanged bool) bool {
|
||
if step.Type != planStepTypeTool {
|
||
return false
|
||
}
|
||
if toolResultIndicatesError(step.OutputSummary) || toolResultSignalsDependencyGap(step.OutputSummary) {
|
||
return true
|
||
}
|
||
if referencesChanged {
|
||
return true
|
||
}
|
||
if !hasPendingWorkAfterStep(state.Steps) {
|
||
return false
|
||
}
|
||
switch step.ToolName {
|
||
case "manage_trader", "manage_strategy", "manage_model_config", "manage_exchange_config", "execute_trade":
|
||
return toolActionMayChangePlan(step.ToolArgs)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func hasPendingWorkAfterStep(steps []PlanStep) bool {
|
||
for _, step := range steps {
|
||
if step.Status == planStepStatusPending {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func toolActionMayChangePlan(args map[string]any) bool {
|
||
action, _ := args["action"].(string)
|
||
switch strings.TrimSpace(action) {
|
||
case "create", "update", "delete", "start", "stop", "activate", "duplicate":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func toolResultIndicatesError(summary string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(summary))
|
||
return strings.Contains(lower, `"error"`) || strings.Contains(lower, `"status":"error"`) || strings.Contains(lower, "failed to ")
|
||
}
|
||
|
||
func toolResultSignalsDependencyGap(summary string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(summary))
|
||
patterns := []string{
|
||
"is required", "invalid ai_model_id", "invalid exchange_id", "invalid strategy_id",
|
||
"ai model is disabled", "exchange is disabled", "not found", "missing",
|
||
}
|
||
return containsAnyKeyword(lower, patterns)
|
||
}
|
||
|
||
func completedSteps(steps []PlanStep) []PlanStep {
|
||
out := make([]PlanStep, 0, len(steps))
|
||
for _, step := range steps {
|
||
if step.Status == planStepStatusCompleted {
|
||
out = append(out, step)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (a *Agent) planningStatusText(lang string) string {
|
||
if lang == "zh" {
|
||
return "🧭 正在规划执行步骤..."
|
||
}
|
||
return "🧭 Planning the next execution steps..."
|
||
}
|
||
|
||
func formatPlanStatus(state ExecutionState, lang string) string {
|
||
parts := make([]string, 0, len(state.Steps))
|
||
for i, step := range state.Steps {
|
||
label := step.Title
|
||
if label == "" {
|
||
label = step.Type
|
||
}
|
||
parts = append(parts, fmt.Sprintf("%d.%s", i+1, label))
|
||
}
|
||
if lang == "zh" {
|
||
return fmt.Sprintf("🗺️ 计划: %s", strings.Join(parts, " -> "))
|
||
}
|
||
return fmt.Sprintf("🗺️ Plan: %s", strings.Join(parts, " -> "))
|
||
}
|
||
|
||
func formatStepStatus(step PlanStep, idx, total int, lang string) string {
|
||
label := step.Title
|
||
if label == "" {
|
||
label = step.Type
|
||
}
|
||
if lang == "zh" {
|
||
return fmt.Sprintf("▶️ 步骤 %d/%d: %s", idx+1, total, label)
|
||
}
|
||
return fmt.Sprintf("▶️ Step %d/%d: %s", idx+1, total, label)
|
||
}
|
||
|
||
func formatStepCompleteStatus(step PlanStep, lang string) string {
|
||
label := step.Title
|
||
if label == "" {
|
||
label = step.Type
|
||
}
|
||
if lang == "zh" {
|
||
return fmt.Sprintf("✅ 已完成: %s", label)
|
||
}
|
||
return fmt.Sprintf("✅ Completed: %s", label)
|
||
}
|
||
|
||
func formatReplanStatus(decision replannerDecision, lang string) string {
|
||
switch decision.Action {
|
||
case "replace_remaining":
|
||
if lang == "zh" {
|
||
return "🔄 已根据新结果更新后续步骤"
|
||
}
|
||
return "🔄 Updated the remaining steps based on new results"
|
||
case "ask_user":
|
||
if lang == "zh" {
|
||
return "📝 当前流程需要用户补充信息"
|
||
}
|
||
return "📝 This flow needs more user input"
|
||
case "finish":
|
||
if lang == "zh" {
|
||
return "🏁 已提前收敛到最终回复"
|
||
}
|
||
return "🏁 Converged early to the final response"
|
||
default:
|
||
if lang == "zh" {
|
||
return "🔄 已重新评估计划"
|
||
}
|
||
return "🔄 Re-evaluated the plan"
|
||
}
|
||
}
|
||
|
||
func (a *Agent) executePlanTool(ctx context.Context, storeUserID string, userID int64, lang string, step PlanStep) string {
|
||
argsJSON := "{}"
|
||
if len(step.ToolArgs) > 0 {
|
||
if data, err := json.Marshal(step.ToolArgs); err == nil {
|
||
argsJSON = string(data)
|
||
}
|
||
}
|
||
return a.handleToolCall(ctx, storeUserID, userID, lang, mcp.ToolCall{
|
||
ID: step.ID,
|
||
Type: "function",
|
||
Function: mcp.ToolCallFunction{
|
||
Name: step.ToolName,
|
||
Arguments: argsJSON,
|
||
},
|
||
})
|
||
}
|
||
|
||
func (a *Agent) executeReasonStep(ctx context.Context, userID int64, lang, goal string, state ExecutionState, step PlanStep) (string, error) {
|
||
obsJSON, _ := json.Marshal(buildObservationContext(state))
|
||
stageCtx, cancel := withPlannerStageTimeout(ctx, plannerReasonTimeout)
|
||
defer cancel()
|
||
|
||
resp, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||
Messages: []mcp.Message{
|
||
mcp.NewSystemMessage("You are the reasoning module for NOFXi. Return one short paragraph only. No markdown, no bullet list."),
|
||
mcp.NewUserMessage(fmt.Sprintf("Language: %s\nGoal: %s\nReasoning task: %s\nObservations JSON: %s\nPersistent preferences: %s\nTask state: %s", lang, goal, step.Instruction, string(obsJSON), a.buildPersistentPreferencesContext(userID), buildTaskStateContext(a.getTaskState(userID)))),
|
||
},
|
||
Ctx: stageCtx,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return summarizeObservation(resp), nil
|
||
}
|
||
|
||
func (a *Agent) generateFinalPlanResponse(ctx context.Context, userID int64, lang string, state ExecutionState, instruction string) (string, error) {
|
||
obsJSON, _ := json.Marshal(buildObservationContext(state))
|
||
systemPrompt := a.buildSystemPrompt(lang)
|
||
if instruction == "" {
|
||
instruction = "Provide the best possible final response to the user based on the finished execution."
|
||
}
|
||
stageCtx, cancel := withPlannerStageTimeout(ctx, plannerFinalTimeout)
|
||
defer cancel()
|
||
return a.aiClient.CallWithRequest(&mcp.Request{
|
||
Messages: []mcp.Message{
|
||
mcp.NewSystemMessage(systemPrompt),
|
||
mcp.NewSystemMessage("You are responding after a completed execution plan. Use the observations as the source of truth. Be concise and actionable."),
|
||
mcp.NewUserMessage(fmt.Sprintf("Goal: %s\nResponse instruction: %s\nObservations JSON: %s\nPersistent preferences: %s\nTask state: %s", state.Goal, instruction, string(obsJSON), a.buildPersistentPreferencesContext(userID), buildTaskStateContext(a.getTaskState(userID)))),
|
||
},
|
||
Ctx: stageCtx,
|
||
})
|
||
}
|
||
|
||
func nextPendingStepIndex(steps []PlanStep) int {
|
||
for i := range steps {
|
||
if steps[i].Status == "" || steps[i].Status == planStepStatusPending {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func summarizeObservation(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if len(value) <= observationMaxLength {
|
||
return value
|
||
}
|
||
return strings.TrimSpace(value[:observationMaxLength]) + "..."
|
||
}
|
||
|
||
func (a *Agent) thinkAndActLegacy(ctx context.Context, userID int64, lang, text string, onEvent func(event, data string)) (string, error) {
|
||
systemPrompt := a.buildSystemPrompt(lang)
|
||
enrichment := a.gatherContext(text)
|
||
preferencesCtx := a.buildPersistentPreferencesContext(userID)
|
||
|
||
userPrompt := text
|
||
if preferencesCtx != "" {
|
||
userPrompt = preferencesCtx + "\n\n---\n" + userPrompt
|
||
}
|
||
if enrichment != "" {
|
||
userPrompt = text + "\n\n---\n[NOFXi System Context - real-time data for reference]\n" + enrichment
|
||
if preferencesCtx != "" {
|
||
userPrompt = preferencesCtx + "\n\n---\n" + userPrompt
|
||
}
|
||
}
|
||
|
||
messages := []mcp.Message{mcp.NewSystemMessage(systemPrompt)}
|
||
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
|
||
if isConfigOrTraderIntent(text) {
|
||
taskStateCtx = ""
|
||
}
|
||
if taskStateCtx != "" {
|
||
messages = append(messages, mcp.NewSystemMessage(taskStateCtx))
|
||
}
|
||
history := a.history.Get(userID)
|
||
if len(history) > 0 {
|
||
history = history[:len(history)-1]
|
||
}
|
||
for _, msg := range history {
|
||
messages = append(messages, mcp.NewMessage(msg.Role, msg.Content))
|
||
}
|
||
messages = append(messages, mcp.NewUserMessage(userPrompt))
|
||
|
||
tools := agentTools()
|
||
|
||
const maxToolRounds = 5
|
||
for round := 0; round < maxToolRounds; round++ {
|
||
req := &mcp.Request{
|
||
Messages: messages,
|
||
Tools: tools,
|
||
ToolChoice: "auto",
|
||
Ctx: ctx,
|
||
}
|
||
|
||
resp, err := a.aiClient.CallWithRequestFull(req)
|
||
if err != nil {
|
||
if round == 0 {
|
||
plainResp, plainErr := a.aiClient.CallWithRequest(&mcp.Request{Messages: messages, Ctx: ctx})
|
||
if plainErr != nil {
|
||
a.logger.Warn("legacy AI plain fallback failed", "error", plainErr, "user_id", userID)
|
||
return a.aiServiceFailure(lang, plainErr)
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventDelta, plainResp)
|
||
}
|
||
return plainResp, nil
|
||
}
|
||
a.logger.Warn("legacy AI tool round failed", "error", err, "user_id", userID, "round", round)
|
||
return a.aiServiceFailure(lang, err)
|
||
}
|
||
|
||
if len(resp.ToolCalls) == 0 {
|
||
if onEvent != nil {
|
||
onEvent(StreamEventDelta, resp.Content)
|
||
}
|
||
return resp.Content, nil
|
||
}
|
||
|
||
assistantMsg := mcp.Message{Role: "assistant", ToolCalls: resp.ToolCalls}
|
||
if resp.Content != "" {
|
||
assistantMsg.Content = resp.Content
|
||
}
|
||
messages = append(messages, assistantMsg)
|
||
|
||
for _, tc := range resp.ToolCalls {
|
||
if onEvent != nil {
|
||
onEvent(StreamEventTool, tc.Function.Name)
|
||
}
|
||
result := a.handleToolCall(ctx, storeUserIDFromContext(ctx), userID, lang, tc)
|
||
messages = append(messages, mcp.Message{
|
||
Role: "tool",
|
||
Content: result,
|
||
ToolCallID: tc.ID,
|
||
})
|
||
}
|
||
}
|
||
|
||
finalResp, err := a.aiClient.CallWithRequest(&mcp.Request{Messages: messages, Ctx: ctx})
|
||
if err != nil {
|
||
a.logger.Warn("legacy AI final response failed", "error", err, "user_id", userID)
|
||
return a.aiServiceFailure(lang, err)
|
||
}
|
||
if onEvent != nil {
|
||
onEvent(StreamEventDelta, finalResp)
|
||
}
|
||
return finalResp, nil
|
||
}
|