Files
nofx/agent/llm_skill_router.go
2026-04-26 11:58:29 +08:00

722 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"
)
type llmSkillRouteDecision struct {
Intent string `json:"intent,omitempty"`
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
TargetSkill string `json:"target_skill,omitempty"`
ExtractedFields map[string]any `json:"extracted_fields,omitempty"`
NeedPlannerHelp bool `json:"need_planner_help,omitempty"`
Route string `json:"route"`
Track string `json:"track,omitempty"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Filter string `json:"filter,omitempty"`
InlineSubIntent string `json:"inline_sub_intent,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
ContextSwitch bool `json:"context_switch,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
}
func (a *Agent) tryLLMIntentRoute(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
}
text = strings.TrimSpace(text)
if text == "" {
return "", false, nil
}
decision, ok, err := a.routeTurnWithLLM(ctx, userID, lang, text)
if err != nil || !ok {
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
switch decision.Intent {
case "continue", "continue_active":
if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) {
return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent)
}
if _, has := a.getActiveSkillSession(userID); has {
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
if a.hasAnyActiveContext(userID) {
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
}
return "", false, nil
case "cancel":
a.clearPendingProposalSession(userID)
if a.hasAnyActiveContext(userID) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
}
return "", false, nil
case "resume_snapshot":
a.clearPendingProposalSession(userID)
if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) {
if _, has := a.getActiveSkillSession(userID); has {
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
}
return "", false, nil
case "instant_reply":
if a.hasAnyActiveContext(userID) {
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
}
if answer, ok := a.tryDirectAnswer(ctx, userID, lang, text, onEvent); ok {
return answer, true, nil
}
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
return answer, true, err
}
interruptedActiveContext := false
if a.hasAnyActiveContext(userID) {
a.clearPendingProposalSession(userID)
if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, decision.TargetSnapshotID) {
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
}
interruptedActiveContext = true
}
switch decision.Route {
case "workflow":
a.clearPendingProposalSession(userID)
answer, handled, execErr := a.executeWorkflowDecomposition(ctx, storeUserID, userID, lang, text, workflowDecomposition{Tasks: decision.Tasks}, onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, handled, execErr
case "skill":
a.clearPendingProposalSession(userID)
answer, handled, execErr := a.executeRoutedAtomicSkill(ctx, storeUserID, userID, lang, text, decision, onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, handled, execErr
case "planner":
a.clearPendingProposalSession(userID)
answer, execErr := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, plannerContextModeFromRouteDecision(decision), onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, true, execErr
default:
if decision.NeedPlannerHelp || decision.Track == "planning_track" {
a.clearPendingProposalSession(userID)
answer, execErr := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, plannerContextModeFromRouteDecision(decision), onEvent)
if interruptedActiveContext {
answer = a.maybeAppendResumePrompt(userID, lang, text, answer)
}
return answer, true, execErr
}
}
return "", false, nil
}
func plannerContextModeFromRouteDecision(decision llmSkillRouteDecision) string {
if decision.ContextSwitch {
return "fresh_context"
}
return ""
}
func (a *Agent) executeRoutedAtomicSkill(ctx context.Context, storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision, onEvent func(event, data string)) (string, bool, error) {
outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision)
if !ok {
return "", false, nil
}
if isReadOnlyAtomicSkillAction(outcome.Skill, outcome.Action) {
answer := strings.TrimSpace(outcome.UserMessage)
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_intent_plan"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
emitStreamText(onEvent, answer)
}
return answer, true, nil
}
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
if err != nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return "", false, nil
}
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
}
if review.Route == "replan" {
answer, planErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("Original user request:\n%s\n\nPrevious skill outcome JSON:\n%s", text, mustMarshalJSON(outcome)), onEvent)
return answer, true, planErr
}
answer := strings.TrimSpace(review.Answer)
if answer == "" {
answer = strings.TrimSpace(outcome.UserMessage)
}
if answer == "" {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
label := "llm_intent_plan"
if decision.Skill != "" {
label += ":" + decision.Skill
}
if decision.Action != "" {
label += ":" + decision.Action
}
onEvent(StreamEventTool, label)
emitStreamText(onEvent, answer)
}
return answer, true, nil
}
func isReadOnlyAtomicSkillAction(skill, action string) bool {
action = strings.TrimSpace(strings.ToLower(action))
switch action {
case "query", "query_list", "query_detail", "query_running", "query_strategy_binding", "query_exchange_binding", "query_model_binding":
return true
}
return false
}
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision llmSkillRouteDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
return normalizeLLMSkillRouteDecision(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 normalizeLLMSkillRouteDecision(decision), nil
}
}
return llmSkillRouteDecision{}, fmt.Errorf("invalid llm skill route json")
}
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
decision.Intent = strings.TrimSpace(strings.ToLower(decision.Intent))
decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID)
decision.TargetSkill = strings.TrimSpace(strings.ToLower(decision.TargetSkill))
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Track = strings.TrimSpace(strings.ToLower(decision.Track))
decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill))
decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter))
decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
if decision.Confidence < 0 {
decision.Confidence = 0
}
if decision.Confidence > 1 {
decision.Confidence = 1
}
if decision.Route == "" {
switch {
case len(decision.Tasks) > 1:
decision.Route = "workflow"
case decision.TargetSkill != "":
decision.Route = "skill"
case decision.Skill != "" || decision.Action != "":
decision.Route = "skill"
case decision.Track == "planning_track":
decision.Route = "planner"
}
}
if decision.Track == "" {
switch decision.Route {
case "skill", "workflow":
decision.Track = "fast_track"
case "planner":
decision.Track = "planning_track"
}
}
if decision.Intent == "" {
switch {
case decision.Route == "instant_reply":
decision.Intent = "instant_reply"
case decision.TargetSnapshotID != "" && decision.Route == "" && decision.Skill == "" && decision.Action == "" && len(decision.Tasks) == 0:
decision.Intent = "resume_snapshot"
case decision.Route != "" || decision.Track != "" || decision.Skill != "" || decision.Action != "" || decision.TargetSkill != "" || len(decision.Tasks) > 0:
decision.Intent = "start_new"
}
}
if decision.Skill == "" && decision.Action == "" && decision.TargetSkill != "" {
decision.Skill, decision.Action = parseTargetSkill(decision.TargetSkill)
}
if decision.Route == "" && decision.NeedPlannerHelp {
decision.Route = "planner"
}
if decision.Route == "workflow" {
decision.Skill = ""
decision.Action = ""
decision.Filter = ""
return decision
}
if decision.Route != "skill" {
decision.Action = ""
decision.Skill = ""
decision.Filter = ""
decision.Tasks = nil
return decision
}
decision.Tasks = nil
if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" {
decision.Action = "query_running"
} else {
decision.Action = normalizeAtomicSkillAction(decision.Skill, decision.Action)
}
return decision
}
func (a *Agent) routeTurnWithLLM(ctx context.Context, userID int64, lang, text string) (llmSkillRouteDecision, bool, error) {
systemPrompt, userPrompt := a.buildTopLevelRouterPrompt(userID, lang, text)
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 llmSkillRouteDecision{}, false, err
}
decision, err := parseLLMSkillRouteDecision(raw)
if err != nil {
return llmSkillRouteDecision{}, false, err
}
return decision, true, nil
}
func (a *Agent) buildTopLevelRouterPrompt(userID int64, lang, text string) (string, string) {
activeSkill := a.getSkillSession(userID)
activeTask, hasActiveTask := a.getActiveSkillSession(userID)
activeWorkflow := a.getWorkflowSession(userID)
activeExec := a.getExecutionState(userID)
pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID)
previousAssistantReply := a.currentPendingHintText(userID)
snapshots := a.SnapshotManager(userID).List()
snapshotJSON, _ := json.Marshal(snapshots)
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
managementSummary := buildManagementSkillRoutingContextWithSession(lang, &activeSkill)
recentConversation := a.buildRecentConversationContext(userID, text)
if strings.TrimSpace(recentConversation) == "" {
recentConversation = "(empty)"
}
activeFlowSummary := buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal)
if strings.TrimSpace(activeFlowSummary) == "" {
activeFlowSummary = "none"
}
systemPrompt := prependNOFXiAdvisorPreamble(`You are the lightweight intent planner for NOFXi.
Return JSON only.
You are deciding what the current user turn should do at the top level.
You must classify every message into exactly one of these intents before any execution layer takes over.
Valid intents:
- "continue_active": the user is still working on the current active flow
- "start_new": the user is starting or switching to a new task
- "resume_snapshot": the user wants to resume one suspended snapshot
- "cancel": the user wants to cancel the current active flow
- "instant_reply": the user is greeting, chatting, thanking, or asking for a direct explanation without changing task state
Valid routes when intent=start_new:
- "skill"
- "workflow"
- "planner"
Rules:
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question.
- If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks.
- If the user is clearly answering the previous question, prefer "continue_active".
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
- If the user explicitly refers to a suspended task like "刚才那个", "恢复刚才那个", choose "resume_snapshot" and fill target_snapshot_id.
- If the user is only greeting, thanking, social chatting, or asking a concept question without changing task state, choose "instant_reply".
- If the request is broad, ambiguous, or creative, you may choose route "planner".
- If a single management or diagnosis skill can handle it directly, prefer route "skill".
- If multiple dependent steps are needed, prefer route "workflow".
- Set context_switch=true when the user is opening a new topic/task and prior current references or suspended snapshots should not be used to fill business fields. Set context_switch=false when the user intentionally relies on previous context.
- Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON.
Return JSON with this exact shape:
{"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","route":"skill|workflow|planner","track":"fast_track|planning_track","skill":"","action":"","target_skill":"","filter":"","tasks":[],"context_switch":false,"need_planner_help":false,"confidence":0.0}`)
if strings.TrimSpace(activeSkill.Name) != "" || hasActiveTask || hasPendingProposal {
systemPrompt = prependNOFXiAdvisorPreamble(`You are the one-pass semantic gateway for NOFXi.
Return JSON only.
You are deciding whether the user is continuing the current active flow, switching to a new task, resuming a suspended snapshot, cancelling, or simply asking for a direct reply.
Rules:
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question.
- If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks.
- Prefer "continue_active" when the user is plausibly answering the current active flow.
- If the user asks a read-only management query while an active flow is open, output intent "start_new", route "skill", and the matching query action. For example, "现有策略有哪些" means strategy_management/query_list and must use the strategy query tool, not a freeform answer.
- If the user starts a multi-step, multi-domain, batch, or condition-based management request while an active flow is open, output intent "start_new", route "workflow", and fill tasks exactly like the normal top-level router. Do not squeeze a complex new request into only the first skill/action.
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
- Examples of forced switch: "不是交易员,是策略", "不是这个", "换个任务", "I mean the strategy, not the trader".
- If the user refers to a suspended task and one snapshot clearly matches, use "resume_snapshot".
- If the user cancels the current task, use "cancel".
- If the user only greets, thanks, chats, or asks for explanation without changing state, use "instant_reply".
- Short greetings or acknowledgements like "你好", "hi", "hello", "谢谢", "收到", "好的" should default to "instant_reply" unless they clearly contain task data.
- If intent=start_new, keep the same business routing semantics as the normal router: use route "skill" for one atomic management action, route "workflow" for multiple dependent or independent management actions, and route "planner" for broad or ambiguous work.
- You may set target_skill when intent=start_new and the next task is clear.
- Do not hallucinate snapshot ids; only use those disclosed in Suspended snapshots JSON.
Return JSON with this exact shape:
{"intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","target_snapshot_id":"","route":"skill|workflow|planner","track":"fast_track|planning_track","skill":"","action":"","target_skill":"","filter":"","tasks":[],"context_switch":false,"extracted_fields":{},"need_planner_help":false,"reason":"","confidence":0.0}`)
}
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nManagement skill summary:\n%s\n\nManagement domain primer:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n",
lang,
text,
defaultIfEmpty(previousAssistantReply, "(empty)"),
defaultIfEmpty(managementSummary, "(empty)"),
defaultIfEmpty(buildManagementDomainPrimer(lang), "(empty)"),
currentRefs,
activeFlowSummary,
defaultIfEmpty(string(snapshotJSON), "[]"),
recentConversation,
)
return systemPrompt, userPrompt
}
func buildTopLevelActiveFlowSummary(lang string, skill skillSession, activeTask ActiveSkillSession, hasActiveTask bool, workflow WorkflowSession, state ExecutionState, pendingProposal PendingProposalSession, hasPendingProposal bool) string {
lines := make([]string, 0, 8)
if hasActiveTask {
lines = append(lines, fmt.Sprintf("Active task session: %s / %s / phase=%s", activeTask.SkillName, activeTask.ActionName, defaultIfEmpty(activeTask.LegacyPhase, "collecting")))
if strings.TrimSpace(activeTask.Goal) != "" {
lines = append(lines, "Active task goal: "+strings.TrimSpace(activeTask.Goal))
}
if activeTask.PendingHint != nil && strings.TrimSpace(activeTask.PendingHint.Prompt) != "" {
lines = append(lines, "Active task pending hint: "+strings.TrimSpace(activeTask.PendingHint.Prompt))
}
if len(activeTask.CollectedFields) > 0 {
fieldsJSON, _ := json.Marshal(activeTask.CollectedFields)
lines = append(lines, "Active task collected_fields: "+string(fieldsJSON))
}
}
if strings.TrimSpace(skill.Name) != "" {
lines = append(lines, fmt.Sprintf("Active skill session: %s / %s / phase=%s", skill.Name, skill.Action, defaultIfEmpty(skill.Phase, "collecting")))
if routing := buildSkillActionRoutingSummary(lang, skill); routing != "" {
lines = append(lines, routing)
}
}
if hasActiveWorkflowSession(workflow) {
lines = append(lines, fmt.Sprintf("Active workflow: original_request=%s pending_tasks=%d", workflow.OriginalRequest, countPendingWorkflowTasks(workflow)))
}
if hasActiveExecutionState(state) {
lines = append(lines, fmt.Sprintf("Active execution state: status=%s goal=%s", state.Status, state.Goal))
if state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" {
lines = append(lines, "Waiting question: "+strings.TrimSpace(state.Waiting.Question))
}
}
if hasPendingProposal {
lines = append(lines, "Pending assistant proposal awaiting user response.")
if strings.TrimSpace(pendingProposal.SourceUserText) != "" {
lines = append(lines, "Proposal source request: "+strings.TrimSpace(pendingProposal.SourceUserText))
}
lines = append(lines, "Proposal text: "+strings.TrimSpace(pendingProposal.ProposalText))
}
return strings.Join(lines, "\n")
}
func (a *Agent) handlePendingProposalResponse(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
proposal, ok := a.getPendingProposalSession(userID)
if !ok {
return "", false, nil
}
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("The user is replying to the assistant's previous proposal.\n\nOriginal user request:\n%s\n\nPrevious assistant proposal:\n%s\n\nCurrent user reply:\n%s", proposal.SourceUserText, proposal.ProposalText, text), onEvent)
if err == nil && strings.TrimSpace(answer) != "" {
a.clearPendingProposalSession(userID)
}
return answer, true, err
}
func countPendingWorkflowTasks(session WorkflowSession) int {
count := 0
for _, task := range session.Tasks {
switch task.Status {
case workflowTaskPending, workflowTaskRunning:
count++
}
}
return count
}
func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) {
session := skillSession{Name: decision.Skill, Action: decision.Action, Phase: "collecting"}
applyExtractedFieldsToSkillSession(&session, decision.ExtractedFields, "llm_router")
return a.executeAtomicSkillTaskOutcomeWithSession(storeUserID, userID, lang, text, session, nil)
}
func applyExtractedFieldsToSkillSession(session *skillSession, values map[string]any, source string) {
if session == nil || len(values) == 0 {
return
}
ensureSkillFields(session)
for key, raw := range values {
value := strings.TrimSpace(fmt.Sprint(raw))
if value == "" {
continue
}
switch key {
case "target_ref_id":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.ID = value
if source != "" {
session.TargetRef.Source = source
}
case "target_ref_name":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.Name = value
if source != "" {
session.TargetRef.Source = source
}
default:
setField(session, key, value)
}
}
}
func buildCurrentReferenceSummary(lang string, refs *CurrentReferences) string {
if refs == nil {
if lang == "zh" {
return "- 当前没有明确锁定的操作对象。"
}
return "- No current entity references are locked yet."
}
lines := make([]string, 0, 4)
appendLine := func(kind string, ref *EntityReference) {
if ref == nil {
return
}
name := strings.TrimSpace(defaultIfEmpty(ref.Name, ref.ID))
if name == "" {
return
}
source := formatReferenceSourceLabel(lang, ref.Source)
if lang == "zh" {
line := fmt.Sprintf("- 当前%s: %s", referenceKindDisplayName(lang, kind), name)
if source != "" {
line += fmt.Sprintf("(来源: %s", source)
}
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
lines = append(lines, line)
return
}
line := fmt.Sprintf("- Current %s: %s", referenceKindDisplayName(lang, kind), name)
if source != "" {
line += fmt.Sprintf(" (source: %s)", source)
}
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
lines = append(lines, line)
}
appendLine("strategy", refs.Strategy)
appendLine("trader", refs.Trader)
appendLine("model", refs.Model)
appendLine("exchange", refs.Exchange)
if len(lines) == 0 {
if lang == "zh" {
return "- 当前没有明确锁定的操作对象。"
}
return "- No current entity references are locked yet."
}
return strings.Join(lines, "\n")
}
func formatReferenceSourceLabel(lang, source string) string {
source = strings.TrimSpace(source)
if source == "" {
return ""
}
if lang == "zh" {
switch source {
case "user_mention":
return "用户提及"
case "tool_output":
return "工具结果"
case "inferred_from_context":
return "上下文推断"
default:
return source
}
}
switch source {
case "user_mention":
return "user mention"
case "tool_output":
return "tool output"
case "inferred_from_context":
return "context inference"
default:
return source
}
}
func hasAnyActiveContext(a *Agent, userID int64) bool {
if a == nil {
return false
}
return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID))
}
func (a *Agent) clearAnyActiveContext(userID int64) bool {
cleared := false
if a.hasActiveSkillSession(userID) {
a.clearSkillSession(userID)
cleared = true
}
if hasActiveWorkflowSession(a.getWorkflowSession(userID)) {
a.clearWorkflowSession(userID)
cleared = true
}
if hasActiveExecutionState(a.getExecutionState(userID)) {
a.clearExecutionState(userID)
cleared = true
}
if cleared {
a.SnapshotManager(userID).Clear()
}
return cleared
}
func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any {
var raw string
switch skill {
case "trader_management":
if strings.HasPrefix(action, "query") {
raw = a.toolListTraders(storeUserID)
}
case "exchange_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetExchangeConfigs(storeUserID)
}
case "model_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetModelConfigs(storeUserID)
}
case "strategy_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetStrategies(storeUserID)
}
}
if strings.TrimSpace(raw) == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil
}
return data
}
func mustMarshalJSON(v any) string {
data, _ := json.Marshal(v)
return string(data)
}
func applyTraderQueryFilter(lang, fallback, raw, filter string) string {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return fallback
}
var payload struct {
Traders []struct {
Name string `json:"name"`
IsRunning bool `json:"is_running"`
} `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallback
}
switch filter {
case "running_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有运行中的交易员。"
}
return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no running traders right now."
}
return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", "))
case "stopped_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if !trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有已停止的交易员。"
}
return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no stopped traders right now."
}
return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", "))
default:
return fallback
}
}