Refine agent strategy routing and config handling

This commit is contained in:
lky-spec
2026-04-28 19:37:44 +08:00
parent fc6c42ac11
commit 2d45e7ab15
22 changed files with 1321 additions and 968 deletions

View File

@@ -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 {