mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +08:00
Refine agent strategy routing and config handling
This commit is contained in:
@@ -9,13 +9,6 @@ import (
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
type llmSkillRouteDecision struct {
|
||||
Intent string `json:"intent,omitempty"`
|
||||
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
|
||||
ContextSwitch bool `json:"context_switch,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
type unifiedTurnDecision struct {
|
||||
TopicIntent string `json:"topic_intent,omitempty"`
|
||||
BusinessAction string `json:"business_action,omitempty"`
|
||||
@@ -43,50 +36,6 @@ func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userI
|
||||
return answer, handled, execErr
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
case "cancel":
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
a.clearActiveSkillSession(userID)
|
||||
a.clearAnyActiveContext(userID)
|
||||
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
case "resume_snapshot":
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) {
|
||||
return a.tryMinimalBrain(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
|
||||
}
|
||||
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
a.clearPendingProposalSession(userID)
|
||||
if a.suspendAndTryRestoreSuspendedTask(userID, lang, text, decision.TargetSnapshotID) {
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
|
||||
@@ -272,6 +221,8 @@ context_mode values:
|
||||
|
||||
Rules:
|
||||
- This router decides what context downstream LLMs will see. Be conservative with stale references.
|
||||
- Treat topic_intent as the primary decision. If the user is naturally responding to the active flow, choose topic_intent="continue_active", business_action="continue_skill", context_mode="use_current"; do not hand off a continuing active flow to planned_agent.
|
||||
- When an active flow has a previous assistant question, proposal, or confirmation request, reason about what the user's message refers to in that context before deciding it is a new task.
|
||||
- If the user clearly switches domain/entity, set topic_intent="start_new" and context_mode="fresh_context".
|
||||
- If the user says "不是交易员,是策略" or similar corrections, use fresh_context.
|
||||
- If the user answers the previous assistant question, choose continue_active.
|
||||
@@ -336,6 +287,14 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri
|
||||
if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) {
|
||||
return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
if activeSession, hasActive := a.getActiveSkillSession(userID); hasActive {
|
||||
decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang)
|
||||
mergeExtractedData(&activeSession, decision.ExtractedData)
|
||||
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
|
||||
}
|
||||
if a.hasAnyActiveContext(userID) {
|
||||
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
}
|
||||
|
||||
switch decision.BusinessAction {
|
||||
@@ -428,146 +387,6 @@ func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string
|
||||
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
|
||||
}
|
||||
|
||||
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)
|
||||
if decision.Confidence < 0 {
|
||||
decision.Confidence = 0
|
||||
}
|
||||
if decision.Confidence > 1 {
|
||||
decision.Confidence = 1
|
||||
}
|
||||
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))
|
||||
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 topic router for NOFXi.
|
||||
Return JSON only.
|
||||
|
||||
Your only job is to decide whether the current user turn continues the current topic/state, starts a new topic, resumes a suspended topic, cancels the current topic, or is a direct conversational reply.
|
||||
Do not perform business intent recognition. Do not choose skills, actions, tasks, or fields. The central brain will do that after you return.
|
||||
|
||||
Valid intents:
|
||||
- "continue_active": the user is still working on the current active flow
|
||||
- "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
|
||||
|
||||
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".
|
||||
- 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":"","context_switch":false,"confidence":0.0}`)
|
||||
|
||||
if strings.TrimSpace(activeSkill.Name) != "" || hasActiveTask || hasPendingProposal {
|
||||
systemPrompt = prependNOFXiAdvisorPreamble(`You are the one-pass topic gateway for NOFXi.
|
||||
Return JSON only.
|
||||
|
||||
Your only job is topic-state routing: continuing the active flow, switching to a new topic, resuming a suspended snapshot, cancelling, or giving a direct conversational reply.
|
||||
Do not perform business intent recognition. Do not choose skills, actions, tasks, or fields. The central brain will do that after you return.
|
||||
|
||||
Rules:
|
||||
- Read the previous assistant reply carefully. The user's short answer may be replying to that exact proposal or question.
|
||||
- If Active flow summary includes a pending hint or waiting question, short replies like "1", "2", "A", "B", "确认", "需要", or "好的" usually mean the user is continuing that flow unless they clearly switch tasks.
|
||||
- Prefer "continue_active" when the user is plausibly answering the current active flow.
|
||||
- If the user asks a read-only management query while an active flow is open, output intent "start_new"; the central brain will choose the query tool.
|
||||
- If the user starts a multi-step, multi-domain, batch, or condition-based management request while an active flow is open, output intent "start_new"; the central brain will decompose it.
|
||||
- If the user clearly corrects the entity/domain, you must output "start_new", not "continue_active".
|
||||
- Examples of forced switch: "不是交易员,是策略", "不是这个", "换个任务", "I mean the strategy, not the trader".
|
||||
- If the user refers to a suspended task and one snapshot clearly matches, use "resume_snapshot".
|
||||
- If the user cancels the current task, use "cancel".
|
||||
- If the user only greets, thanks, chats, or asks for explanation without changing state, use "instant_reply".
|
||||
- Short greetings or acknowledgements like "你好", "hi", "hello", "谢谢", "收到", "好的" should default to "instant_reply" unless they clearly contain task data.
|
||||
- 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":"","context_switch":false,"confidence":0.0}`)
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n",
|
||||
lang,
|
||||
text,
|
||||
defaultIfEmpty(previousAssistantReply, "(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 {
|
||||
|
||||
Reference in New Issue
Block a user