Files
nofx/agent/agentic_loop.go
tinkle-community 785922697b feat(agent): add native function-calling agentic loop as the new brain core
One standard tool-use loop replaces the need for layered JSON routing:
the LLM sees all 22 tools plus real multi-turn history, every tool result
(including errors) returns to the loop as an observation, and the final
user-facing reply is always LLM-written. Interruptions report exactly
which tools already executed so side effects are never silently lost or
repeated by fallback paths. Gated by NOFX_AGENT_V2 (default on).
2026-06-11 01:00:41 +08:00

194 lines
7.2 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"
"fmt"
"os"
"strings"
"nofx/mcp"
)
const (
// agenticMaxToolRounds bounds the number of LLM round-trips in one user
// turn. Each round may execute several tool calls, so this comfortably
// covers chained operations (create → configure → start) while still
// terminating runaway loops.
agenticMaxToolRounds = 12
// agenticHistoryMessages is the number of recent history messages replayed
// to the LLM as real conversation turns.
agenticHistoryMessages = 12
)
// agentV2Enabled reports whether the native function-calling loop is the
// primary brain. Enabled by default; set NOFX_AGENT_V2=0/false/off/disabled
// to fall back to the legacy routing stack.
func agentV2Enabled() bool {
switch strings.TrimSpace(strings.ToLower(os.Getenv("NOFX_AGENT_V2"))) {
case "0", "false", "off", "disabled":
return false
}
return true
}
// runAgenticTurn drives one user turn through a native function-calling loop:
// the LLM sees the full toolset plus recent conversation, decides which tools
// to call, receives every tool result (including errors) as observations, and
// writes the final user-facing reply itself.
//
// Returns handled=false when nothing user-visible happened and the caller
// should fall back to the legacy routing stack (e.g. the very first LLM call
// failed). Once any tool has executed, the turn is always handled so side
// effects are never silently repeated by a fallback path.
func (a *Agent) runAgenticTurn(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
}
messages := []mcp.Message{mcp.NewSystemMessage(a.buildSystemPromptForStoreUser(lang, storeUserID))}
if prefs := a.buildPersistentPreferencesContext(userID); prefs != "" {
messages = append(messages, mcp.NewSystemMessage(prefs))
}
if taskCtx := buildTaskStateContext(a.getTaskState(userID)); taskCtx != "" {
messages = append(messages, mcp.NewSystemMessage(taskCtx))
}
messages = append(messages, a.recentHistoryMessages(userID, text)...)
messages = append(messages, mcp.NewUserMessage(text))
tools := agentTools()
var executedTools []string
for round := 0; round < agenticMaxToolRounds; round++ {
resp, err := a.aiClient.CallWithRequestFull(&mcp.Request{
Messages: messages,
Tools: tools,
ToolChoice: "auto",
Ctx: ctx,
})
if err != nil {
a.logger.Warn("agentic turn LLM call failed", "error", err, "user_id", userID, "round", round)
if len(executedTools) == 0 {
// Nothing happened yet — safe to let the legacy stack retry.
return "", false, nil
}
reply := agenticInterruptedReply(lang, executedTools)
return a.finishAgenticTurn(userID, lang, text, reply, onEvent), true, nil
}
if len(resp.ToolCalls) == 0 {
reply := strings.TrimSpace(resp.Content)
if reply == "" {
if len(executedTools) == 0 {
return "", false, nil
}
reply = agenticInterruptedReply(lang, executedTools)
}
return a.finishAgenticTurn(userID, lang, text, reply, onEvent), true, nil
}
assistantMsg := mcp.Message{Role: "assistant", ToolCalls: resp.ToolCalls}
if resp.Content != "" {
assistantMsg.Content = resp.Content
}
if resp.ReasoningContent != "" {
assistantMsg.ReasoningContent = resp.ReasoningContent
}
messages = append(messages, assistantMsg)
for _, tc := range resp.ToolCalls {
if onEvent != nil {
onEvent(StreamEventTool, tc.Function.Name)
}
executedTools = append(executedTools, tc.Function.Name)
result := a.handleToolCall(ctx, storeUserID, userID, lang, tc)
messages = append(messages, mcp.Message{
Role: "tool",
Content: result,
ToolCallID: tc.ID,
})
}
}
// Round budget exhausted: ask the LLM to wrap up with what it has, without
// offering further tools.
messages = append(messages, mcp.NewSystemMessage(agenticWrapUpInstruction(lang)))
final, err := a.aiClient.CallWithRequest(&mcp.Request{Messages: messages, Ctx: ctx})
if err != nil || strings.TrimSpace(final) == "" {
if err != nil {
a.logger.Warn("agentic wrap-up call failed", "error", err, "user_id", userID)
}
final = agenticInterruptedReply(lang, executedTools)
}
return a.finishAgenticTurn(userID, lang, text, final, onEvent), true, nil
}
// finishAgenticTurn applies final-reply guards, records the turn in history,
// and streams the reply.
func (a *Agent) finishAgenticTurn(userID int64, lang, text, reply string, onEvent func(event, data string)) string {
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
reply = guarded
}
if a.history != nil {
a.history.Add(userID, "user", text)
a.history.Add(userID, "assistant", reply)
}
emitStreamText(onEvent, reply)
return reply
}
// recentHistoryMessages replays recent conversation turns as real chat
// messages so the LLM has multi-turn context, dropping a trailing duplicate of
// the current user text if the caller already recorded it.
func (a *Agent) recentHistoryMessages(userID int64, currentText string) []mcp.Message {
if a.history == nil {
return nil
}
msgs := a.history.Get(userID)
if n := len(msgs); n > 0 && msgs[n-1].Role == "user" &&
strings.TrimSpace(msgs[n-1].Content) == strings.TrimSpace(currentText) {
msgs = msgs[:n-1]
}
if len(msgs) > agenticHistoryMessages {
msgs = msgs[len(msgs)-agenticHistoryMessages:]
}
out := make([]mcp.Message, 0, len(msgs))
for _, m := range msgs {
content := strings.TrimSpace(m.Content)
if content == "" {
continue
}
switch m.Role {
case "user":
out = append(out, mcp.NewUserMessage(content))
case "assistant":
out = append(out, mcp.Message{Role: "assistant", Content: content})
}
}
return out
}
// agenticInterruptedReply tells the user exactly which tools already ran when
// a turn cannot produce an LLM-written reply, so work is never silently lost.
func agenticInterruptedReply(lang string, executedTools []string) string {
tools := strings.Join(executedTools, ", ")
if lang == "zh" {
if tools == "" {
return "刚才处理你的请求时 AI 服务中断了,已执行的操作没有丢失。请再说一次你想做什么,我接着处理。"
}
return fmt.Sprintf("处理过程中 AI 服务中断了。已执行的操作:%s。这些结果已生效你可以让我继续下一步或查询当前状态。", tools)
}
if tools == "" {
return "The AI service was interrupted while handling your request. Nothing was lost — please tell me again what you'd like to do."
}
return fmt.Sprintf("The AI service was interrupted mid-task. Tools already executed: %s. Those results took effect — ask me to continue or check the current state.", tools)
}
// agenticWrapUpInstruction is appended when the tool-round budget is spent.
func agenticWrapUpInstruction(lang string) string {
if lang == "zh" {
return "工具调用轮次已达上限。请基于以上已获得的全部结果,直接给用户一个完整的中文总结回复:说明已完成什么、未完成什么、建议的下一步。不要再请求调用工具。"
}
return "Tool-call round limit reached. Using everything gathered above, write the final reply for the user now: what was completed, what was not, and the suggested next step. Do not request more tool calls."
}