mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-29 01:25:03 -04:00
LocalAGI 7e0947d added allowed_tools/excluded_tools and the required_tool_before_finish gate. Single-node agents get them through LocalAGI's runtime, but the distributed executor drives cogito directly and its static config meta did not list the fields, so the agent form hid them and the worker ignored them. The distributed config now parses the tool lists from a JSON array or a comma/newline separated string, and the meta entries match LocalAGI's. The executor filters the knowledge base, skill and MCP tools (MCP via cogito.WithMCPToolFilter) before the model sees them, and re-prompts the model when it answers before the required tool returned "ok": true, up to the configured number of reminders. LocalAGI keeps its filter and gate helpers unexported, so a minimal copy lives in core/services/agents/toolpolicy.go. A spec compares the meta entries with LocalAGI's to catch drift. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5-5 [Claude Code]
457 lines
16 KiB
Go
457 lines
16 KiB
Go
package agents
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/cogito"
|
|
"github.com/mudler/cogito/clients"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// Callbacks defines the event hooks for agent execution.
|
|
// The same executor uses different callback implementations
|
|
// for local (SSE manager) vs distributed (NATS EventBridge) modes.
|
|
type Callbacks struct {
|
|
// OnStream receives raw streaming events from cogito (reasoning tokens, content tokens, tool calls).
|
|
OnStream func(cogito.StreamEvent)
|
|
// OnReasoning receives the complete parsed reasoning text after the LLM call.
|
|
OnReasoning func(text string)
|
|
// OnToolCall receives tool selection decisions (tool name + arguments).
|
|
OnToolCall func(name, args string)
|
|
// OnToolResult receives tool execution results.
|
|
OnToolResult func(name, result string)
|
|
// OnStatus receives status updates ("processing", "completed", "error: ...").
|
|
OnStatus func(status string)
|
|
// OnMessage receives final messages (sender="user"/"agent", content, messageID).
|
|
OnMessage func(sender, content, messageID string)
|
|
}
|
|
|
|
// DefaultInnerMonologueTemplate is the prompt used for autonomous/background runs
|
|
// when the agent evaluates what to do next based on its permanent goal.
|
|
const DefaultInnerMonologueTemplate = `You are an autonomous agent. Your permanent goal is: {{.Goal}}
|
|
|
|
Evaluate the current situation and decide what action to take next.
|
|
Consider what you have done so far and what remains to be done.
|
|
If there is nothing to do, respond with a brief status update.`
|
|
|
|
// ExecuteChatOpts holds optional dependencies for agent execution.
|
|
type ExecuteChatOpts struct {
|
|
SkillProvider SkillProvider // optional: provides skills for injection
|
|
APIURL string // resolved API URL for KB and memory operations
|
|
APIKey string // resolved API key for KB and memory operations
|
|
UserID string // owner user ID — passed in collection API calls so per-user scoping works in distributed mode
|
|
MessageID string // original message ID from the dispatch — used to correlate SSE responses with the originating request
|
|
}
|
|
|
|
// ExecuteBackgroundRun runs an autonomous/background agent execution.
|
|
// This is the equivalent of LocalAGI's periodicallyRun — the agent evaluates
|
|
// its permanent goal using the inner monologue template and decides what to do.
|
|
func ExecuteBackgroundRun(ctx context.Context, apiURL, apiKey string, cfg *AgentConfig, cb Callbacks, opts ...ExecuteChatOpts) (string, error) {
|
|
// Build the inner monologue message
|
|
monologue := cmp.Or(cfg.InnerMonologueTemplate, DefaultInnerMonologueTemplate)
|
|
|
|
// Simple template substitution for {{.Goal}}
|
|
message := strings.ReplaceAll(monologue, "{{.Goal}}", cfg.PermanentGoal)
|
|
|
|
return ExecuteChat(ctx, apiURL, apiKey, cfg, message, cb, opts...)
|
|
}
|
|
|
|
// ExecuteBackgroundRunWithLLM is like ExecuteBackgroundRun but accepts a pre-built LLM client.
|
|
// This enables testing background agent execution with mock LLMs.
|
|
func ExecuteBackgroundRunWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, cb Callbacks, opts ...ExecuteChatOpts) (string, error) {
|
|
monologue := cmp.Or(cfg.InnerMonologueTemplate, DefaultInnerMonologueTemplate)
|
|
message := strings.ReplaceAll(monologue, "{{.Goal}}", cfg.PermanentGoal)
|
|
return ExecuteChatWithLLM(ctx, llm, cfg, message, cb, opts...)
|
|
}
|
|
|
|
// ExecuteChat runs a single agent chat interaction using cogito.
|
|
// It is stateless — config and conversation come from the caller,
|
|
// results are delivered via callbacks.
|
|
|
|
func ExecuteChat(ctx context.Context, apiURL, apiKey string, cfg *AgentConfig, message string, cb Callbacks, opts ...ExecuteChatOpts) (string, error) {
|
|
if cfg.Model == "" {
|
|
return "", fmt.Errorf("agent has no model configured")
|
|
}
|
|
|
|
// Resolve API endpoint and key.
|
|
// Agent config overrides if set (user may point to external OpenAI, etc.)
|
|
// Otherwise fall back to worker defaults.
|
|
effectiveURL := apiURL
|
|
if cfg.APIURL != "" {
|
|
effectiveURL = cfg.APIURL
|
|
}
|
|
effectiveKey := apiKey
|
|
if cfg.APIKey != "" {
|
|
effectiveKey = cfg.APIKey
|
|
}
|
|
endpoint := effectiveURL + "/v1"
|
|
llm := clients.NewLocalAILLM(cfg.Model, effectiveKey, endpoint)
|
|
|
|
var o ExecuteChatOpts
|
|
if len(opts) > 0 {
|
|
o = opts[0]
|
|
}
|
|
o.APIURL = effectiveURL
|
|
o.APIKey = effectiveKey
|
|
return ExecuteChatWithLLM(ctx, llm, cfg, message, cb, o)
|
|
}
|
|
|
|
// ExecuteChatWithLLM is like ExecuteChat but accepts a pre-built LLM client.
|
|
// This enables testing with mock LLMs.
|
|
func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, message string, cb Callbacks, opts ...ExecuteChatOpts) (string, error) {
|
|
var skillProvider SkillProvider
|
|
var effectiveURL, effectiveKey, userID string
|
|
if len(opts) > 0 {
|
|
skillProvider = opts[0].SkillProvider
|
|
effectiveURL = opts[0].APIURL
|
|
effectiveKey = opts[0].APIKey
|
|
userID = opts[0].UserID
|
|
}
|
|
// Notify processing
|
|
if cb.OnStatus != nil {
|
|
cb.OnStatus("processing")
|
|
}
|
|
|
|
// Build conversation fragment
|
|
fragment := cogito.NewEmptyFragment()
|
|
|
|
// System prompt
|
|
systemPrompt := cmp.Or(cfg.SystemPrompt, "You are a helpful assistant.")
|
|
|
|
// Inject skills (if enabled)
|
|
if cfg.EnableSkills && skillProvider != nil {
|
|
skillsMode := cmp.Or(cfg.SkillsMode, SkillsModePrompt)
|
|
|
|
allSkills, err := skillProvider.ListSkills()
|
|
if err == nil && len(allSkills) > 0 {
|
|
filtered := FilterSkills(allSkills, cfg.SelectedSkills)
|
|
|
|
// Prompt mode: inject full skill content into system prompt
|
|
if skillsMode == SkillsModePrompt || skillsMode == SkillsModeBoth {
|
|
if skillsText := RenderSkillsPrompt(filtered, cfg.SkillsPrompt); skillsText != "" {
|
|
systemPrompt += "\n\n" + skillsText
|
|
}
|
|
}
|
|
|
|
// Tools mode: add a hint so the agent knows to use request_skill
|
|
if skillsMode == SkillsModeTools {
|
|
systemPrompt += "\n\n" + SkillsToolsHint
|
|
}
|
|
|
|
// Tools mode: skills will be added as cogito tools after fragment is built
|
|
// (handled below with cogitoOpts)
|
|
_ = filtered // used below
|
|
}
|
|
}
|
|
|
|
fragment = fragment.AddMessage(cogito.SystemMessageRole, systemPrompt)
|
|
|
|
// KB auto-search: query the knowledge base with the user's message
|
|
// and prepend results to the conversation as context.
|
|
// Resolve KB mode: prefer KBMode field, fall back to legacy booleans.
|
|
kbMode := cfg.KBMode
|
|
if kbMode == "" {
|
|
// Legacy fallback
|
|
switch {
|
|
case cfg.KBAutoSearch && cfg.KBAsTools:
|
|
kbMode = KBModeBoth
|
|
case cfg.KBAsTools:
|
|
kbMode = KBModeTools
|
|
default:
|
|
kbMode = KBModeAutoSearch
|
|
}
|
|
}
|
|
|
|
kbCitations := &kbCitationList{}
|
|
if cfg.EnableKnowledgeBase && (kbMode == KBModeAutoSearch || kbMode == KBModeBoth) {
|
|
kbResult := KBAutoSearchPrompt(ctx, effectiveURL, effectiveKey, cfg.Name, message, cfg.KnowledgeBaseResults, userID)
|
|
if kbResult.Prompt != "" {
|
|
fragment = fragment.AddMessage(cogito.SystemMessageRole, kbResult.Prompt)
|
|
kbCitations.AddKBCitations(kbResult.Citations)
|
|
}
|
|
}
|
|
|
|
// User message
|
|
fragment = fragment.AddMessage(cogito.UserMessageRole, message)
|
|
|
|
// Build cogito options
|
|
var cogitoOpts []cogito.Option
|
|
// Local tools are collected first so the tool filter applies to all of
|
|
// them at once; cogito only runs tools it offered, so filtering what is
|
|
// offered also filters the lookup of the model's tool calls.
|
|
var localTools []cogito.ToolDefinitionInterface
|
|
filter := newToolFilter(cfg.AllowedTools, cfg.ExcludedTools)
|
|
|
|
// MCP sessions
|
|
sessions, cleanup := setupMCPSessions(ctx, cfg)
|
|
if cleanup != nil {
|
|
defer cleanup()
|
|
}
|
|
if len(sessions) > 0 {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithMCPs(sessions...), cogito.WithMCPToolFilter(filter.mcpToolFilter()))
|
|
}
|
|
|
|
// KB tools (search_memory / add_memory) — when kb mode is "tools" or "both"
|
|
if cfg.EnableKnowledgeBase && (kbMode == KBModeTools || kbMode == KBModeBoth) {
|
|
kbResults := cfg.KnowledgeBaseResults
|
|
if kbResults <= 0 {
|
|
kbResults = 5
|
|
}
|
|
localTools = append(localTools,
|
|
cogito.NewToolDefinition(
|
|
KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID, CitationCollector: kbCitations},
|
|
KBSearchMemoryArgs{},
|
|
"search_memory",
|
|
"Search the knowledge base for relevant information",
|
|
),
|
|
cogito.NewToolDefinition(
|
|
KBAddMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, UserID: userID},
|
|
KBAddMemoryArgs{},
|
|
"add_memory",
|
|
"Store content in memory for later retrieval",
|
|
),
|
|
)
|
|
}
|
|
|
|
// Skill tools — when skills_mode is "tools" or "both"
|
|
if cfg.EnableSkills && skillProvider != nil {
|
|
skillsMode := cmp.Or(cfg.SkillsMode, SkillsModePrompt)
|
|
if skillsMode == SkillsModeTools || skillsMode == SkillsModeBoth {
|
|
allSkills, _ := skillProvider.ListSkills()
|
|
filtered := FilterSkills(allSkills, cfg.SelectedSkills)
|
|
if len(filtered) > 0 {
|
|
localTools = append(localTools,
|
|
cogito.NewToolDefinition(
|
|
RequestSkillTool{Skills: filtered},
|
|
RequestSkillArgs{},
|
|
"request_skill",
|
|
"Request a skill by name. Available skills: "+skillNames(filtered),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
localTools = filter.filterTools(localTools)
|
|
if len(localTools) > 0 {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithTools(localTools...))
|
|
}
|
|
|
|
// Required-tool gate: the agent must run the configured tool to success
|
|
// before its answer is final. It is enforced on the output because a
|
|
// model follows "always call X first" unreliably.
|
|
requiredTool := cfg.RequiredToolBeforeFinish
|
|
requiredPassed := false
|
|
requiredAttempts := 0
|
|
maxRequiredAttempts := cfg.RequiredToolBeforeFinishAttempts
|
|
if maxRequiredAttempts <= 0 {
|
|
maxRequiredAttempts = defaultRequiredFinishAttempts
|
|
}
|
|
requiredPrompt := requiredFinishPromptFor(requiredTool, cfg.RequiredToolBeforeFinishPrompt)
|
|
requiredAvailable := requiredTool != "" && requiredToolAvailable(ctx, requiredTool, localTools, sessions, filter)
|
|
|
|
// Sink state is always disabled — the agent responds directly when no tools match.
|
|
cogitoOpts = append(cogitoOpts, cogito.DisableSinkState)
|
|
|
|
// Stream callback
|
|
if cb.OnStream != nil {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithStreamCallback(cb.OnStream))
|
|
}
|
|
|
|
// Reasoning callback
|
|
if cb.OnReasoning != nil {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithReasoningCallback(func(s string) {
|
|
if s != "" {
|
|
cb.OnReasoning(s)
|
|
}
|
|
}))
|
|
}
|
|
|
|
// Tool call result callback
|
|
if cb.OnToolResult != nil || cb.OnToolCall != nil || requiredAvailable {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithToolCallResultCallback(func(t cogito.ToolStatus) {
|
|
if requiredAvailable && t.Name == requiredTool && requiredToolResultOK(t.Result) {
|
|
requiredPassed = true
|
|
}
|
|
if isInternalCogitoTool(t.Name) {
|
|
return
|
|
}
|
|
if cb.OnToolCall != nil {
|
|
argsJSON, _ := json.Marshal(t.ToolArguments.Arguments)
|
|
cb.OnToolCall(t.Name, string(argsJSON))
|
|
}
|
|
if cb.OnToolResult != nil && t.Result != "" {
|
|
cb.OnToolResult(t.Name, t.Result)
|
|
}
|
|
}))
|
|
}
|
|
|
|
// Tool call decision callback — forward tool selection as stream event
|
|
if cb.OnStream != nil {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithToolCallBack(
|
|
func(tc *cogito.ToolChoice, _ *cogito.SessionState) cogito.ToolCallDecision {
|
|
if !isInternalCogitoTool(tc.Name) {
|
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
|
cb.OnStream(cogito.StreamEvent{
|
|
Type: cogito.StreamEventToolCall,
|
|
ToolName: tc.Name,
|
|
ToolArgs: string(argsJSON),
|
|
})
|
|
}
|
|
return cogito.ToolCallDecision{Approved: true}
|
|
},
|
|
))
|
|
}
|
|
|
|
// Iteration limits
|
|
if cfg.MaxAttempts > 1 {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithMaxAttempts(cfg.MaxAttempts))
|
|
cogitoOpts = append(cogitoOpts, cogito.WithMaxRetries(cfg.MaxAttempts))
|
|
}
|
|
|
|
// Auto compaction
|
|
if cfg.EnableAutoCompaction {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithCompactionThreshold(cfg.AutoCompactionThreshold))
|
|
}
|
|
|
|
// Loop detection
|
|
if cfg.LoopDetection > 0 {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithLoopDetection(cfg.LoopDetection))
|
|
}
|
|
|
|
// Reasoning for instruct models (enables both ForceReasoning + ForceReasoningTool)
|
|
if cfg.EnableReasoningForInstruct {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithForceReasoning())
|
|
cogitoOpts = append(cogitoOpts, cogito.WithForceReasoningTool())
|
|
}
|
|
|
|
// Guided tools
|
|
if cfg.EnableGuidedTools {
|
|
cogitoOpts = append(cogitoOpts, cogito.EnableGuidedTools)
|
|
}
|
|
|
|
// Max iterations (tool loop iterations)
|
|
if cfg.MaxIterations > 0 {
|
|
cogitoOpts = append(cogitoOpts, cogito.WithIterations(cfg.MaxIterations))
|
|
}
|
|
|
|
// Context propagation (enables cancellation of cogito execution)
|
|
cogitoOpts = append(cogitoOpts, cogito.WithContext(ctx))
|
|
|
|
// Execute
|
|
xlog.Info("Executing agent chat", "agent", cfg.Name, "model", cfg.Model)
|
|
result, err := cogito.ExecuteTools(llm, fragment, cogitoOpts...)
|
|
if err != nil {
|
|
if cb.OnStatus != nil {
|
|
cb.OnStatus("error: " + err.Error())
|
|
}
|
|
return "", fmt.Errorf("agent execution failed: %w", err)
|
|
}
|
|
|
|
for len(result.Messages) > 0 && textFinalizationNeedsRequiredTool(requiredAvailable, requiredPassed,
|
|
requiredAttempts, maxRequiredAttempts, result.LastMessage().Role, result.LastMessage().Content) {
|
|
requiredAttempts++
|
|
xlog.Info("required-tool gate: answer without the required tool, nudging",
|
|
"agent", cfg.Name, "tool", requiredTool, "attempt", requiredAttempts)
|
|
answered := result
|
|
next, err := cogito.ExecuteTools(llm, result.AddMessage(cogito.UserMessageRole, requiredPrompt), cogitoOpts...)
|
|
if err != nil && ctx.Err() != nil {
|
|
if cb.OnStatus != nil {
|
|
cb.OnStatus("error: " + err.Error())
|
|
}
|
|
return "", fmt.Errorf("agent execution failed: %w", err)
|
|
}
|
|
// A failed retry must not throw away the answer the model already gave.
|
|
if err != nil && !errors.Is(err, cogito.ErrNoToolSelected) {
|
|
xlog.Error("required-tool gate: retry failed, keeping the previous answer", "agent", cfg.Name, "error", err)
|
|
result = answered
|
|
break
|
|
}
|
|
result = next
|
|
}
|
|
if requiredAvailable && !requiredPassed && requiredAttempts >= maxRequiredAttempts {
|
|
xlog.Warn("required-tool gate: bypass after max attempts, answer finalized ungated",
|
|
"agent", cfg.Name, "tool", requiredTool)
|
|
}
|
|
|
|
// Extract response
|
|
response := ""
|
|
if len(result.Messages) > 0 {
|
|
last := result.Messages[len(result.Messages)-1]
|
|
response = last.Content
|
|
}
|
|
|
|
// Strip thinking tags if configured
|
|
if cfg.StripThinkingTags && response != "" {
|
|
response = stripThinkingTags(response)
|
|
}
|
|
responseForMemory := response
|
|
response = AppendKBCitations(response, cfg.Name, userID, kbCitations.Citations())
|
|
|
|
// Save conversation to KB when long-term memory is enabled.
|
|
// Use a detached context: the parent ctx may be cancelled (e.g. in distributed
|
|
// mode handleJob defers cancel()) before this goroutine completes.
|
|
if cfg.LongTermMemory && effectiveURL != "" {
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
saveConversationToKB(ctx, llm, effectiveURL, effectiveKey, cfg, message, responseForMemory, userID)
|
|
}()
|
|
}
|
|
|
|
// Publish agent response — use original messageID from the dispatch so the
|
|
// frontend can correlate this response with the conversation that sent it.
|
|
var respMsgID string
|
|
if len(opts) > 0 && opts[0].MessageID != "" {
|
|
respMsgID = opts[0].MessageID + "-agent"
|
|
} else {
|
|
respMsgID = fmt.Sprintf("%d-agent", time.Now().UnixNano())
|
|
}
|
|
if cb.OnMessage != nil {
|
|
cb.OnMessage("agent", response, respMsgID)
|
|
}
|
|
|
|
// Mark completed
|
|
if cb.OnStatus != nil {
|
|
cb.OnStatus("completed")
|
|
}
|
|
|
|
xlog.Info("Agent chat completed", "agent", cfg.Name, "responseLen", len(response))
|
|
return response, nil
|
|
}
|
|
|
|
// isInternalCogitoTool returns true for tool names used internally by cogito's
|
|
// force-reasoning pipeline (reasoning, pick_tool, pick_tools, reply).
|
|
// These should be filtered from user-facing stream events.
|
|
func isInternalCogitoTool(name string) bool {
|
|
switch name {
|
|
case "reasoning", "pick_tool", "pick_tools", "reply":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stripThinkingTags removes <thinking>...</thinking> blocks from content.
|
|
func stripThinkingTags(content string) string {
|
|
// Simple implementation — remove content between <thinking> and </thinking>
|
|
result := content
|
|
for {
|
|
start := strings.Index(result, "<thinking>")
|
|
if start == -1 {
|
|
break
|
|
}
|
|
end := strings.Index(result[start:], "</thinking>")
|
|
if end == -1 {
|
|
break
|
|
}
|
|
result = result[:start] + result[start+end+len("</thinking>"):]
|
|
}
|
|
return result
|
|
}
|