From 84e2fc5eacb0154169bc80d253f14519c397ec22 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 27 Sep 2026 20:49:12 +0000 Subject: [PATCH] feat(agents): support tool lists and required tool in distributed mode 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 Assisted-by: Claude:claude-opus-5-5 [Claude Code] --- core/services/agents/config.go | 7 + core/services/agents/configmeta.go | 20 ++ core/services/agents/executor.go | 65 +++- core/services/agents/toolpolicy.go | 220 ++++++++++++++ core/services/agents/toolpolicy_test.go | 387 ++++++++++++++++++++++++ docs/content/features/agents.md | 2 - 6 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 core/services/agents/toolpolicy.go create mode 100644 core/services/agents/toolpolicy_test.go diff --git a/core/services/agents/config.go b/core/services/agents/config.go index 0016cf51e..875eedd36 100644 --- a/core/services/agents/config.go +++ b/core/services/agents/config.go @@ -107,6 +107,13 @@ type AgentConfig struct { LoopDetection int `json:"loop_detection"` EnableAutoCompaction bool `json:"enable_auto_compaction"` AutoCompactionThreshold int `json:"auto_compaction_threshold"` + + // Tool policy (see toolpolicy.go) + RequiredToolBeforeFinish string `json:"required_tool_before_finish"` + RequiredToolBeforeFinishPrompt string `json:"required_tool_before_finish_prompt"` + RequiredToolBeforeFinishAttempts int `json:"required_tool_before_finish_attempts"` + AllowedTools ToolNames `json:"allowed_tools"` + ExcludedTools ToolNames `json:"excluded_tools"` } // ConnectorConfig defines a connector integration (Slack, Discord, etc.). diff --git a/core/services/agents/configmeta.go b/core/services/agents/configmeta.go index d767aaff8..9f36ed1a9 100644 --- a/core/services/agents/configmeta.go +++ b/core/services/agents/configmeta.go @@ -134,6 +134,14 @@ func defaultFields() []ConfigField { {Name: "enable_reasoning_tool", Label: "Enable Reasoning for Tools", Type: FieldCheckbox, DefaultValue: true, Tags: ConfigFieldTags{Section: "AdvancedSettings"}}, {Name: "enable_reasoning_for_instruct", Label: "Enable Reasoning for Instruct Models", Type: FieldCheckbox, DefaultValue: false, HelpText: "Force structured reasoning before tool selection (recommended for instruct-tuned models)", Tags: ConfigFieldTags{Section: "AdvancedSettings"}}, {Name: "enable_guided_tools", Label: "Enable Guided Tools", Type: FieldCheckbox, DefaultValue: false, HelpText: "Filter tools through guidance using descriptions", Tags: ConfigFieldTags{Section: "AdvancedSettings"}}, + {Name: "allowed_tools", Label: "Allowed Tools", Type: FieldTextarea, DefaultValue: "", Placeholder: "get_document_content, search", + HelpText: "Comma or newline separated tool names. When set, the agent is offered only these tools (actions, knowledge base tools and MCP tools). send_message, stop and update_state are always kept. Leave empty to offer every tool.", + Tags: ConfigFieldTags{Section: "AdvancedSettings"}, + }, + {Name: "excluded_tools", Label: "Excluded Tools", Type: FieldTextarea, DefaultValue: "", Placeholder: "search_memory", + HelpText: "Comma or newline separated tool names that are never offered to the agent, even if they are in Allowed Tools. send_message, stop and update_state cannot be excluded; use their own settings instead.", + Tags: ConfigFieldTags{Section: "AdvancedSettings"}, + }, {Name: "enable_skills", Label: "Enable Skills", Type: FieldCheckbox, DefaultValue: false, HelpText: "Inject skills into the agent", Tags: ConfigFieldTags{Section: "AdvancedSettings"}}, {Name: "skills_mode", Label: "Skills Injection Mode", Type: FieldSelect, DefaultValue: "prompt", Options: []ConfigFieldOption{ @@ -146,6 +154,18 @@ func defaultFields() []ConfigField { }, {Name: "parallel_jobs", Label: "Parallel Jobs", Type: FieldNumber, DefaultValue: 5, Min: 1, Step: 1, Tags: ConfigFieldTags{Section: "AdvancedSettings"}}, {Name: "max_attempts", Label: "Max Attempts", Type: FieldNumber, DefaultValue: 2, Min: 1, Step: 1, Tags: ConfigFieldTags{Section: "AdvancedSettings"}}, + {Name: "required_tool_before_finish", Label: "Required Tool Before Finish", Type: FieldText, DefaultValue: "", Placeholder: "check_policy", + HelpText: "Name of a tool the agent must call successfully (a JSON result with \"ok\": true) before it may send its final answer. Has no effect if the agent does not have this tool. Leave empty to disable.", + Tags: ConfigFieldTags{Section: "AdvancedSettings"}, + }, + {Name: "required_tool_before_finish_prompt", Label: "Required Tool Prompt", Type: FieldTextarea, DefaultValue: "", + HelpText: "Instruction sent to the model when it tries to finish before the required tool has passed. Leave empty to use a default that names the tool.", + Tags: ConfigFieldTags{Section: "AdvancedSettings"}, + }, + {Name: "required_tool_before_finish_attempts", Label: "Required Tool Attempts", Type: FieldNumber, DefaultValue: 3, Min: 1, Step: 1, + HelpText: "How many times the model is told to run the required tool before its answer is sent anyway", + Tags: ConfigFieldTags{Section: "AdvancedSettings"}, + }, {Name: "max_iterations", Label: "Max Iterations", Type: FieldNumber, DefaultValue: 1, Min: 1, Step: 1, HelpText: "Maximum tool loop iterations per execution", Tags: ConfigFieldTags{Section: "AdvancedSettings"}}, // MCP diff --git a/core/services/agents/executor.go b/core/services/agents/executor.go index 2787aeabc..9fa3c336e 100644 --- a/core/services/agents/executor.go +++ b/core/services/agents/executor.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "encoding/json" + "errors" "fmt" "strings" "time" @@ -181,6 +182,11 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m // 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) @@ -188,7 +194,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m defer cleanup() } if len(sessions) > 0 { - cogitoOpts = append(cogitoOpts, cogito.WithMCPs(sessions...)) + cogitoOpts = append(cogitoOpts, cogito.WithMCPs(sessions...), cogito.WithMCPToolFilter(filter.mcpToolFilter())) } // KB tools (search_memory / add_memory) — when kb mode is "tools" or "both" @@ -197,7 +203,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m if kbResults <= 0 { kbResults = 5 } - cogitoOpts = append(cogitoOpts, cogito.WithTools( + localTools = append(localTools, cogito.NewToolDefinition( KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID, CitationCollector: kbCitations}, KBSearchMemoryArgs{}, @@ -210,7 +216,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m "add_memory", "Store content in memory for later retrieval", ), - )) + ) } // Skill tools — when skills_mode is "tools" or "both" @@ -220,18 +226,36 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m allSkills, _ := skillProvider.ListSkills() filtered := FilterSkills(allSkills, cfg.SelectedSkills) if len(filtered) > 0 { - cogitoOpts = append(cogitoOpts, cogito.WithTools( + 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) @@ -250,8 +274,11 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m } // Tool call result callback - if cb.OnToolResult != nil || cb.OnToolCall != nil { + 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 } @@ -327,6 +354,32 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m 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 { diff --git a/core/services/agents/toolpolicy.go b/core/services/agents/toolpolicy.go new file mode 100644 index 000000000..cb5d97cdd --- /dev/null +++ b/core/services/agents/toolpolicy.go @@ -0,0 +1,220 @@ +package agents + +// Tool policy for the distributed executor: the allowed/excluded tool lists and +// the required-tool-before-finish gate. The semantics mirror LocalAGI's +// core/agent/toolfilter.go and the gate in core/agent/agent.go. Those helpers +// are unexported there, so the small pieces below are kept in step by hand; the +// meta parity spec in toolpolicy_test.go catches drift in the form fields. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mudler/cogito" + "github.com/mudler/xlog" +) + +// ToolNames is a list of tool names. The agent form submits it as a comma or +// newline separated string and the API as a JSON array, so both are accepted. +type ToolNames []string + +// UnmarshalJSON accepts a JSON array of strings, a comma or newline separated +// string, or null. Names are trimmed and empty entries dropped. +func (t *ToolNames) UnmarshalJSON(data []byte) error { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return err + } + var raw []string + switch v := value.(type) { + case nil: + *t = nil + return nil + case string: + raw = strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '\n' || r == '\r' }) + case []any: + for _, item := range v { + name, ok := item.(string) + if !ok { + return fmt.Errorf("expected a list of tool names, got %T", item) + } + raw = append(raw, name) + } + default: + return fmt.Errorf("expected a list of tool names or a comma separated string, got %T", value) + } + var names ToolNames + for _, n := range raw { + if n = strings.TrimSpace(n); n != "" { + names = append(names, n) + } + } + *t = names + return nil +} + +// controlActionNames are LocalAGI's loop-driving actions. The distributed +// executor does not offer them today, but an agent config is shared between +// both modes, so the filter must treat them the same way in both. +var controlActionNames = map[string]struct{}{ + "send_message": {}, + "stop": {}, + "update_state": {}, +} + +// toolFilter is an allow/deny list over tool names. A nil *toolFilter allows +// everything. +type toolFilter struct { + allow map[string]struct{} + deny map[string]struct{} +} + +func newToolFilter(allow, deny []string) *toolFilter { + f := &toolFilter{allow: toNameSet(allow), deny: toNameSet(deny)} + if len(f.allow) == 0 && len(f.deny) == 0 { + return nil + } + return f +} + +func toNameSet(names []string) map[string]struct{} { + set := make(map[string]struct{}, len(names)) + for _, n := range names { + if n = strings.TrimSpace(n); n != "" { + set[n] = struct{}{} + } + } + return set +} + +func (f *toolFilter) allows(name string) bool { + if f == nil { + return true + } + if _, ok := controlActionNames[name]; ok { + return true + } + if _, denied := f.deny[name]; denied { + return false + } + if len(f.allow) == 0 { + return true + } + _, allowed := f.allow[name] + return allowed +} + +func (f *toolFilter) filterTools(tools []cogito.ToolDefinitionInterface) []cogito.ToolDefinitionInterface { + if f == nil { + return tools + } + out := make([]cogito.ToolDefinitionInterface, 0, len(tools)) + for _, t := range tools { + if f.allows(t.Tool().Function.Name) { + out = append(out, t) + } + } + return out +} + +// mcpToolFilter is needed on top of filterTools because cogito discovers MCP +// tools straight from the live sessions. +func (f *toolFilter) mcpToolFilter() cogito.MCPToolFilter { + if f == nil { + return nil + } + return func(_ *gomcp.ClientSession, toolName string) bool { + return f.allows(toolName) + } +} + +// defaultRequiredFinishAttempts bounds the reminders: a gate that can loop +// forever is worse than one that gives up loudly. +const defaultRequiredFinishAttempts = 3 + +func requiredFinishPromptFor(tool, override string) string { + if override != "" { + return override + } + return "Before you send your final answer you MUST first call the tool " + tool + + " and it must succeed (ok:true). Call " + tool + " now; only send the final " + + "message after it passes." +} + +// requiredToolResultOK reports whether a tool result is a JSON object with a +// top-level "ok": true. When the result is not JSON as a whole (MCP content +// may wrap it in text), each top-level object embedded in it is checked. +func requiredToolResultOK(result string) bool { + trimmed := strings.TrimSpace(result) + if json.Valid([]byte(trimmed)) { + return jsonObjectOK([]byte(trimmed)) + } + for i := 0; i < len(result); { + j := strings.IndexByte(result[i:], '{') + if j < 0 { + return false + } + start := i + j + dec := json.NewDecoder(strings.NewReader(result[start:])) + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + i = start + 1 + continue + } + if jsonObjectOK(raw) { + return true + } + // Skip the whole object so its nested objects are not checked on their own. + i = start + int(dec.InputOffset()) + } + return false +} + +func jsonObjectOK(data []byte) bool { + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err != nil { + return false + } + var ok bool + if err := json.Unmarshal(obj["ok"], &ok); err != nil { + return false + } + return ok +} + +// requiredToolAvailable reports whether the model is offered the required +// tool. The gate stays inert otherwise, so a pool-wide setting is harmless for +// agents that lack the tool. MCP sessions are only listed when the tool is not +// a local one. +func requiredToolAvailable(ctx context.Context, name string, local []cogito.ToolDefinitionInterface, sessions []*gomcp.ClientSession, filter *toolFilter) bool { + if name == "" || !filter.allows(name) { + return false + } + if cogito.Tools(local).Find(name) != nil { + return true + } + for _, s := range sessions { + res, err := s.ListTools(ctx, nil) + if err != nil { + xlog.Warn("required-tool gate: failed to list MCP tools", "error", err) + continue + } + for _, t := range res.Tools { + if t.Name == name { + return true + } + } + } + return false +} + +// textFinalizationNeedsRequiredTool reports whether the run ended with a +// non-empty assistant answer although the required tool has not passed and +// reminders are left. +func textFinalizationNeedsRequiredTool(toolAvailable, toolPassed bool, attempts, max int, lastRole, lastContent string) bool { + return toolAvailable && !toolPassed && attempts < max && + lastRole == "assistant" && strings.TrimSpace(lastContent) != "" +} diff --git a/core/services/agents/toolpolicy_test.go b/core/services/agents/toolpolicy_test.go new file mode 100644 index 000000000..4a18d3f80 --- /dev/null +++ b/core/services/agents/toolpolicy_test.go @@ -0,0 +1,387 @@ +package agents + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "sync" + "sync/atomic" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/mudler/LocalAGI/core/state" + "github.com/mudler/cogito" + openai "github.com/sashabaranov/go-openai" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// mcpFixture serves an MCP server over SSE whose tools return fixed results, +// so the executor reaches it through the same transport as a real agent. +type mcpFixture struct { + server *httptest.Server + calls map[string]*atomic.Int32 +} + +func newMCPFixture(results map[string]string) *mcpFixture { + srv := mcp.NewServer(&mcp.Implementation{Name: "fixture", Version: "v0.0.1"}, nil) + fx := &mcpFixture{calls: map[string]*atomic.Int32{}} + for name, result := range results { + counter := &atomic.Int32{} + fx.calls[name] = counter + srv.AddTool(&mcp.Tool{ + Name: name, + Description: "fixture tool " + name, + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + }, func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + counter.Add(1) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: result}}}, nil + }) + } + fx.server = httptest.NewServer(mcp.NewSSEHandler(func(*http.Request) *mcp.Server { return srv }, nil)) + return fx +} + +func (fx *mcpFixture) close() { fx.server.Close() } +func (fx *mcpFixture) callCount(n string) int32 { return fx.calls[n].Load() } + +// policyLLM answers each chat completion through respond (plain text answer +// when respond is nil) and records every +// request, so specs can see which tools were offered and which messages the +// executor added. +type policyLLM struct { + mu sync.Mutex + requests []openai.ChatCompletionRequest + asked [][]openai.ChatCompletionMessage + respond func(req openai.ChatCompletionRequest) openai.ChatCompletionMessage + answer string +} + +func (m *policyLLM) Ask(_ context.Context, f cogito.Fragment) (cogito.Fragment, error) { + m.mu.Lock() + m.asked = append(m.asked, append([]openai.ChatCompletionMessage(nil), f.Messages...)) + m.mu.Unlock() + return f.AddMessage(cogito.AssistantMessageRole, m.answer), nil +} + +func (m *policyLLM) CreateChatCompletion(_ context.Context, req openai.ChatCompletionRequest) (cogito.LLMReply, cogito.LLMUsage, error) { + m.mu.Lock() + m.requests = append(m.requests, req) + m.mu.Unlock() + msg := openai.ChatCompletionMessage{Role: "assistant", Content: m.answer} + if m.respond != nil { + msg = m.respond(req) + } + return cogito.LLMReply{ + ChatCompletionResponse: openai.ChatCompletionResponse{ + Choices: []openai.ChatCompletionChoice{{Message: msg}}, + }, + }, cogito.LLMUsage{}, nil +} + +// offeredTools returns the sorted tool names of the first request that +// offered tools to the model. +func (m *policyLLM) offeredTools() []string { + m.mu.Lock() + defer m.mu.Unlock() + for _, req := range m.requests { + if len(req.Tools) == 0 { + continue + } + names := []string{} + for _, t := range req.Tools { + if t.Function != nil { + names = append(names, t.Function.Name) + } + } + sort.Strings(names) + return names + } + return nil +} + +// nudges counts the user messages carrying prompt in the longest conversation +// the model saw, which is the number of times the gate sent it. +func (m *policyLLM) nudges(prompt string) int { + m.mu.Lock() + defer m.mu.Unlock() + best := 0 + count := func(msgs []openai.ChatCompletionMessage) { + n := 0 + for _, msg := range msgs { + if msg.Role == "user" && msg.Content == prompt { + n++ + } + } + if n > best { + best = n + } + } + for _, req := range m.requests { + count(req.Messages) + } + for _, msgs := range m.asked { + count(msgs) + } + return best +} + +func toolCallMessage(name string) openai.ChatCompletionMessage { + return openai.ChatCompletionMessage{ + Role: "assistant", + ToolCalls: []openai.ToolCall{{ + ID: "call-" + name, + Type: openai.ToolTypeFunction, + Function: openai.FunctionCall{Name: name, Arguments: `{}`}, + }}, + } +} + +func lastMessage(req openai.ChatCompletionRequest) openai.ChatCompletionMessage { + if len(req.Messages) == 0 { + return openai.ChatCompletionMessage{} + } + return req.Messages[len(req.Messages)-1] +} + +var _ = Describe("tool policy settings", func() { + Describe("config parsing", func() { + It("accepts the tool lists as a comma or newline separated string", func() { + var cfg AgentConfig + Expect(ParseConfigJSON(`{"allowed_tools":"a, b\nc,,","excluded_tools":" d \r\n"}`, &cfg)).To(Succeed()) + Expect([]string(cfg.AllowedTools)).To(Equal([]string{"a", "b", "c"})) + Expect([]string(cfg.ExcludedTools)).To(Equal([]string{"d"})) + }) + + It("accepts the tool lists as a JSON array", func() { + var cfg AgentConfig + Expect(ParseConfigJSON(`{"allowed_tools":["a"," b ",""],"excluded_tools":null}`, &cfg)).To(Succeed()) + Expect([]string(cfg.AllowedTools)).To(Equal([]string{"a", "b"})) + Expect(cfg.ExcludedTools).To(BeEmpty()) + }) + + It("rejects a list with non-string entries", func() { + var cfg AgentConfig + Expect(ParseConfigJSON(`{"allowed_tools":[1]}`, &cfg)).ToNot(Succeed()) + }) + + It("keeps every setting when the config is stored through LocalAGI's config", func() { + // The REST handlers decode into state.AgentConfig and store its JSON; + // the distributed dispatcher decodes that JSON into AgentConfig. + var in state.AgentConfig + Expect(json.Unmarshal([]byte(`{ + "name": "a", + "allowed_tools": "search, check_policy", + "excluded_tools": ["add_memory"], + "required_tool_before_finish": "check_policy", + "required_tool_before_finish_prompt": "run it", + "required_tool_before_finish_attempts": 4 + }`), &in)).To(Succeed()) + stored, err := json.Marshal(in) + Expect(err).ToNot(HaveOccurred()) + + var out AgentConfig + Expect(ParseConfigJSON(string(stored), &out)).To(Succeed()) + Expect([]string(out.AllowedTools)).To(Equal([]string{"search", "check_policy"})) + Expect([]string(out.ExcludedTools)).To(Equal([]string{"add_memory"})) + Expect(out.RequiredToolBeforeFinish).To(Equal("check_policy")) + Expect(out.RequiredToolBeforeFinishPrompt).To(Equal("run it")) + Expect(out.RequiredToolBeforeFinishAttempts).To(Equal(4)) + + again, err := json.Marshal(out) + Expect(err).ToNot(HaveOccurred()) + var back state.AgentConfig + Expect(json.Unmarshal(again, &back)).To(Succeed()) + Expect(back.AllowedTools).To(Equal([]string{"search", "check_policy"})) + Expect(back.RequiredToolBeforeFinishAttempts).To(Equal(4)) + }) + }) + + Describe("config meta", func() { + It("describes the settings exactly like LocalAGI does", func() { + upstream := map[string]ConfigField{} + for _, f := range state.NewAgentConfigMeta(nil, nil, nil, nil).Fields { + upstream[f.Name] = ConfigField{ + Name: f.Name, Type: string(f.Type), Label: f.Label, DefaultValue: f.DefaultValue, + Placeholder: f.Placeholder, HelpText: f.HelpText, Min: f.Min, Max: f.Max, Step: f.Step, + Tags: ConfigFieldTags{Section: f.Tags.Section}, + } + } + local := map[string]ConfigField{} + for _, f := range DefaultConfigMeta().Fields { + local[f.Name] = f + } + for _, name := range []string{ + "allowed_tools", "excluded_tools", + "required_tool_before_finish", "required_tool_before_finish_prompt", "required_tool_before_finish_attempts", + } { + Expect(upstream).To(HaveKey(name)) + Expect(local).To(HaveKeyWithValue(name, upstream[name]), name) + } + }) + }) + + Describe("tool filter", func() { + It("keeps the control actions even when they are excluded or not allowed", func() { + f := newToolFilter([]string{"search"}, []string{"send_message", "stop", "update_state", "search"}) + for _, name := range []string{"send_message", "stop", "update_state"} { + Expect(f.allows(name)).To(BeTrue(), name) + } + Expect(f.allows("search")).To(BeFalse()) + Expect(f.allows("other")).To(BeFalse()) + }) + }) + + Describe("ExecuteChatWithLLM", func() { + var fx *mcpFixture + + BeforeEach(func() { + fx = newMCPFixture(map[string]string{ + "check_policy": `{"ok":true}`, + "mcp_allowed": "allowed result", + "mcp_blocked": "blocked result", + }) + }) + + AfterEach(func() { fx.close() }) + + baseConfig := func() *AgentConfig { + return &AgentConfig{ + Name: "policy-agent", + Model: "test-model", + MCPServers: []MCPServer{{URL: fx.server.URL}}, + EnableKnowledgeBase: true, + KBMode: KBModeTools, + } + } + + Context("with allowed and excluded tools", func() { + It("offers the model only the allowed tools that are not excluded, MCP tools included", func() { + llm := &policyLLM{answer: "final"} + cfg := baseConfig() + cfg.AllowedTools = ToolNames{"mcp_allowed", "search_memory", "add_memory"} + cfg.ExcludedTools = ToolNames{"add_memory"} + + _, err := ExecuteChatWithLLM(context.Background(), llm, cfg, "hi", Callbacks{}) + Expect(err).ToNot(HaveOccurred()) + Expect(llm.offeredTools()).To(Equal([]string{"mcp_allowed", "search_memory"})) + }) + + It("offers every tool when no list is set", func() { + llm := &policyLLM{answer: "final"} + _, err := ExecuteChatWithLLM(context.Background(), llm, baseConfig(), "hi", Callbacks{}) + Expect(err).ToNot(HaveOccurred()) + Expect(llm.offeredTools()).To(Equal([]string{"add_memory", "check_policy", "mcp_allowed", "mcp_blocked", "search_memory"})) + }) + + It("does not run a filtered MCP tool the model calls anyway", func() { + var calls atomic.Int32 + llm := &policyLLM{answer: "final", respond: func(openai.ChatCompletionRequest) openai.ChatCompletionMessage { + if calls.Add(1) == 1 { + return toolCallMessage("mcp_blocked") + } + return openai.ChatCompletionMessage{Role: "assistant", Content: "done"} + }} + cfg := baseConfig() + cfg.ExcludedTools = ToolNames{"mcp_blocked"} + + _, _ = ExecuteChatWithLLM(context.Background(), llm, cfg, "hi", Callbacks{}) + Expect(fx.callCount("mcp_blocked")).To(BeZero()) + }) + }) + + Context("with a required tool before finish", func() { + const prompt = "RUN check_policy NOW" + + It("nudges the model until the required tool passes, then returns its answer", func() { + llm := &policyLLM{answer: "final answer", respond: func(req openai.ChatCompletionRequest) openai.ChatCompletionMessage { + if last := lastMessage(req); last.Role == "user" && last.Content == prompt { + return toolCallMessage("check_policy") + } + return openai.ChatCompletionMessage{Role: "assistant", Content: "final answer"} + }} + cfg := baseConfig() + cfg.RequiredToolBeforeFinish = "check_policy" + cfg.RequiredToolBeforeFinishPrompt = prompt + + result, err := ExecuteChatWithLLM(context.Background(), llm, cfg, "hi", Callbacks{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("final answer")) + Expect(fx.callCount("check_policy")).To(Equal(int32(1))) + Expect(llm.nudges(prompt)).To(Equal(1)) + }) + + It("lets the answer through after the configured number of reminders", func() { + llm := &policyLLM{answer: "stubborn answer"} + cfg := baseConfig() + cfg.RequiredToolBeforeFinish = "check_policy" + cfg.RequiredToolBeforeFinishPrompt = prompt + cfg.RequiredToolBeforeFinishAttempts = 2 + + result, err := ExecuteChatWithLLM(context.Background(), llm, cfg, "hi", Callbacks{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("stubborn answer")) + Expect(llm.nudges(prompt)).To(Equal(2)) + Expect(fx.callCount("check_policy")).To(BeZero()) + }) + + It("uses three reminders and a prompt naming the tool by default", func() { + llm := &policyLLM{answer: "stubborn answer"} + cfg := baseConfig() + cfg.RequiredToolBeforeFinish = "check_policy" + + _, err := ExecuteChatWithLLM(context.Background(), llm, cfg, "hi", Callbacks{}) + Expect(err).ToNot(HaveOccurred()) + Expect(llm.nudges(requiredFinishPromptFor("check_policy", ""))).To(Equal(3)) + Expect(requiredFinishPromptFor("check_policy", "")).To(ContainSubstring("check_policy")) + }) + + It("keeps nudging when the required tool fails", func() { + fx.close() + fx = newMCPFixture(map[string]string{"check_policy": `{"ok":false}`}) + llm := &policyLLM{answer: "final answer", respond: func(req openai.ChatCompletionRequest) openai.ChatCompletionMessage { + if last := lastMessage(req); last.Role == "user" && last.Content == prompt { + return toolCallMessage("check_policy") + } + return openai.ChatCompletionMessage{Role: "assistant", Content: "final answer"} + }} + cfg := baseConfig() + cfg.RequiredToolBeforeFinish = "check_policy" + cfg.RequiredToolBeforeFinishPrompt = prompt + cfg.RequiredToolBeforeFinishAttempts = 2 + + _, err := ExecuteChatWithLLM(context.Background(), llm, cfg, "hi", Callbacks{}) + Expect(err).ToNot(HaveOccurred()) + Expect(fx.callCount("check_policy")).To(Equal(int32(2))) + Expect(llm.nudges(prompt)).To(Equal(2)) + }) + + It("does nothing when the agent does not have the required tool", func() { + llm := &policyLLM{answer: "final"} + cfg := baseConfig() + cfg.RequiredToolBeforeFinish = "check_policy" + cfg.RequiredToolBeforeFinishPrompt = prompt + cfg.ExcludedTools = ToolNames{"check_policy"} + + result, err := ExecuteChatWithLLM(context.Background(), llm, cfg, "hi", Callbacks{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("final")) + Expect(llm.nudges(prompt)).To(BeZero()) + }) + }) + }) +}) + +var _ = DescribeTable("requiredToolResultOK", + func(result string, want bool) { + Expect(requiredToolResultOK(result)).To(Equal(want)) + }, + Entry("top-level ok true", `{"ok":true}`, true), + Entry("top-level ok false", `{"ok":false}`, false), + Entry("ok as a string", `{"ok":"true"}`, false), + Entry("ok nested in another object", `{"data":{"ok":true}}`, false), + Entry("object embedded in text", `result: {"ok": true, "n": 1} done`, true), + Entry("plain text", `"ok": true`, false), +) diff --git a/docs/content/features/agents.md b/docs/content/features/agents.md index 86bc4a31a..4e9026942 100644 --- a/docs/content/features/agents.md +++ b/docs/content/features/agents.md @@ -191,8 +191,6 @@ Each agent has its own configuration that controls its behavior. Key settings in - **Allowed / Excluded Tools** (`allowed_tools`, `excluded_tools`) - limit the tools the agent can see, including MCP tools. The agent always keeps its control actions (`send_message`, `stop`, `update_state`). If a tool is in both lists, it is excluded. - **Required Tool Before Finish** (`required_tool_before_finish`) - a tool the agent must call successfully before it can give its final answer, for example a validation or policy check. `required_tool_before_finish_prompt` changes the reminder the model gets when it tries to finish early. `required_tool_before_finish_attempts` sets how many reminders it gets before the answer goes through anyway (default 3). -The tool lists and the required-tool settings are available only in single-node mode. The agent form in distributed mode does not show them yet. - The pool-level defaults (API URL, API key, models) can be set via environment variables. Individual agents can further override these in their configuration, allowing them to use different LLM providers (OpenAI, other LocalAI instances, etc.) on a per-agent basis. ## Skills