Files
nofx/agent/central_brain.go
2026-04-26 22:38:16 +08:00

747 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
// brainDecision is the routing contract between the first-pass LLM and the executor.
type brainDecision struct {
ThoughtProcess string `json:"thought_process"`
ActionType string `json:"action_type"` // CONTINUE_TASK | NEW_TASK | EXPLAIN_KNOWLEDGE | CANCEL_TASK
TargetSkill string `json:"target_skill,omitempty"` // "skill_name:action" for NEW_TASK
ExtractedData map[string]any `json:"extracted_data,omitempty"`
ReplyToUser string `json:"reply_to_user"`
}
// activeSessionStepDecision is the per-turn control loop inside one active skill task.
type activeSessionStepDecision struct {
Route string `json:"route"` // ask_user | execute_skill | finish_task | cancel_task
Reply string `json:"reply,omitempty"`
ExtractedData map[string]any `json:"extracted_data,omitempty"`
}
// tryMinimalBrain is the single entry point replacing tryUnifiedSemanticGateway.
// Intelligence layer: one routing LLM call → active-session loop → legacy skill execution.
func (a *Agent) tryMinimalBrain(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if a.aiClient == nil {
return "", false, nil
}
activeSession, hasActive := a.getActiveSkillSession(userID)
recentHistory := a.buildRecentConversationContext(userID, text)
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
previousAssistantReply := a.currentPendingHintText(userID)
systemPrompt := buildBrainSystemPrompt(lang)
userPrompt := buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, currentRefs, activeSession, hasActive)
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, nil
}
decision, ok := parseBrainDecision(raw)
if !ok {
return "", false, nil
}
return a.executeBrainDecision(ctx, storeUserID, userID, lang, text, decision, activeSession, hasActive, onEvent)
}
func buildBrainSystemPrompt(lang string) string {
return prependNOFXiAdvisorPreamble(`You are the central brain of NOFXi. Read the intelligence report and output ONE JSON decision. No markdown, no extra text.
Available action_type values:
- "CONTINUE_TASK": user is continuing the current active task
- "NEW_TASK": user is starting a new task
- "EXPLAIN_KNOWLEDGE": user is asking a knowledge question only
- "CANCEL_TASK": user wants to stop the current task
Available skills (for NEW_TASK target_skill):
trader_management, exchange_management, model_management, strategy_management,
trader_diagnosis, exchange_diagnosis, model_diagnosis, strategy_diagnosis
Available actions:
create, update, update_name, update_bindings, configure_strategy, configure_exchange, configure_model,
update_status, update_endpoint, update_config, update_prompt, delete, start, stop, activate, duplicate,
query_list, query_detail, query_running
Rules:
- Prefer CONTINUE_TASK when there is an active task and the user is still talking about it.
- If the current user message is only a greeting, thanks, acknowledgement, or lightweight social chat like "你好", "hi", "hello", "thanks", "谢谢", "收到", do NOT continue the task.
- For those lightweight social messages, choose EXPLAIN_KNOWLEDGE and reply naturally, or let the task stay suspended.
- Use NEW_TASK only when there is no active task, or the user clearly switches goals/domains.
- Use EXPLAIN_KNOWLEDGE for concept/range/help questions; do not change state. When answering, use ONLY the options/values listed in the active session's missing_required_fields. Never invent field values or provider names.
- Use CANCEL_TASK for "cancel", "stop", "forget it", "never mind", "算了", "取消".
- Domain guard: if the user says "模型", "AI 模型", or "model" and asks to create or configure one, you must route to model_management, not exchange_management.
- Domain guard: for model_management, the field "provider" means the AI model vendor such as OpenAI, DeepSeek, Claude, Gemini, Qwen, Kimi, Grok, Minimax, claw402, blockrun-base, or blockrun-sol. It never means an exchange like Binance, OKX, Bybit, CFD, forex, or metals.
- extracted_data should include any concrete facts from the user's message.
- When an active session exposes allowed_field_spec_json, extracted_data must use only those canonical keys. Never output aliases, translated labels, or raw user wording as keys.
- If the user clearly means a bulk destructive operation like "删除所有策略" or "全部删除策略", put the intent signal into extracted_data too. Example: {"bulk_scope":"all"}.
- For strategy changes, do not use the generic "strategy_management:update" action. Use "strategy_management:update_name" for renaming, "strategy_management:update_prompt" for prompt changes, or "strategy_management:update_config" for parameter/config changes. For strategy_management:update_config, extracted_data may include a StrategyConfig-shaped "config_patch".
- Current references are context only. Do not turn a current reference into target_ref_id/target_ref_name unless the user explicitly names that object or clearly refers to "this/current/that previous one". If a mutating task has no clear target, ask instead of executing.
- reply_to_user should be concise and in the user's language.
- For NEW_TASK, target_skill format must be "skill_name:action", for example "strategy_management:create".
Output shape (JSON only):
{"thought_process":"...","action_type":"...","target_skill":"...","extracted_data":{},"reply_to_user":"..."}`)
}
func buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, currentRefs string, activeSession ActiveSkillSession, hasActive bool) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Language: %s\nUser message: %s\n\n", lang, text))
sb.WriteString("=== PREVIOUS ASSISTANT REPLY ===\n")
sb.WriteString(defaultIfEmpty(strings.TrimSpace(previousAssistantReply), "none"))
sb.WriteString("\n\n")
sb.WriteString("=== MANAGEMENT DOMAIN PRIMER ===\n")
if hasActive {
sb.WriteString(defaultIfEmpty(buildSkillDomainPrimer(lang, activeSession.SkillName), "none"))
} else {
sb.WriteString(defaultIfEmpty(buildManagementDomainPrimer(lang), "none"))
}
sb.WriteString("\n\n")
sb.WriteString("=== ACTIVE SESSION ===\n")
if hasActive {
sb.WriteString(fmt.Sprintf("skill: %s\naction: %s\n", activeSession.SkillName, activeSession.ActionName))
if strings.TrimSpace(activeSession.Goal) != "" {
sb.WriteString(fmt.Sprintf("goal: %s\n", activeSession.Goal))
}
if activeSession.PendingHint != nil && strings.TrimSpace(activeSession.PendingHint.Prompt) != "" {
sb.WriteString(fmt.Sprintf("pending_hint: %s\n", strings.TrimSpace(activeSession.PendingHint.Prompt)))
}
if len(activeSession.CollectedFields) > 0 {
fieldsJSON, _ := json.Marshal(activeSession.CollectedFields)
sb.WriteString(fmt.Sprintf("collected_fields: %s\n", fieldsJSON))
}
if missing := fieldConstraintSummary(activeSession); missing != "" {
sb.WriteString("missing_required_fields:\n")
sb.WriteString(missing)
sb.WriteString("\n")
}
fieldSpecs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(activeSession), lang)
if len(fieldSpecs) > 0 {
fieldSpecsJSON, _ := json.Marshal(fieldSpecs)
sb.WriteString(fmt.Sprintf("allowed_field_spec_json: %s\n", fieldSpecsJSON))
}
} else {
sb.WriteString("none\n")
}
sb.WriteString("\n=== CURRENT REFERENCES ===\n")
sb.WriteString(currentRefs)
sb.WriteString("\n\n=== RECENT CONVERSATION ===\n")
sb.WriteString(recentHistory)
return sb.String()
}
func parseBrainDecision(raw string) (brainDecision, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var d brainDecision
if err := json.Unmarshal([]byte(raw), &d); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return brainDecision{}, false
}
if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil {
return brainDecision{}, false
}
}
d.ActionType = strings.ToUpper(strings.TrimSpace(d.ActionType))
d.TargetSkill = strings.TrimSpace(d.TargetSkill)
d.ReplyToUser = strings.TrimSpace(d.ReplyToUser)
switch d.ActionType {
case "CONTINUE_TASK", "NEW_TASK", "EXPLAIN_KNOWLEDGE", "CANCEL_TASK":
return d, true
default:
return brainDecision{}, false
}
}
func parseActiveSessionStepDecision(raw string) (activeSessionStepDecision, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var d activeSessionStepDecision
if err := json.Unmarshal([]byte(raw), &d); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return activeSessionStepDecision{}, false
}
if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil {
return activeSessionStepDecision{}, false
}
}
d.Route = strings.TrimSpace(strings.ToLower(d.Route))
d.Reply = strings.TrimSpace(d.Reply)
switch d.Route {
case "ask_user", "execute_skill", "finish_task", "cancel_task":
return d, true
default:
return activeSessionStepDecision{}, false
}
}
func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, userID int64, lang, text string, d brainDecision, activeSession ActiveSkillSession, hasActive bool, onEvent func(event, data string)) (string, bool, error) {
switch d.ActionType {
case "CANCEL_TASK":
a.clearActiveSkillSession(userID)
a.clearAnyActiveContext(userID)
reply := d.ReplyToUser
if reply == "" {
if lang == "zh" {
reply = "已取消当前流程。"
} else {
reply = "Cancelled the current flow."
}
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "EXPLAIN_KNOWLEDGE":
reply := d.ReplyToUser
if reply == "" {
return "", false, nil
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "NEW_TASK":
skill, action := parseTargetSkill(d.TargetSkill)
if skill == "" {
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
return answer, true, err
}
session := newActiveSkillSession(userID, skill, action)
session.Goal = strings.TrimSpace(text)
d.ExtractedData = filterExtractedDataForActiveSession(session, d.ExtractedData, lang)
mergeExtractedData(&session, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
case "CONTINUE_TASK":
if !hasActive {
return "", false, nil
}
d.ExtractedData = filterExtractedDataForActiveSession(activeSession, d.ExtractedData, lang)
mergeExtractedData(&activeSession, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
default:
return "", false, nil
}
}
func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession, onEvent func(event, data string)) (string, bool, error) {
if answer, ok := a.answerSkillSessionExplanation(storeUserID, lang, activeToLegacySkillSession(session), text); ok {
session = appendActiveSessionLocalHistory(session, "user", text)
session = appendActiveSessionLocalHistory(session, "assistant", answer)
setActiveSessionPendingHint(&session, answer)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, answer)
a.recordSkillInteraction(userID, text, answer)
return answer, true, nil
}
session = appendActiveSessionLocalHistory(session, "user", text)
clearActiveSessionPendingHint(&session)
stepDecision, ok := a.planActiveSessionStep(ctx, storeUserID, userID, lang, text, session)
if !ok {
stepDecision = activeSessionStepDecision{}
}
mergeExtractedData(&session, stepDecision.ExtractedData)
if stepDecision.Route == "" {
if len(missingRequiredFields(session)) > 0 {
stepDecision.Route = "ask_user"
} else {
stepDecision.Route = "execute_skill"
}
}
switch stepDecision.Route {
case "cancel_task":
a.clearActiveSkillSession(userID)
reply := defaultIfEmpty(stepDecision.Reply, "已取消当前流程。")
if lang != "zh" && strings.TrimSpace(stepDecision.Reply) == "" {
reply = "Cancelled the current flow."
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "finish_task":
a.clearActiveSkillSession(userID)
reply := strings.TrimSpace(stepDecision.Reply)
if reply == "" {
return "", false, nil
}
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "ask_user":
reply := strings.TrimSpace(stepDecision.Reply)
if reply == "" {
reply = a.askForMissingFields(lang, session)
}
if len(missingRequiredFields(session)) == 0 && actionNeedsConfirmation(session.SkillName, session.ActionName) {
session.LegacyPhase = "await_confirmation"
session.CollectedFields["phase"] = "await_confirmation"
}
session = appendActiveSessionLocalHistory(session, "assistant", reply)
setActiveSessionPendingHint(&session, reply)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
case "execute_skill":
outcome, nextSession, pending, ok := a.executeActiveSkillSession(storeUserID, userID, lang, text, session)
if !ok {
return "", false, nil
}
if pending {
reply := strings.TrimSpace(outcome.UserMessage)
if reply == "" {
reply = a.askForMissingFields(lang, nextSession)
}
nextSession = appendActiveSessionLocalHistory(nextSession, "assistant", reply)
setActiveSessionPendingHint(&nextSession, reply)
a.saveActiveSkillSession(nextSession)
emitBrainReply(onEvent, reply)
a.recordSkillInteraction(userID, text, reply)
return reply, true, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if review.Route == "replan" && answer == "" {
answer = outcome.UserMessage
}
if answer == "" {
return "", false, nil
}
a.clearActiveSkillSession(userID)
emitBrainReply(onEvent, answer)
a.recordSkillInteraction(userID, text, answer)
return answer, true, nil
default:
return "", false, nil
}
}
func (a *Agent) planActiveSessionStep(ctx context.Context, storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (activeSessionStepDecision, bool) {
if a.aiClient == nil {
return activeSessionStepDecision{}, false
}
legacy := activeToLegacySkillSession(session)
resources := a.buildActiveSessionResources(storeUserID, legacy)
resourcesJSON, _ := json.Marshal(resources)
collectedJSON, _ := json.Marshal(session.CollectedFields)
missingSummary := formatConversationMissingFields(lang, missingRequiredFieldsForBrain(session))
fieldSpecs := allowedFieldSpecsForSkillSession(legacy, lang)
fieldSpecsJSON, _ := json.Marshal(fieldSpecs)
localHistory := formatActiveSessionLocalHistory(session.LocalHistory)
if localHistory == "" {
localHistory = "(empty)"
}
previousAssistantReply := a.currentPendingHintText(userID)
domainPrimer := buildSkillDomainPrimer(lang, session.SkillName)
systemPrompt := prependNOFXiAdvisorPreamble(fmt.Sprintf(`You are the active-task orchestration loop for NOFXi.
You decide the NEXT step for exactly one active task. Return JSON only.
Active task:
- skill: %s
- action: %s
- goal: %s
Current collected fields:
%s
Current missing field summary:
%s
Relevant disclosed resources:
%s
Allowed field spec JSON:
%s
Domain knowledge:
%s
Rules:
- Your job is to decide the next move, not to explain internal schema names.
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal, confirmation request, or question.
- Use contextual memory from the active task history and current references.
- Prefer "execute_skill" when the user has already given enough information to act.
- Prefer "ask_user" only when something truly necessary is still missing.
- If the current message is only a greeting, thanks, acknowledgement, or small talk and does not add task information, do NOT continue task execution. Choose "ask_user" only if you need to gently restate what is pending; otherwise choose "finish_task" with a short social reply.
- Ask naturally. Do not say raw slot names like target_ref unless the user explicitly asks for internal details.
- If the user clearly means a bulk destructive operation like "删除所有策略", "全部删除策略", "all strategies", set extracted_data to {"bulk_scope":"all"} and choose "execute_skill". Do not ask for target_ref.
- If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it.
- Current references are context for reasoning only. Do not copy a current reference into target_ref_id/target_ref_name unless the user explicitly refers to that object by name/id or clearly says "this/current/that previous one". If the target is not clear, ask instead of executing.
- For trader bindings, exchange/model/strategy must resolve to an ID from Relevant disclosed resources before execution. Never invent a resource name or use a generic venue type like Binance/OKX as the bound exchange unless it appears as an actual disclosed resource.
- For strategy_management:create or strategy_management:update_config, when the user describes strategy intent, output config_patch as a partial StrategyConfig JSON object instead of leaving the default template unchanged. Example: "BTC趋势做空" should set coin_source to static BTCUSDT and add prompt/risk/entry rules for BTC trend-following short bias.
- If there are multiple targets and the user did not disambiguate, ask a natural question with the available names.
- If the current user message answers a missing field directly, extract it and continue.
- extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys.
- If a user-provided value does not fit one of those canonical keys, omit it; never create another key.
- If this task is already done and the best next step is just to tell the user the result, choose "finish_task".
- If the user aborts the task, choose "cancel_task".
Return JSON with this exact shape:
{"route":"ask_user|execute_skill|finish_task|cancel_task","reply":"","extracted_data":{}}`,
session.SkillName,
session.ActionName,
defaultIfEmpty(session.Goal, "(not set)"),
defaultIfEmpty(string(collectedJSON), "{}"),
missingSummary,
defaultIfEmpty(string(resourcesJSON), "{}"),
defaultIfEmpty(string(fieldSpecsJSON), "[]"),
defaultIfEmpty(domainPrimer, "(none)"),
))
userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nPrevious assistant reply:\n%s\n\nActive task local history:\n%s\n", lang, text, defaultIfEmpty(previousAssistantReply, "(empty)"), localHistory)
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 activeSessionStepDecision{}, false
}
decision, ok := parseActiveSessionStepDecision(raw)
if !ok {
return activeSessionStepDecision{}, false
}
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
return decision, true
}
func (a *Agent) executeActiveSkillSession(storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (skillOutcome, ActiveSkillSession, bool, bool) {
legacy := activeToLegacySkillSession(session)
a.saveSkillSession(userID, legacy)
answer, handled := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, legacy)
if !handled {
a.clearSkillSession(userID)
return skillOutcome{}, ActiveSkillSession{}, false, false
}
updatedLegacy := a.getSkillSession(userID)
a.clearSkillSession(userID)
outcome := inferSkillOutcome(session.SkillName, session.ActionName, answer, updatedLegacy, skillDataForAction(storeUserID, session.SkillName, session.ActionName, a))
if updatedLegacy.Name != "" {
nextSession := activeSessionFromLegacy(session, updatedLegacy)
return outcome, nextSession, true, true
}
return outcome, ActiveSkillSession{}, false, true
}
func (a *Agent) askForMissingFields(lang string, session ActiveSkillSession) string {
missing := missingRequiredFieldsForBrain(session)
if len(missing) == 0 {
if lang == "zh" {
return "还需要一点信息,我再继续。"
}
return "I need a bit more information before I continue."
}
if session.SkillName == "model_management" && session.ActionName == "create" {
for _, field := range missing {
if field == "provider" {
return modelProviderChoicePrompt(lang)
}
}
}
def, ok := getSkillDefinition(session.SkillName)
if !ok {
if lang == "zh" {
return "还需要更多信息,请继续。"
}
return "I need a bit more information to continue."
}
labels := make([]string, 0, len(missing))
for _, field := range missing {
label := slotDisplayName(field, lang)
if constraint, ok := def.FieldConstraints[field]; ok {
desc := strings.TrimSpace(constraint.Description)
if len(constraint.Values) > 0 {
desc = strings.Join(constraint.Values, " / ")
}
if desc != "" {
label = fmt.Sprintf("%s%s", label, desc)
}
}
labels = append(labels, label)
}
if lang == "zh" {
return "还差一点信息,我才能继续:" + strings.Join(labels, "、") + "。"
}
return "I still need a bit more information before I can continue: " + strings.Join(labels, ", ") + "."
}
func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
legacy := skillSession{
Name: s.SkillName,
Action: s.ActionName,
Phase: defaultIfEmpty(strings.TrimSpace(s.LegacyPhase), "executing"),
Fields: make(map[string]string),
}
for k, v := range s.CollectedFields {
str := activeFieldString(v)
if str == "" || str == "<nil>" {
continue
}
switch k {
case "phase":
legacy.Phase = str
case "target_ref_id":
ensureTargetRef(&legacy)
legacy.TargetRef.ID = str
case "target_ref_name":
ensureTargetRef(&legacy)
legacy.TargetRef.Name = str
case "target_ref":
ensureTargetRef(&legacy)
if legacy.TargetRef.ID == "" {
legacy.TargetRef.ID = str
}
if legacy.TargetRef.Name == "" {
legacy.TargetRef.Name = str
}
default:
legacy.Fields[k] = str
}
}
return legacy
}
func activeFieldString(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case map[string]any, []any, map[string]string, []string:
raw, err := json.Marshal(v)
if err != nil {
return ""
}
return strings.TrimSpace(string(raw))
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func activeSessionFromLegacy(base ActiveSkillSession, legacy skillSession) ActiveSkillSession {
next := base
next.LegacyPhase = strings.TrimSpace(legacy.Phase)
if next.CollectedFields == nil {
next.CollectedFields = map[string]any{}
}
for key, value := range legacy.Fields {
value = strings.TrimSpace(value)
if value == "" {
continue
}
next.CollectedFields[key] = value
}
if legacy.TargetRef != nil {
if value := strings.TrimSpace(legacy.TargetRef.ID); value != "" {
next.CollectedFields["target_ref_id"] = value
}
if value := strings.TrimSpace(legacy.TargetRef.Name); value != "" {
next.CollectedFields["target_ref_name"] = value
}
}
return next
}
func ensureTargetRef(s *skillSession) {
if s.TargetRef == nil {
s.TargetRef = &EntityReference{}
}
}
func (a *Agent) buildActiveSessionResources(storeUserID string, session skillSession) map[string]any {
switch session.Name {
case "trader_management":
if session.Action == "create" {
return a.buildTraderCreateConversationResources(storeUserID, session)
}
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadTraderOptions(storeUserID))
case "exchange_management":
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadExchangeOptions(storeUserID))
case "model_management":
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadEnabledModelOptions(storeUserID))
case "strategy_management":
return a.buildSimpleEntityConversationResources(storeUserID, session, a.loadStrategyOptions(storeUserID))
default:
return nil
}
}
func missingRequiredFieldsForBrain(session ActiveSkillSession) []string {
missing := missingRequiredFields(session)
if len(missing) == 0 {
return nil
}
out := make([]string, 0, len(missing))
for _, field := range missing {
if field == "target_ref" {
if activeSessionHasField(session, "target_ref") {
continue
}
}
out = append(out, field)
}
return out
}
func formatActiveSessionLocalHistory(history []chatMessage) string {
if len(history) == 0 {
return ""
}
start := 0
if len(history) > 8 {
start = len(history) - 8
}
lines := make([]string, 0, len(history)-start)
for _, msg := range history[start:] {
role := strings.TrimSpace(msg.Role)
if role == "" {
role = "unknown"
}
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
lines = append(lines, fmt.Sprintf("%s: %s", role, content))
}
return strings.Join(lines, "\n")
}
func appendActiveSessionLocalHistory(session ActiveSkillSession, role, content string) ActiveSkillSession {
content = strings.TrimSpace(content)
if content == "" {
return session
}
session.LocalHistory = append(session.LocalHistory, chatMessage{
Role: strings.TrimSpace(role),
Content: content,
})
if len(session.LocalHistory) > 12 {
session.LocalHistory = append([]chatMessage(nil), session.LocalHistory[len(session.LocalHistory)-12:]...)
}
return session
}
func parseTargetSkill(target string) (skill, action string) {
parts := strings.SplitN(target, ":", 2)
if len(parts) != 2 {
return "", ""
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
func mergeExtractedData(s *ActiveSkillSession, data map[string]any) {
if s.CollectedFields == nil {
s.CollectedFields = map[string]any{}
}
for k, v := range data {
k = strings.TrimSpace(k)
if k == "" {
continue
}
s.CollectedFields[k] = v
}
}
func filterExtractedDataForActiveSession(session ActiveSkillSession, data map[string]any, lang string) map[string]any {
if len(data) == 0 {
return data
}
specs := allowedFieldSpecsForSkillSession(activeToLegacySkillSession(session), lang)
if len(specs) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(specs))
for _, spec := range specs {
key := strings.TrimSpace(spec.Key)
if key != "" {
allowed[key] = struct{}{}
}
}
out := make(map[string]any, len(data))
for key, value := range data {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if _, ok := allowed[key]; !ok {
continue
}
out[key] = value
}
if len(out) == 0 {
return nil
}
return out
}
func emitBrainReply(onEvent func(event, data string), reply string) {
if onEvent == nil || reply == "" {
return
}
onEvent(StreamEventTool, "central_brain")
emitStreamText(onEvent, reply)
}