From f3c33b55d74efdda7b77740f0573c9207fb91d01 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Thu, 11 Jun 2026 01:03:08 +0800 Subject: [PATCH] feat(agent): make the agentic loop the primary brain path thinkAndAct/thinkAndActStream now try the native function-calling loop first for fresh conversations; in-flight legacy flows (skill sessions, workflows, execution states, pending proposals) stay on the legacy stack until they finish. NOFX_AGENT_V2=off restores the old routing entirely. --- agent/agentic_loop.go | 17 ++++++++++++++++ agent/agentic_loop_test.go | 41 ++++++++++++++++++++++++++++++++++++++ agent/planner_runtime.go | 12 +++++++++++ 3 files changed, 70 insertions(+) diff --git a/agent/agentic_loop.go b/agent/agentic_loop.go index 3522c044..fa98e3fc 100644 --- a/agent/agentic_loop.go +++ b/agent/agentic_loop.go @@ -32,6 +32,23 @@ func agentV2Enabled() bool { return true } +// shouldUseAgenticTurn reports whether this turn should go through the native +// function-calling loop. In-flight legacy flows (skill sessions, workflows, +// execution states, pending proposals) stay on the legacy stack so they finish +// with the state machine that started them. +func (a *Agent) shouldUseAgenticTurn(userID int64) bool { + if a.aiClient == nil || !agentV2Enabled() { + return false + } + if a.hasAnyActiveContext(userID) { + return false + } + if _, ok := a.getPendingProposalSession(userID); ok { + 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 diff --git a/agent/agentic_loop_test.go b/agent/agentic_loop_test.go index 4a5a4eac..7e9eb138 100644 --- a/agent/agentic_loop_test.go +++ b/agent/agentic_loop_test.go @@ -4,11 +4,13 @@ import ( "context" "errors" "log/slog" + "path/filepath" "strings" "testing" "time" "nofx/mcp" + "nofx/store" ) // scriptedAIClient returns queued LLMResponses (or errors) in order for @@ -250,6 +252,45 @@ func TestRunAgenticTurnIncludesRecentHistory(t *testing.T) { } } +func TestShouldUseAgenticTurn(t *testing.T) { + t.Setenv("NOFX_AGENT_V2", "") + + a := newAgenticTestAgent(&scriptedAIClient{}) + if !a.shouldUseAgenticTurn(10) { + t.Fatal("fresh conversation with AI client should use the agentic turn") + } + + t.Run("disabled by env", func(t *testing.T) { + t.Setenv("NOFX_AGENT_V2", "off") + if a.shouldUseAgenticTurn(10) { + t.Fatal("env kill switch must disable the agentic turn") + } + }) + + t.Run("no AI client", func(t *testing.T) { + noAI := New(nil, nil, DefaultConfig(), slog.Default()) + if noAI.shouldUseAgenticTurn(10) { + t.Fatal("agentic turn requires an AI client") + } + }) + + t.Run("active legacy session stays on legacy stack", func(t *testing.T) { + st, err := store.New(filepath.Join(t.TempDir(), "agentic-guard.db")) + if err != nil { + t.Fatalf("create store: %v", err) + } + b := New(nil, st, DefaultConfig(), slog.Default()) + b.SetAIClient(&scriptedAIClient{}) + if !b.shouldUseAgenticTurn(11) { + t.Fatal("fresh conversation should use the agentic turn") + } + b.saveActiveSkillSession(newActiveSkillSession(11, "strategy_management", "create")) + if b.shouldUseAgenticTurn(11) { + t.Fatal("active skill session must stay on the legacy stack") + } + }) +} + func TestAgentV2Enabled(t *testing.T) { cases := []struct { value string diff --git a/agent/planner_runtime.go b/agent/planner_runtime.go index 43786fea..265fa57a 100644 --- a/agent/planner_runtime.go +++ b/agent/planner_runtime.go @@ -821,6 +821,12 @@ func (a *Agent) thinkAndAct(ctx context.Context, storeUserID string, userID int6 lock := a.flowLock(userID) lock.Lock() defer lock.Unlock() + if a.shouldUseAgenticTurn(userID) { + if answer, ok, err := a.runAgenticTurn(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { + return a.maybeAppendResumePrompt(userID, lang, text, answer), err + } + // Not handled — fall through to the legacy routing stack. + } if a.aiClient != nil { if answer, ok, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, nil); ok || err != nil { return a.maybeAppendResumePrompt(userID, lang, text, answer), err @@ -852,6 +858,12 @@ func (a *Agent) thinkAndActStream(ctx context.Context, storeUserID string, userI lock := a.flowLock(userID) lock.Lock() defer lock.Unlock() + if a.shouldUseAgenticTurn(userID) { + if answer, ok, err := a.runAgenticTurn(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { + return a.maybeAppendResumePrompt(userID, lang, text, answer), err + } + // Not handled — fall through to the legacy routing stack. + } if a.aiClient != nil { if answer, ok, err := a.tryLLMIntentRoute(ctx, storeUserID, userID, lang, text, onEvent); ok || err != nil { answer = a.maybeAppendResumePrompt(userID, lang, text, answer)