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 <mudler@localai.io>
Assisted-by: Claude:claude-opus-5-5 [Claude Code]
This commit is contained in:
Ettore Di Giacinto committed 2026-09-27 20:49:12 +00:00
1 parent b7155a9d97
commit 84e2fc5eac
6 files changed
+693 -8

No files matched your search

+7
View File
@@ -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.).
+20
View File
@@ -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
+59 -6
View File
@@ -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 {
+220
View File
@@ -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) != ""
}
+387
View File
@@ -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),
)
-2
View File
@@ -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