From cb7a8ef1e1bfff788adccf7392f7fbbc03ec02e1 Mon Sep 17 00:00:00 2001
From: Vitor Castro
Date: Sun, 20 Sep 2026 20:39:26 +0100
Subject: [PATCH 1/2] Reduce MCP prompt weight with a hard tool cap and lazy
discovery.
Clamp maxMcpToolsPerTurn at 12, keep MCP out of the first-hop catalog until tool_search/tool_describe pin, and cap wiki inject to ~75% of num_ctx.
---
docs/architecture-and-features.md | 14 ++-
.../Components/AgenticConfigEditor.razor | 10 +-
.../Agentic/AgentLoopRunner.cs | 86 ++++++++++++-
.../Agentic/AgenticModels.cs | 4 +
.../Agentic/AgenticToolRegistryService.cs | 56 +++++----
.../Agentic/ClientSideToolCalling.cs | 20 +--
.../Agentic/Mcp/McpPinnedToolFactory.cs | 61 +++++++++
.../Agentic/Prompts/AgenticPromptProfile.cs | 10 +-
.../Prompts/AgenticSystemPromptBuilder.cs | 12 +-
.../Agentic/Prompts/LlmCapabilities.cs | 13 +-
.../Agentic/ProseToolCallParser.cs | 9 +-
.../Agentic/SessionDiscoveryTools.cs | 19 ++-
.../Engine/SystemPromptBuilder.cs | 3 +-
.../Session/SessionWikiSettings.cs | 20 +++
.../Agentic/Mcp/McpToolExecutor.cs | 26 +++-
.../Agentic/SessionDiscoveryToolExecutor.cs | 65 +++++++++-
.../AgenticActionsE2ETests.cs | 4 +-
.../AgenticPromptProfileTests.cs | 41 +++++-
tests/ContextMemory.Api.Tests/AgenticTests.cs | 80 +++++++++++-
.../ClientSideToolCallingTests.cs | 74 +++++++++++
.../Fakes/AgenticStubOllamaHandler.cs | 119 ++++++++++++++----
.../ProseToolCallParserTests.cs | 28 +++++
.../SessionWikiSettingsTests.cs | 26 ++++
23 files changed, 708 insertions(+), 92 deletions(-)
create mode 100644 src/ContextMemory.Core/Agentic/Mcp/McpPinnedToolFactory.cs
diff --git a/docs/architecture-and-features.md b/docs/architecture-and-features.md
index 54190b3..32fc4c8 100644
--- a/docs/architecture-and-features.md
+++ b/docs/architecture-and-features.md
@@ -133,11 +133,11 @@ Theory: minimize tokens in the big chat model. Prefer **lazy discovery** over st
| Mechanism | Role |
|---|---|
| **Static (every turn)** | Persona + budgeted session wiki + rolling summary + top-K Global Wiki digests |
-| **On demand** | `wiki_search`, `wiki_grep`, `skill_search`/`skill_read`, `rule_search`/`rule_read`, `tool_describe`, artifacts |
+| **On demand** | `wiki_search`, `wiki_grep`, `skill_search`/`skill_read`, `tool_search`/`tool_describe`, artifacts |
| **Models** | `LlmModel` (answer/agent) + `WikiLlmModel` (digests, rolling summary, mid-turn compaction) |
| **Mid-turn compaction** | If estimated tokens > `MaxContextTokens`: archive transcript as `history:…` artifact, summarize with `WikiLlmModel`, shrink messages; emit phase `Compacting` |
| **Artifacts** | Long tool/MCP outputs stored per session; sandbox/terminal **always** archived with a short preview + `artifactId`. Tools: `artifact_tail` / `artifact_read` |
-| **Lazy tools[]** | Built-ins and MCP expose name + one-line description + open schema; call `tool_describe` before first use of an unfamiliar tool |
+| **Lazy tools[]** | Built-ins in `tools[]`; MCP discovered via `tool_search` → `tool_describe` (pin) → call; open stubs omit params until pin |
| **Lazy skills** | Prompt lists up to 3 default skill ids; `skill_search` → snippets; `skill_read` → body |
| **Rules** | `always_on` injected into system; `requestable` via `rule_search` / `rule_read` (`Activation` on catalog skills) |
| **Hooks** | Guardrail kinds `pre-tool-use` / `post-tool-use` (deny/allow patterns, require confirm, redact) |
@@ -154,7 +154,7 @@ Theory: minimize tokens in the big chat model. Prefer **lazy discovery** over st
| `artifact_read` / `artifact_tail` | Recover truncated / archived tool output |
| `skill_search` / `skill_read` | Find and load skill bodies |
| `rule_search` / `rule_read` | Requestable rules |
-| `tool_describe` | Full tool schema/description |
+| `tool_search` / `tool_describe` | Find MCP tools by keyword; load full schema and pin into turn catalog |
| `session_log_search` | Grep session log when summary misses a detail |
| `delegate_task` | Spawn isolated subagent (depth 1) |
| `todo_write` | Session todo list for the Admin timeline |
@@ -298,12 +298,14 @@ Discovery telemetry also reports `harness_mode`, `resolved_prompt_profile`, `pro
### Supported LLM backends
+ContextMemory is **engine-agnostic**: it never embeds weights. Point each tenant (or the host defaults) at any OpenAI-compatible `/v1` server — Ollama, vLLM, LM Studio, [ExLlamaSharp](https://github.com/Kortexio/ExLlamaSharp), OpenAI, Azure OpenAI-compatible gateways, LiteLLM, custom.
+
| Backend value | Wire protocol | Host default URL |
|---|---|---|
-| `ollama` (default) | OpenAI `/v1` on `OllamaEndpoint` — **auto-switches to native when `numCtx` is set** | `ContextMemory:OllamaEndpoint` |
+| `ollama` (DX default) | OpenAI `/v1` on `LlmEndpoint` / `OllamaEndpoint` — **auto-switches to native when `numCtx` is set** | `ContextMemory:LlmEndpoint` (fallback `OllamaEndpoint`) |
| `vllm` / `openai-compatible` / `openai` / `custom` | OpenAI `/v1/chat/completions` | `ContextMemory:OpenAiEndpoint` or per-app `llmEndpoint` |
| `lmstudio` | OpenAI `/v1` | `ContextMemory:LmStudioEndpoint` |
-| `ollama-native` | Ollama `/api/chat` (respects `options.num_ctx`) | `ContextMemory:OllamaEndpoint` |
+| `ollama-native` | Ollama `/api/chat` (respects `options.num_ctx`) | `ContextMemory:LlmEndpoint` / `OllamaEndpoint` |
**Per-app overrides** (Admin → app → Config, or `PATCH /admin/apps/{id}/config`):
@@ -314,7 +316,7 @@ Discovery telemetry also reports `harness_mode`, `resolved_prompt_profile`, `pro
| `llmEndpoint` | Optional base URL for this app only. Empty = host default above. For OpenAI-compatible URLs, `/v1` is appended if missing. |
| `llmApiKey` | Optional API key for this app. Empty = host `OpenAiApiKey`. |
-Example — point one tenant at a remote vLLM / LiteLLM server:
+Example — point one tenant at a remote vLLM / LiteLLM / ExLlamaSharp server:
```json
{
diff --git a/src/ContextMemory.Admin.UI/Components/AgenticConfigEditor.razor b/src/ContextMemory.Admin.UI/Components/AgenticConfigEditor.razor
index d5c69dd..0d3c67b 100644
--- a/src/ContextMemory.Admin.UI/Components/AgenticConfigEditor.razor
+++ b/src/ContextMemory.Admin.UI/Components/AgenticConfigEditor.razor
@@ -197,10 +197,10 @@
-
+
- Hard ceiling for how many MCP tools are offered to the model each turn (top-K by query relevance).
- Applies to Weak and Strong harness alike. Set ≥ catalog size (e.g. 100) to expose all tools.
+ Top-K MCP tools offered to the model each turn (by query relevance). Hard-capped at 12
+ by the gateway so oversized catalogs cannot blow the context window (Qwen dumps the selection into the system prompt).
@@ -497,7 +497,7 @@
},
Tools = new AgenticToolsConfig
{
- MaxMcpToolsPerTurn = Tools.MaxMcpToolsPerTurn > 0 ? Tools.MaxMcpToolsPerTurn : 12,
+ MaxMcpToolsPerTurn = Math.Clamp(Tools.MaxMcpToolsPerTurn > 0 ? Tools.MaxMcpToolsPerTurn : 12, 1, 12),
Http = new AgenticHttpToolsConfig
{
Enabled = Tools.Http.Enabled,
@@ -602,7 +602,7 @@
EnabledGuardrailIds = packs.EnabledGuardrailIds?.ToList() ?? [],
Tools = new AgenticToolsForm
{
- MaxMcpToolsPerTurn = config.Tools.MaxMcpToolsPerTurn > 0 ? config.Tools.MaxMcpToolsPerTurn : 12,
+ MaxMcpToolsPerTurn = Math.Clamp(config.Tools.MaxMcpToolsPerTurn > 0 ? config.Tools.MaxMcpToolsPerTurn : 12, 1, 12),
Http = new HttpToolsForm
{
Enabled = config.Tools.Http.Enabled,
diff --git a/src/ContextMemory.Core/Agentic/AgentLoopRunner.cs b/src/ContextMemory.Core/Agentic/AgentLoopRunner.cs
index e4f6abc..ddc147a 100644
--- a/src/ContextMemory.Core/Agentic/AgentLoopRunner.cs
+++ b/src/ContextMemory.Core/Agentic/AgentLoopRunner.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Net.Http;
+using System.Text.Json;
using ContextMemory.Core.Localization;
using ContextMemory.Core.Configuration;
using ContextMemory.Core.Contracts;
@@ -24,6 +25,7 @@ public sealed class AgentLoopRunner : IAgentLoopRunner
private readonly IAgenticPendingStore _pendingStore;
private readonly IAgentContextCompactor _contextCompactor;
private readonly IAgentStateMachine _stateMachine;
+ private readonly IMcpToolCatalog _mcpCatalog;
private readonly ILogger _logger;
private readonly ContextMemoryOptions _options;
@@ -35,6 +37,7 @@ public AgentLoopRunner(
IAgenticPendingStore pendingStore,
IAgentContextCompactor contextCompactor,
IAgentStateMachine stateMachine,
+ IMcpToolCatalog mcpCatalog,
ILogger logger,
IOptions options)
{
@@ -45,6 +48,7 @@ public AgentLoopRunner(
_pendingStore = pendingStore;
_contextCompactor = contextCompactor;
_stateMachine = stateMachine;
+ _mcpCatalog = mcpCatalog;
_logger = logger;
_options = options.Value;
}
@@ -54,6 +58,7 @@ public async Task RunAsync(AgentLoopRequest request, CancellationTo
var messages = request.Messages;
EnsureUserMessagePresent(messages, request);
var steps = request.Steps;
+ var toolsList = request.Tools.ToList();
var capabilities = LlmCapabilitiesResolver.From(request.RuntimeConfig);
var maxIterations = LlmCapabilitiesResolver.ResolveMaxIterations(request.RuntimeConfig);
var loopTimeout = ResolveLoopTimeout(request.RuntimeConfig);
@@ -119,7 +124,7 @@ public async Task RunAsync(AgentLoopRequest request, CancellationTo
cancellationToken.ThrowIfCancellationRequested();
EnsureUserMessagePresent(messages, request);
- var toolsForRequest = request.Tools.Count > 0 ? request.Tools.ToList() : null;
+ var toolsForRequest = toolsList.Count > 0 ? toolsList : null;
if (toolsForRequest is not null && capabilities.SanitizeSchemasAggressively)
{
toolsForRequest = SanitizeToolSchemas(toolsForRequest);
@@ -565,6 +570,22 @@ await _contextCompactor
.WithTrace(trace);
}
+ if (toolOutcome.Result is { Success: true }
+ && string.Equals(
+ toolCall.Function.Name,
+ SessionDiscoveryTools.ToolDescribe,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ await TryPinMcpToolAfterDescribeAsync(
+ toolCall,
+ request.RuntimeConfig,
+ toolsList,
+ messages,
+ capabilities.PreferClientSideToolParsing,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
loopState = ApplyTransition(loopState, AgentLoopEvent.ToolResult, trace);
}
@@ -942,6 +963,69 @@ private static string TruncateForLog(string? value, int maxChars)
: capabilities.DefaultToolChoice;
}
+ private async Task TryPinMcpToolAfterDescribeAsync(
+ OllamaToolCall describeCall,
+ AppRuntimeConfig runtimeConfig,
+ List toolsList,
+ List messages,
+ bool preferClientSideTools,
+ CancellationToken cancellationToken)
+ {
+ string? toolName = null;
+ try
+ {
+ using var doc = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(describeCall.Function.Arguments)
+ ? "{}"
+ : describeCall.Function.Arguments);
+ var root = doc.RootElement;
+ toolName = TryGetJsonString(root, "toolName")
+ ?? TryGetJsonString(root, "name")
+ ?? TryGetJsonString(root, "tool");
+ }
+ catch
+ {
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(toolName))
+ return;
+
+ toolName = toolName.Trim();
+ if (toolsList.Any(t =>
+ string.Equals(t.Function.Name, toolName, StringComparison.OrdinalIgnoreCase)))
+ return;
+
+ var catalog = await _mcpCatalog
+ .GetAllToolsAsync(runtimeConfig, cancellationToken)
+ .ConfigureAwait(false);
+ var match = catalog.FirstOrDefault(t =>
+ string.Equals(t.QualifiedName, toolName, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(t.Name, toolName, StringComparison.OrdinalIgnoreCase));
+ if (match is null)
+ return;
+
+ var max = LlmCapabilitiesResolver.ResolveMaxMcpTools(runtimeConfig);
+ var pinnedCount = toolsList.Count(t =>
+ McpToolNaming.TryParseQualifiedName(t.Function.Name, out _, out _));
+ if (pinnedCount >= max)
+ return;
+
+ toolsList.Add(McpPinnedToolFactory.Create(match, runtimeConfig));
+ _logger.LogInformation(
+ "Pinned MCP tool {Tool} into turn catalog for {AppId} after tool_describe",
+ match.QualifiedName,
+ runtimeConfig.AppId);
+
+ if (preferClientSideTools && toolsList.Count > 0)
+ ClientSideToolCalling.EnsureCatalogInSystemPrompt(messages, toolsList);
+ }
+
+ private static string? TryGetJsonString(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+
private static void EnsureUserMessagePresent(List messages, AgentLoopRequest request)
{
var hasRealUser = messages.Any(m =>
diff --git a/src/ContextMemory.Core/Agentic/AgenticModels.cs b/src/ContextMemory.Core/Agentic/AgenticModels.cs
index dffa2a9..b74fe91 100644
--- a/src/ContextMemory.Core/Agentic/AgenticModels.cs
+++ b/src/ContextMemory.Core/Agentic/AgenticModels.cs
@@ -68,6 +68,10 @@ public record AgenticToolsConfig
[JsonPropertyName("integrations")]
public List Integrations { get; init; } = [];
+ ///
+ /// Top-K MCP tools offered to the model each turn. Effective value is clamped to
+ /// LlmCapabilitiesResolver.AbsoluteMaxMcpToolsPerTurn (12) at resolve time.
+ ///
[JsonPropertyName("maxMcpToolsPerTurn")]
public int MaxMcpToolsPerTurn { get; init; } = 12;
diff --git a/src/ContextMemory.Core/Agentic/AgenticToolRegistryService.cs b/src/ContextMemory.Core/Agentic/AgenticToolRegistryService.cs
index 4d9ccea..ca4dc24 100644
--- a/src/ContextMemory.Core/Agentic/AgenticToolRegistryService.cs
+++ b/src/ContextMemory.Core/Agentic/AgenticToolRegistryService.cs
@@ -24,6 +24,7 @@ public async Task> BuildToolsAsync(
IReadOnlyList? recentToolNames = null,
CancellationToken cancellationToken = default)
{
+ _ = userQuery;
var tools = new List();
tools.AddRange(AgenticToolRegistry.BuildExecutionTools(runtimeConfig, lazySchemas: true));
@@ -34,7 +35,7 @@ public async Task> BuildToolsAsync(
if (wikiGrep is not null)
tools.Add(wikiGrep);
- // Cursor-style discovery helpers (artifact/skill/log/tool_describe).
+ // Cursor-style discovery helpers (artifact/skill/log/tool_search/tool_describe).
tools.AddRange(SessionDiscoveryTools.BuildTools(runtimeConfig));
var caps = LlmCapabilitiesResolver.From(runtimeConfig);
@@ -44,26 +45,21 @@ public async Task> BuildToolsAsync(
tools.AddRange(AgenticDocumentTools.BuildTools(runtimeConfig));
tools.AddRange(AgenticCanvasTools.BuildTools(runtimeConfig));
- object openParameters = new Dictionary
+ // Lazy MCP: zero tools on first hop. Pin only MCP tools already invoked this conversation
+ // (real schema from catalog). New MCP tools enter via tool_search → tool_describe → pin.
+ if (recentToolNames is { Count: > 0 })
{
- ["type"] = "object",
- ["properties"] = new Dictionary(),
- ["additionalProperties"] = true
- };
-
- var mcpTools = await _mcpCatalog
- .GetToolsAsync(runtimeConfig, userQuery, recentToolNames, cancellationToken)
- .ConfigureAwait(false);
- foreach (var mcpTool in mcpTools)
- {
- var fullDescription = AgenticToolDescriptionBuilder.BuildMcpDescription(mcpTool, runtimeConfig);
- // Name-only style: open schema; full schema via tool_describe.
- tools.Add(new OllamaTool(
- "function",
- new OllamaFunction(
- mcpTool.QualifiedName,
- SessionDiscoveryTools.ShortenDescription(fullDescription),
- openParameters)));
+ var recent = recentToolNames.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var allMcp = await _mcpCatalog
+ .GetAllToolsAsync(runtimeConfig, cancellationToken)
+ .ConfigureAwait(false);
+ var max = LlmCapabilitiesResolver.ResolveMaxMcpTools(runtimeConfig);
+ foreach (var mcpTool in allMcp
+ .Where(t => recent.Contains(t.QualifiedName) || recent.Contains(t.Name))
+ .Take(max))
+ {
+ tools.Add(McpPinnedToolFactory.Create(mcpTool, runtimeConfig));
+ }
}
var capability = PolicyLayersFactory
@@ -78,8 +74,24 @@ public async Task BuildToolNamesSummaryAsync(
IReadOnlyList? recentToolNames = null,
CancellationToken cancellationToken = default)
{
- var tools = await BuildToolsAsync(runtimeConfig, userQuery, recentToolNames, cancellationToken).ConfigureAwait(false);
- return string.Join(", ", tools.Select(t => t.Function.Name));
+ var tools = await BuildToolsAsync(runtimeConfig, userQuery, recentToolNames, cancellationToken)
+ .ConfigureAwait(false);
+ var names = tools
+ .Select(t => t.Function.Name)
+ .Where(n => !McpToolNaming.TryParseQualifiedName(n, out _, out _))
+ .ToList();
+
+ var mcpServers = runtimeConfig.Agentic.Tools.Integrations
+ .Where(i => string.Equals(i.Type, "mcp", StringComparison.OrdinalIgnoreCase) && i.Enabled)
+ .Select(i => i.Name)
+ .Where(n => !string.IsNullOrWhiteSpace(n))
+ .ToList();
+
+ if (mcpServers.Count == 0)
+ return string.Join(", ", names);
+
+ var mcpHint = $"MCP via tool_search (servers: {string.Join(", ", mcpServers)})";
+ return names.Count == 0 ? mcpHint : string.Join(", ", names) + "; " + mcpHint;
}
public List BuildMcpServers(AppRuntimeConfig runtimeConfig) =>
diff --git a/src/ContextMemory.Core/Agentic/ClientSideToolCalling.cs b/src/ContextMemory.Core/Agentic/ClientSideToolCalling.cs
index 5964150..c27d09d 100644
--- a/src/ContextMemory.Core/Agentic/ClientSideToolCalling.cs
+++ b/src/ContextMemory.Core/Agentic/ClientSideToolCalling.cs
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
+using ContextMemory.Core.Agentic.Mcp;
using ContextMemory.Core.Models;
namespace ContextMemory.Core.Agentic;
@@ -22,15 +23,16 @@ public static void EnsureCatalogInSystemPrompt(List messages, IRe
if (system is null)
return;
- if (system.Content?.Contains(CatalogMarker, StringComparison.Ordinal) == true)
- return;
-
var catalog = BuildCatalog(tools);
+ var content = system.Content ?? string.Empty;
+ var markerIdx = content.IndexOf(CatalogMarker, StringComparison.Ordinal);
+ if (markerIdx >= 0)
+ content = content[..markerIdx].TrimEnd() + "\n\n" + catalog;
+ else
+ content = content.TrimEnd() + "\n\n" + catalog;
+
var idx = messages.IndexOf(system);
- messages[idx] = system with
- {
- Content = (system.Content ?? string.Empty).TrimEnd() + "\n\n" + catalog
- };
+ messages[idx] = system with { Content = content };
}
public static string BuildCatalog(IReadOnlyList tools)
@@ -60,6 +62,10 @@ public static string BuildCatalog(IReadOnlyList tools)
}
sb.AppendLine();
+ // Skip open stub schemas (empty properties) — full params appear after tool_describe pin.
+ if (McpPinnedToolFactory.IsOpenStubParameters(fn.Parameters))
+ continue;
+
var schema = CompactSchema(fn.Parameters);
if (!string.IsNullOrWhiteSpace(schema))
sb.AppendLine(" params: " + NeutralizeXmlTriggers(schema));
diff --git a/src/ContextMemory.Core/Agentic/Mcp/McpPinnedToolFactory.cs b/src/ContextMemory.Core/Agentic/Mcp/McpPinnedToolFactory.cs
new file mode 100644
index 0000000..4b6eec4
--- /dev/null
+++ b/src/ContextMemory.Core/Agentic/Mcp/McpPinnedToolFactory.cs
@@ -0,0 +1,61 @@
+using ContextMemory.Core.Agentic.Prompts;
+using ContextMemory.Core.Models;
+
+namespace ContextMemory.Core.Agentic.Mcp;
+
+/// Builds entries for MCP tools after lazy discovery (real schema from catalog).
+public static class McpPinnedToolFactory
+{
+ public static OllamaTool Create(McpToolDefinition mcpTool, AppRuntimeConfig runtimeConfig)
+ {
+ var fullDescription = AgenticToolDescriptionBuilder.BuildMcpDescription(mcpTool, runtimeConfig);
+ var parameters = mcpTool.InputSchema is null
+ ? OpenStubParameters()
+ : McpInputSchemaSanitizer.Sanitize(mcpTool.InputSchema);
+
+ return new OllamaTool(
+ "function",
+ new OllamaFunction(
+ mcpTool.QualifiedName,
+ SessionDiscoveryTools.ShortenDescription(fullDescription),
+ parameters));
+ }
+
+ public static object OpenStubParameters() => new Dictionary
+ {
+ ["type"] = "object",
+ ["properties"] = new Dictionary(),
+ ["additionalProperties"] = true
+ };
+
+ public static bool IsOpenStubParameters(object? parameters)
+ {
+ if (parameters is null)
+ return true;
+
+ if (parameters is Dictionary dict
+ && dict.TryGetValue("properties", out var props))
+ {
+ if (props is Dictionary nested && nested.Count == 0)
+ return true;
+ if (props is System.Collections.IDictionary idict && idict.Count == 0)
+ return true;
+ }
+
+ try
+ {
+ var json = parameters switch
+ {
+ string s => s,
+ System.Text.Json.JsonElement el => el.GetRawText(),
+ _ => System.Text.Json.JsonSerializer.Serialize(parameters)
+ };
+ return json.Contains("\"properties\":{}", StringComparison.Ordinal)
+ || json.Contains("\"properties\": {}", StringComparison.Ordinal);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/ContextMemory.Core/Agentic/Prompts/AgenticPromptProfile.cs b/src/ContextMemory.Core/Agentic/Prompts/AgenticPromptProfile.cs
index 114f7be..45377be 100644
--- a/src/ContextMemory.Core/Agentic/Prompts/AgenticPromptProfile.cs
+++ b/src/ContextMemory.Core/Agentic/Prompts/AgenticPromptProfile.cs
@@ -78,15 +78,15 @@ public static string ToolCallingHint(AgenticPromptProfile profile) =>
profile switch
{
AgenticPromptProfile.ComposerLike =>
- "Prefer tool_describe → tool call → short observation. Discover context lazily; avoid dumping large payloads into chat.",
+ "MCP: tool_search → tool_describe → call. Discover context lazily; avoid dumping large payloads into chat.",
AgenticPromptProfile.Claude =>
- "Use tools via the function-calling interface. Call tool_describe before unfamiliar tools.",
+ "Use tools via the function-calling interface. MCP: tool_search → tool_describe before unfamiliar tools.",
AgenticPromptProfile.Qwen =>
- "Emit tool/function calls in the backend JSON format. Call tool_describe before first use of unknown tools.",
+ "Emit tool/function calls in the backend JSON format. MCP: tool_search → tool_describe before first use.",
AgenticPromptProfile.OpenAi =>
- "Use OpenAI-style function calls. Call tool_describe before first use of unknown tools.",
+ "Use OpenAI-style function calls. MCP: tool_search → tool_describe before first use of unknown tools.",
_ =>
- "When invoking a tool, emit valid tool/function call JSON for this backend. Call tool_describe before unfamiliar tools."
+ "When invoking a tool, emit valid tool/function call JSON for this backend. MCP: tool_search → tool_describe before unfamiliar tools."
};
private static bool ContainsAny(string haystack, params string[] needles)
diff --git a/src/ContextMemory.Core/Agentic/Prompts/AgenticSystemPromptBuilder.cs b/src/ContextMemory.Core/Agentic/Prompts/AgenticSystemPromptBuilder.cs
index 2104ae2..f177396 100644
--- a/src/ContextMemory.Core/Agentic/Prompts/AgenticSystemPromptBuilder.cs
+++ b/src/ContextMemory.Core/Agentic/Prompts/AgenticSystemPromptBuilder.cs
@@ -22,7 +22,7 @@ public static string Build(
.ToList();
var mcpLine = mcpServers.Count > 0
- ? $"\nMCP servers: {string.Join(", ", mcpServers)} (use tool_describe before calling unfamiliar MCP tools)."
+ ? $"\nMCP servers: {string.Join(", ", mcpServers)} — discover with tool_search, then tool_describe, then call (do not invent tool names)."
: string.Empty;
var sb = new StringBuilder();
@@ -34,7 +34,7 @@ public static string Build(
sb.AppendLine(
"Dynamic context discovery: long tool outputs are stored as artifacts — "
+ "use artifact_tail/artifact_read with artifactId from observations. "
- + "Call tool_describe before the first invocation of any unfamiliar tool (MCP or built-in). "
+ + "MCP: tool_search → tool_describe → call. Call tool_describe before the first invocation of any unfamiliar tool. "
+ (capabilities.PreferSkillDiscovery
? "Skills: use skill_search then skill_read. "
: "Critical evidence rules are inlined below; other skills via skill_search / skill_read. ")
@@ -50,13 +50,9 @@ public static string Build(
"Configured MCP servers give live access to external systems (e.g. Zuora). "
+ "For questions about accounts, subscriptions, invoices, payments, or other live records:");
sb.AppendLine(
- "- You MUST call the relevant MCP tools in this turn (e.g. `…__query_objects`, `…__zuora_graphql`, "
- + "`…__get_account_summary`, `…__manage_customer_accounts`).");
+ "- First call `tool_search` with keywords (e.g. account, invoice, query), then `tool_describe` on the match, then call the qualified name.");
sb.AppendLine(
- "- Do NOT answer from imagination, refuse for lack of an ID, or claim tools are unavailable.");
- sb.AppendLine(
- "- If the schema is unclear, call `tool_describe` once, then call the tool with filters "
- + "(example: account status Canceled via `query_objects`).");
+ "- Do NOT invent MCP tool names. Do NOT answer from imagination, refuse for lack of an ID, or claim tools are unavailable.");
sb.AppendLine(
"- Prefer MCP over sandbox/python HTTP. If an MCP call fails, report the tool error.");
}
diff --git a/src/ContextMemory.Core/Agentic/Prompts/LlmCapabilities.cs b/src/ContextMemory.Core/Agentic/Prompts/LlmCapabilities.cs
index 2754d3d..0cd1a37 100644
--- a/src/ContextMemory.Core/Agentic/Prompts/LlmCapabilities.cs
+++ b/src/ContextMemory.Core/Agentic/Prompts/LlmCapabilities.cs
@@ -99,15 +99,22 @@ public static bool LooksLikeVisionModel(string model)
}
///
- /// Effective max MCP tools from tenant config (maxMcpToolsPerTurn). Default 12 when unset.
+ /// Absolute ceiling for MCP tools offered to the model per turn (Qwen dumps the selection into the system prompt).
+ /// Tenant config above this is clamped at resolve time so oversized catalogs cannot blow the context window.
+ ///
+ public const int AbsoluteMaxMcpToolsPerTurn = 12;
+
+ ///
+ /// Effective max MCP tools from tenant config (maxMcpToolsPerTurn). Default 12 when unset; hard-capped at
+ /// .
///
public static int ResolveMaxMcpTools(AppRuntimeConfig config)
{
var configured = config.Agentic.Tools.MaxMcpToolsPerTurn > 0
? config.Agentic.Tools.MaxMcpToolsPerTurn
- : 12;
+ : AbsoluteMaxMcpToolsPerTurn;
var caps = From(config);
- return Math.Min(configured, caps.MaxMcpToolsHint);
+ return Math.Clamp(Math.Min(configured, caps.MaxMcpToolsHint), 1, AbsoluteMaxMcpToolsPerTurn);
}
///
diff --git a/src/ContextMemory.Core/Agentic/ProseToolCallParser.cs b/src/ContextMemory.Core/Agentic/ProseToolCallParser.cs
index b1378a8..cca2b11 100644
--- a/src/ContextMemory.Core/Agentic/ProseToolCallParser.cs
+++ b/src/ContextMemory.Core/Agentic/ProseToolCallParser.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.RegularExpressions;
+using ContextMemory.Core.Agentic.Mcp;
using ContextMemory.Core.Models;
namespace ContextMemory.Core.Agentic;
@@ -74,7 +75,13 @@ public static partial class ProseToolCallParser
foreach (var call in promoted)
{
var name = call.Function?.Name?.Trim();
- if (string.IsNullOrWhiteSpace(name) || allowed.Count == 0 || !allowed.Contains(name))
+ var isMcpQualified = !string.IsNullOrWhiteSpace(name)
+ && McpToolNaming.TryParseQualifiedName(name, out _, out _);
+ // Allow MCP-qualified names through even when not yet pinned so the executor can
+ // reject invent-names with a tool_search hint (instead of silently dropping).
+ if (string.IsNullOrWhiteSpace(name)
+ || allowed.Count == 0
+ || (!allowed.Contains(name) && !isMcpQualified))
{
droppedUnknown++;
continue;
diff --git a/src/ContextMemory.Core/Agentic/SessionDiscoveryTools.cs b/src/ContextMemory.Core/Agentic/SessionDiscoveryTools.cs
index 842e63d..cf301c5 100644
--- a/src/ContextMemory.Core/Agentic/SessionDiscoveryTools.cs
+++ b/src/ContextMemory.Core/Agentic/SessionDiscoveryTools.cs
@@ -12,6 +12,7 @@ public static class SessionDiscoveryTools
public const string RuleRead = "rule_read";
public const string RuleSearch = "rule_search";
public const string ToolDescribe = "tool_describe";
+ public const string ToolSearch = "tool_search";
public const string SessionLogSearch = "session_log_search";
public const string DelegateTask = "delegate_task";
public const string TodoWrite = "todo_write";
@@ -24,6 +25,7 @@ public static bool IsDiscoveryTool(string? toolName) =>
|| string.Equals(toolName, RuleRead, StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, RuleSearch, StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, ToolDescribe, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(toolName, ToolSearch, StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, SessionLogSearch, StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, DelegateTask, StringComparison.OrdinalIgnoreCase)
|| string.Equals(toolName, TodoWrite, StringComparison.OrdinalIgnoreCase);
@@ -108,15 +110,28 @@ public static List BuildTools(AppRuntimeConfig runtimeConfig)
},
required = new[] { "ruleId" }
})),
+ new OllamaTool("function", new OllamaFunction(
+ ToolSearch,
+ "Search MCP tools by keywords (name, server, description). Returns qualified names + snippets; then use tool_describe before calling.",
+ new
+ {
+ type = "object",
+ properties = new
+ {
+ query = new { type = "string", description = "Keywords to match against MCP tools" },
+ maxResults = new { type = "integer", description = "Max matches (default = maxMcpToolsPerTurn)" }
+ },
+ required = new[] { "query" }
+ })),
new OllamaTool("function", new OllamaFunction(
ToolDescribe,
- "Load the full description/schema for a tool by name (MCP/builtins listed with short/open schemas).",
+ "Load the full description/schema for a tool by name (from tool_search or builtins). Pins MCP tools into the turn catalog.",
new
{
type = "object",
properties = new
{
- toolName = new { type = "string", description = "Exact tool/function name" }
+ toolName = new { type = "string", description = "Exact tool/function name (e.g. server__tool_name)" }
},
required = new[] { "toolName" }
})),
diff --git a/src/ContextMemory.Core/Engine/SystemPromptBuilder.cs b/src/ContextMemory.Core/Engine/SystemPromptBuilder.cs
index 2cf79dc..a2f2327 100644
--- a/src/ContextMemory.Core/Engine/SystemPromptBuilder.cs
+++ b/src/ContextMemory.Core/Engine/SystemPromptBuilder.cs
@@ -56,7 +56,8 @@ public string Build(
}
// Lean working set: smaller budget when a rolling summary already covers history.
- var budget = SessionWikiSettings.ResolveMaxWikiContextChars(config, _options);
+ // Also cap at ~75% of num_ctx so fat wiki sessions cannot blow the model window.
+ var budget = SessionWikiSettings.ResolveWikiInjectBudgetChars(config, _options);
if (!string.IsNullOrWhiteSpace(rollingSummary))
budget = Math.Min(budget, Math.Max(800, budget / 2));
var compiled = SessionWikiCompiler.Compile(snapshot, userQuery, budget);
diff --git a/src/ContextMemory.Core/Session/SessionWikiSettings.cs b/src/ContextMemory.Core/Session/SessionWikiSettings.cs
index 0d6ec91..9786c94 100644
--- a/src/ContextMemory.Core/Session/SessionWikiSettings.cs
+++ b/src/ContextMemory.Core/Session/SessionWikiSettings.cs
@@ -38,6 +38,26 @@ public static int ResolveAgentCompactionTokenBudget(
return Math.Min(wikiBudget, FitBudgetForContextWindow(numCtx.Value));
}
+ ///
+ /// Wiki inject char budget capped at ~75% of num_ctx (token→char ≈ ×4) so fat sessions
+ /// cannot dominate the context window.
+ ///
+ public static int ResolveWikiInjectBudgetChars(
+ AppRuntimeConfig config,
+ Configuration.ContextMemoryOptions defaults,
+ int? requestNumCtx = null)
+ {
+ var budget = ResolveMaxWikiContextChars(config, defaults);
+ var numCtx = requestNumCtx is > 0
+ ? requestNumCtx
+ : config.LlmOptions?.NumCtx;
+ if (numCtx is not > 0)
+ return budget;
+
+ var fitChars = FitBudgetForContextWindow(numCtx.Value) * 4;
+ return Math.Min(budget, Math.Max(512, fitChars));
+ }
+
/// Leave headroom for generation and tokenizer drift versus .
public static int FitBudgetForContextWindow(int nCtx) =>
Math.Max(512, nCtx * 3 / 4);
diff --git a/src/ContextMemory.Infrastructure/Agentic/Mcp/McpToolExecutor.cs b/src/ContextMemory.Infrastructure/Agentic/Mcp/McpToolExecutor.cs
index bd4f511..5aa989d 100644
--- a/src/ContextMemory.Infrastructure/Agentic/Mcp/McpToolExecutor.cs
+++ b/src/ContextMemory.Infrastructure/Agentic/Mcp/McpToolExecutor.cs
@@ -10,11 +10,16 @@ namespace ContextMemory.Infrastructure.Agentic.Mcp;
public sealed class McpToolExecutor : IToolExecutor
{
private readonly McpJsonRpcClient _client;
+ private readonly IMcpToolCatalog _catalog;
private readonly ILogger _logger;
- public McpToolExecutor(McpJsonRpcClient client, ILogger logger)
+ public McpToolExecutor(
+ McpJsonRpcClient client,
+ IMcpToolCatalog catalog,
+ ILogger logger)
{
_client = client;
+ _catalog = catalog;
_logger = logger;
}
@@ -89,6 +94,25 @@ public async Task ExecuteAsync(
};
}
+ // Invent-name guard: only tools present in the persisted catalog may run.
+ var catalog = await _catalog
+ .GetAllToolsAsync(runtimeConfig, cancellationToken)
+ .ConfigureAwait(false);
+ var known = catalog.Any(t =>
+ string.Equals(t.QualifiedName, toolCall.Function.Name, StringComparison.OrdinalIgnoreCase)
+ || (McpToolNaming.ServerNamesMatch(t.ServerName, serverName)
+ && string.Equals(t.Name, mcpToolName, StringComparison.OrdinalIgnoreCase)));
+ if (!known)
+ {
+ return new ToolExecutionResult
+ {
+ Output =
+ $"Unknown MCP tool `{toolCall.Function.Name}`. "
+ + "Do not invent tool names — call `tool_search` with keywords, then `tool_describe`, then call the exact qualified name.",
+ ExitCode = 1
+ };
+ }
+
try
{
var argumentsJson = McpQueryObjectsArgumentNormalizer.Normalize(mcpToolName, toolCall.Function.Arguments);
diff --git a/src/ContextMemory.Infrastructure/Agentic/SessionDiscoveryToolExecutor.cs b/src/ContextMemory.Infrastructure/Agentic/SessionDiscoveryToolExecutor.cs
index 5493e49..609dfe1 100644
--- a/src/ContextMemory.Infrastructure/Agentic/SessionDiscoveryToolExecutor.cs
+++ b/src/ContextMemory.Infrastructure/Agentic/SessionDiscoveryToolExecutor.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using ContextMemory.Core.Agentic;
using ContextMemory.Core.Agentic.Mcp;
+using ContextMemory.Core.Agentic.Prompts;
using ContextMemory.Core.Contracts;
using ContextMemory.Core.Models;
@@ -133,6 +134,18 @@ public async Task ExecuteAsync(
return Ok($"# Rule `{rule.Id}` — {rule.Name} ({rule.Activation})\n\n{body}");
}
+ if (string.Equals(name, SessionDiscoveryTools.ToolSearch, StringComparison.OrdinalIgnoreCase))
+ {
+ var query = GetString(root, "query");
+ if (string.IsNullOrWhiteSpace(query))
+ return Fail("tool_search requires query.");
+ var defaultMax = LlmCapabilitiesResolver.ResolveMaxMcpTools(runtimeConfig);
+ var maxResults = GetInt(root, "maxResults", defaultMax);
+ maxResults = Math.Clamp(maxResults, 1, LlmCapabilitiesResolver.AbsoluteMaxMcpToolsPerTurn);
+ return Ok(await SearchMcpToolsAsync(runtimeConfig, query, maxResults, cancellationToken)
+ .ConfigureAwait(false));
+ }
+
if (string.Equals(name, SessionDiscoveryTools.ToolDescribe, StringComparison.OrdinalIgnoreCase))
{
// Models often use name/tool instead of toolName with open schemas.
@@ -145,7 +158,7 @@ public async Task ExecuteAsync(
var described = await DescribeToolAsync(toolName.Trim(), runtimeConfig, cancellationToken)
.ConfigureAwait(false);
return described is null
- ? Fail($"Unknown tool: {toolName}")
+ ? Fail($"Unknown tool: {toolName}. Run tool_search to find MCP tool names.")
: Ok(described);
}
@@ -177,6 +190,56 @@ await _artifacts
return Fail($"Unsupported discovery tool: {name}");
}
+ private async Task SearchMcpToolsAsync(
+ AppRuntimeConfig runtimeConfig,
+ string query,
+ int maxResults,
+ CancellationToken cancellationToken)
+ {
+ var all = await _mcpCatalog
+ .GetAllToolsAsync(runtimeConfig, cancellationToken)
+ .ConfigureAwait(false);
+ if (all.Count == 0)
+ return "No MCP tools are registered for this app. Sync MCP integrations in Admin first.";
+
+ var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var scored = new List<(int Score, McpToolDefinition Tool, string Snippet)>();
+ foreach (var tool in all)
+ {
+ var hay = $"{tool.ServerName}\n{tool.Name}\n{tool.QualifiedName}\n{tool.Description}";
+ var score = 0;
+ foreach (var t in tokens)
+ {
+ if (tool.Name.Contains(t, StringComparison.OrdinalIgnoreCase))
+ score += 5;
+ if (tool.ServerName.Contains(t, StringComparison.OrdinalIgnoreCase))
+ score += 4;
+ if (tool.QualifiedName.Contains(t, StringComparison.OrdinalIgnoreCase))
+ score += 3;
+ if (hay.Contains(t, StringComparison.OrdinalIgnoreCase))
+ score += 1;
+ }
+
+ if (score <= 0)
+ continue;
+
+ var snippet = string.IsNullOrWhiteSpace(tool.Description)
+ ? tool.Name
+ : SessionDiscoveryTools.ShortenDescription(tool.Description, maxChars: 100);
+ scored.Add((score, tool, snippet));
+ }
+
+ if (scored.Count == 0)
+ return $"No matching MCP tools for '{query}'. Try broader keywords (e.g. query, account, invoice).";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"# MCP tool matches for `{query}`");
+ sb.AppendLine("Next: call tool_describe with the exact qualified name, then call the tool.");
+ foreach (var hit in scored.OrderByDescending(x => x.Score).Take(Math.Max(1, maxResults)))
+ sb.AppendLine($"- `{hit.Tool.QualifiedName}`: {hit.Snippet}");
+ return sb.ToString().TrimEnd();
+ }
+
private async Task DescribeToolAsync(
string toolName,
AppRuntimeConfig runtimeConfig,
diff --git a/tests/ContextMemory.Api.Tests/AgenticActionsE2ETests.cs b/tests/ContextMemory.Api.Tests/AgenticActionsE2ETests.cs
index b3cccda..0c4b7b1 100644
--- a/tests/ContextMemory.Api.Tests/AgenticActionsE2ETests.cs
+++ b/tests/ContextMemory.Api.Tests/AgenticActionsE2ETests.cs
@@ -158,7 +158,7 @@ await configStore.UpdateAsync(
},
Guardrails = new AgenticGuardrailsConfig
{
- MaxIterations = 5,
+ MaxIterations = 8,
ValidationMode = "deterministic"
}
}
@@ -192,7 +192,7 @@ await configStore.UpdateAsync(
},
Guardrails = new AgenticGuardrailsConfig
{
- MaxIterations = 5,
+ MaxIterations = 8,
ValidationMode = "deterministic"
}
}
diff --git a/tests/ContextMemory.Api.Tests/AgenticPromptProfileTests.cs b/tests/ContextMemory.Api.Tests/AgenticPromptProfileTests.cs
index ade06a4..e71acce 100644
--- a/tests/ContextMemory.Api.Tests/AgenticPromptProfileTests.cs
+++ b/tests/ContextMemory.Api.Tests/AgenticPromptProfileTests.cs
@@ -173,14 +173,50 @@ public void ResolveMaxMcpTools_RespectsTenantConfig_EvenForWeakModels()
LlmModel = "qwen3.5:9b",
Agentic = new AgenticConfig
{
- Tools = new AgenticToolsConfig { MaxMcpToolsPerTurn = 100 }
+ Tools = new AgenticToolsConfig { MaxMcpToolsPerTurn = 8 }
}
};
- Assert.Equal(100, LlmCapabilitiesResolver.ResolveMaxMcpTools(config));
+ Assert.Equal(8, LlmCapabilitiesResolver.ResolveMaxMcpTools(config));
Assert.Equal(ModelHarnessMode.Weak, LlmCapabilitiesResolver.From(config).HarnessMode);
}
+ [Fact]
+ public void ResolveMaxMcpTools_ClampsOversizedTenantConfig()
+ {
+ var config = new AppRuntimeConfig
+ {
+ AppId = "test",
+ LlmBackend = "ollama-native",
+ LlmModel = "bonsai-27b:latest",
+ Agentic = new AgenticConfig
+ {
+ Tools = new AgenticToolsConfig { MaxMcpToolsPerTurn = 100 }
+ }
+ };
+
+ Assert.Equal(
+ LlmCapabilitiesResolver.AbsoluteMaxMcpToolsPerTurn,
+ LlmCapabilitiesResolver.ResolveMaxMcpTools(config));
+ }
+
+ [Fact]
+ public void ResolveMaxMcpTools_DefaultsToAbsoluteMaxWhenUnset()
+ {
+ var config = new AppRuntimeConfig
+ {
+ AppId = "test",
+ Agentic = new AgenticConfig
+ {
+ Tools = new AgenticToolsConfig { MaxMcpToolsPerTurn = 0 }
+ }
+ };
+
+ Assert.Equal(
+ LlmCapabilitiesResolver.AbsoluteMaxMcpToolsPerTurn,
+ LlmCapabilitiesResolver.ResolveMaxMcpTools(config));
+ }
+
[Fact]
public void ProsePromotion_CanBeDisabledViaCapabilityFlag()
{
@@ -238,6 +274,7 @@ public void Build_IncludesToolsAndDefaultSkills()
Assert.Contains("`tool-calling-discipline`", prompt, StringComparison.Ordinal);
Assert.Contains("skill_read", prompt, StringComparison.OrdinalIgnoreCase);
Assert.Contains("tool_describe", prompt, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("tool_search", prompt, StringComparison.OrdinalIgnoreCase);
// Strong / lazy discovery: skill body is not stuffed into the system prompt.
Assert.DoesNotContain("Emit tool_calls with valid JSON", prompt, StringComparison.Ordinal);
}
diff --git a/tests/ContextMemory.Api.Tests/AgenticTests.cs b/tests/ContextMemory.Api.Tests/AgenticTests.cs
index aeec55a..6eed857 100644
--- a/tests/ContextMemory.Api.Tests/AgenticTests.cs
+++ b/tests/ContextMemory.Api.Tests/AgenticTests.cs
@@ -215,7 +215,7 @@ await configStore.UpdateAsync(
},
Guardrails = new AgenticGuardrailsConfig
{
- MaxIterations = 5,
+ MaxIterations = 8,
ValidationMode = "deterministic"
}
}
@@ -612,6 +612,75 @@ public void McpToolSelector_PrioritizesRelevantAndRecentTools()
Assert.Contains(selected, t => t.QualifiedName == "jira__get_issue");
}
+ [Fact]
+ public async Task AgenticToolRegistryService_OmitsMcpUntilPinnedViaRecent()
+ {
+ var mcpTools = new List
+ {
+ new() { ServerName = "zuora", Name = "query_objects", Description = "Query Zuora objects" },
+ new() { ServerName = "zuora", Name = "zuora_graphql", Description = "GraphQL" }
+ };
+ var catalog = new StubMcpCatalog(mcpTools);
+ var registry = new AgenticToolRegistryService(catalog, new CapabilityPolicyFilter());
+ var config = new AppRuntimeConfig
+ {
+ AppId = "demo",
+ Agentic = new AgenticConfig
+ {
+ Enabled = true,
+ Tools = new AgenticToolsConfig
+ {
+ MaxMcpToolsPerTurn = 12,
+ Integrations =
+ [
+ new IntegrationToolConfig
+ {
+ Type = "mcp",
+ Name = "zuora",
+ Enabled = true,
+ Url = "http://localhost/mcp"
+ }
+ ]
+ }
+ }
+ };
+
+ var firstHop = await registry.BuildToolsAsync(config, "list cancelled accounts", recentToolNames: null);
+ Assert.DoesNotContain(firstHop, t => t.Function.Name.StartsWith("zuora__", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(firstHop, t => t.Function.Name == SessionDiscoveryTools.ToolSearch);
+ Assert.Contains(firstHop, t => t.Function.Name == SessionDiscoveryTools.ToolDescribe);
+
+ var summary = await registry.BuildToolNamesSummaryAsync(config, "accounts", null);
+ Assert.Contains("tool_search", summary, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("zuora__query_objects", summary, StringComparison.OrdinalIgnoreCase);
+
+ var pinned = await registry.BuildToolsAsync(config, "again", ["zuora__query_objects"]);
+ Assert.Contains(pinned, t => t.Function.Name == "zuora__query_objects");
+ Assert.DoesNotContain(pinned, t => t.Function.Name == "zuora__zuora_graphql");
+ }
+
+ private sealed class StubMcpCatalog(IReadOnlyList tools) : IMcpToolCatalog
+ {
+ public Task> GetToolsAsync(
+ AppRuntimeConfig runtimeConfig,
+ string? userQuery = null,
+ IReadOnlyList? recentToolNames = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(tools);
+
+ public Task> GetAllToolsAsync(
+ AppRuntimeConfig runtimeConfig,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(tools);
+
+ public void Invalidate(string appId) { }
+
+ public Task> SyncAsync(
+ AppRuntimeConfig runtimeConfig,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult>([]);
+ }
+
[Fact]
public void McpToolAccess_FilterCatalog_AppliesAllowAndDenyLists()
{
@@ -762,7 +831,7 @@ await configStore.UpdateAsync(
},
Guardrails = new AgenticGuardrailsConfig
{
- MaxIterations = 5,
+ MaxIterations = 8,
ValidationMode = "deterministic"
}
}
@@ -783,9 +852,12 @@ await configStore.UpdateAsync(
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
- Assert.Contains("A-001", body, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Conta A-001", body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Active", body, StringComparison.OrdinalIgnoreCase);
- Assert.Equal(2, _factory.AgenticHandler.ChatRequests.Count);
+ var joined = string.Join('\n', _factory.AgenticHandler.ChatRequestBodies);
+ Assert.Contains("tool_search", joined, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("tool_describe", joined, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("zuora-mcp__get_account", joined, StringComparison.OrdinalIgnoreCase);
}
}
diff --git a/tests/ContextMemory.Api.Tests/ClientSideToolCallingTests.cs b/tests/ContextMemory.Api.Tests/ClientSideToolCallingTests.cs
index ed8ff89..99d1953 100644
--- a/tests/ContextMemory.Api.Tests/ClientSideToolCallingTests.cs
+++ b/tests/ContextMemory.Api.Tests/ClientSideToolCallingTests.cs
@@ -61,6 +61,80 @@ public void EnsureCatalog_AppendsOnce()
Assert.Contains("""{"tool":""", system, StringComparison.Ordinal);
}
+ [Fact]
+ public void EnsureCatalog_ReplacesWhenToolsChange()
+ {
+ var messages = new List
+ {
+ new() { Role = "system", Content = "base" },
+ new() { Role = "user", Content = "q" }
+ };
+ var tools1 = new List
+ {
+ new("function", new OllamaFunction("wiki_search", "Search wiki", new { type = "object" }))
+ };
+ var tools2 = new List
+ {
+ new("function", new OllamaFunction("wiki_search", "Search wiki", new { type = "object" })),
+ new("function", new OllamaFunction(
+ "zuora__query_objects",
+ "Query Zuora",
+ new Dictionary
+ {
+ ["type"] = "object",
+ ["properties"] = new Dictionary
+ {
+ ["object"] = new { type = "string" }
+ }
+ }))
+ };
+
+ ClientSideToolCalling.EnsureCatalogInSystemPrompt(messages, tools1);
+ ClientSideToolCalling.EnsureCatalogInSystemPrompt(messages, tools2);
+
+ var system = messages[0].Content!;
+ Assert.Equal(1, system.Split(ClientSideToolCalling.CatalogMarker, StringSplitOptions.None).Length - 1);
+ Assert.Contains("zuora__query_objects", system, StringComparison.Ordinal);
+ Assert.Contains("params:", system, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void BuildCatalog_SkipsParamsForOpenStubSchemas()
+ {
+ var tools = new List
+ {
+ new("function", new OllamaFunction(
+ "open_stub",
+ "Open schema tool",
+ new Dictionary
+ {
+ ["type"] = "object",
+ ["properties"] = new Dictionary(),
+ ["additionalProperties"] = true
+ })),
+ new("function", new OllamaFunction(
+ "real_schema",
+ "Has real params",
+ new Dictionary
+ {
+ ["type"] = "object",
+ ["properties"] = new Dictionary
+ {
+ ["q"] = new { type = "string" }
+ }
+ }))
+ };
+
+ var catalog = ClientSideToolCalling.BuildCatalog(tools);
+ Assert.Contains("`open_stub`", catalog, StringComparison.Ordinal);
+ Assert.Contains("`real_schema`", catalog, StringComparison.Ordinal);
+ var openIdx = catalog.IndexOf("`open_stub`", StringComparison.Ordinal);
+ var realIdx = catalog.IndexOf("`real_schema`", StringComparison.Ordinal);
+ Assert.True(openIdx >= 0 && realIdx > openIdx);
+ Assert.DoesNotContain("params:", catalog[openIdx..realIdx], StringComparison.Ordinal);
+ Assert.Contains("params:", catalog[realIdx..], StringComparison.Ordinal);
+ }
+
[Fact]
public void BuildCatalog_NeutralizesAngleBracketsInDescriptions()
{
diff --git a/tests/ContextMemory.Api.Tests/Fakes/AgenticStubOllamaHandler.cs b/tests/ContextMemory.Api.Tests/Fakes/AgenticStubOllamaHandler.cs
index 2862aa4..d6310cb 100644
--- a/tests/ContextMemory.Api.Tests/Fakes/AgenticStubOllamaHandler.cs
+++ b/tests/ContextMemory.Api.Tests/Fakes/AgenticStubOllamaHandler.cs
@@ -95,26 +95,9 @@ protected override Task SendAsync(
if (awaitingToolResult)
{
- var isMcp = body.Contains("zuora-mcp__get_account", StringComparison.Ordinal);
- if (isMcp)
- {
- if (useClientSideReply)
- return Task.FromResult(OllamaClientSideToolCall("zuora-mcp__get_account", """{"accountId":"A-001"}"""));
-
- return Task.FromResult(isOpenAiChat
- ? OpenAiToolCall("zuora-mcp__get_account", """{"accountId":"A-001"}""")
- : OllamaToolCall("zuora-mcp__get_account", """{"accountId":"A-001"}"""));
- }
-
- if (body.Contains("delete", StringComparison.OrdinalIgnoreCase))
- {
- if (useClientSideReply)
- return Task.FromResult(OllamaClientSideToolCall("shell_execute", """{"command":"delete --force user-test"}"""));
-
- return Task.FromResult(isOpenAiChat
- ? OpenAiToolCall("shell_execute", """{"command":"delete --force user-test"}""")
- : OllamaToolCall("shell_execute", """{"command":"delete --force user-test"}"""));
- }
+ var toolCall = ResolveNextToolCall(body, useClientSideReply, isOpenAiChat);
+ if (toolCall is not null)
+ return Task.FromResult(toolCall);
if (useClientSideReply)
return Task.FromResult(OllamaClientSideToolCall("shell_execute", """{"command":"echo agentic-ok"}"""));
@@ -126,10 +109,16 @@ protected override Task SendAsync(
var isMcpFollowUp = hasToolResponseAlready
&& (body.Contains("zuora-mcp__get_account", StringComparison.Ordinal)
- || body.Contains("[mock:zuora-mcp]", StringComparison.Ordinal));
+ || body.Contains("[mock:zuora-mcp]", StringComparison.Ordinal)
+ || body.Contains("MCP tool matches", StringComparison.Ordinal));
if (isMcpFollowUp)
{
+ // Still mid discovery / call chain?
+ var midChain = ResolveNextToolCall(body, useClientSideReply: isOllamaChat && hasClientCatalog && !hasNativeTools, isOpenAiChat);
+ if (midChain is not null)
+ return Task.FromResult(midChain);
+
const string mcpAnswer = "Conta A-001 encontrada via Zuora MCP. Estado: Active.";
return Task.FromResult(isOpenAiChat ? OpenAiText(mcpAnswer) : OllamaText(mcpAnswer));
}
@@ -167,6 +156,94 @@ protected override Task SendAsync(
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
}
+ ///
+ /// Lazy MCP: tool_search → tool_describe → zuora-mcp__get_account.
+ /// Also handles shell delete / echo when not an MCP scenario.
+ ///
+ private static HttpResponseMessage? ResolveNextToolCall(string body, bool useClientSideReply, bool isOpenAiChat)
+ {
+ var wantsZuora = body.Contains("A-001", StringComparison.OrdinalIgnoreCase)
+ || body.Contains("Zuora", StringComparison.OrdinalIgnoreCase)
+ || body.Contains("zuora-mcp", StringComparison.OrdinalIgnoreCase);
+
+ if (wantsZuora)
+ {
+ var hasGetAccountResult = body.Contains("[mock:zuora-mcp]", StringComparison.Ordinal);
+ if (hasGetAccountResult)
+ return null;
+
+ // Raw JSON bodies escape backticks as \u0060 — detect describe without relying on `.
+ var hasDescribeResult =
+ body.Contains("zuora-mcp__get_account", StringComparison.Ordinal)
+ && (body.Contains("Input schema", StringComparison.Ordinal)
+ || body.Contains("## Parameters", StringComparison.Ordinal)
+ || body.Contains("\\u0060zuora-mcp__get_account\\u0060", StringComparison.Ordinal));
+ var hasSearchResult = body.Contains("MCP tool matches", StringComparison.Ordinal);
+ var getAccountInCatalog = body.Contains("zuora-mcp__get_account", StringComparison.Ordinal)
+ && body.Contains("## Tool catalog", StringComparison.Ordinal);
+
+ if (!hasSearchResult && !hasDescribeResult
+ && body.Contains("tool_search", StringComparison.Ordinal))
+ {
+ return EmitToolCall("tool_search", """{"query":"account"}""", useClientSideReply, isOpenAiChat);
+ }
+
+ if (hasSearchResult && !hasDescribeResult
+ && body.Contains("tool_describe", StringComparison.Ordinal))
+ {
+ return EmitToolCall(
+ "tool_describe",
+ """{"toolName":"zuora-mcp__get_account"}""",
+ useClientSideReply,
+ isOpenAiChat);
+ }
+
+ if (hasDescribeResult || getAccountInCatalog)
+ {
+ return EmitToolCall(
+ "zuora-mcp__get_account",
+ """{"accountId":"A-001"}""",
+ useClientSideReply,
+ isOpenAiChat);
+ }
+ }
+
+ if (body.Contains("delete", StringComparison.OrdinalIgnoreCase))
+ {
+ return EmitToolCall(
+ "shell_execute",
+ """{"command":"delete --force user-test"}""",
+ useClientSideReply,
+ isOpenAiChat);
+ }
+
+ if (body.Contains("shell_execute", StringComparison.Ordinal)
+ || body.Contains("echo agentic", StringComparison.OrdinalIgnoreCase)
+ || body.Contains("Executa echo", StringComparison.OrdinalIgnoreCase))
+ {
+ return EmitToolCall(
+ "shell_execute",
+ """{"command":"echo agentic-ok"}""",
+ useClientSideReply,
+ isOpenAiChat);
+ }
+
+ return null;
+ }
+
+ private static HttpResponseMessage EmitToolCall(
+ string name,
+ string argumentsJson,
+ bool useClientSideReply,
+ bool isOpenAiChat)
+ {
+ if (useClientSideReply)
+ return OllamaClientSideToolCall(name, argumentsJson);
+ return isOpenAiChat
+ ? OpenAiToolCall(name, argumentsJson)
+ : OllamaToolCall(name, argumentsJson);
+ }
+
private string GetFinalAnswerContent()
{
_finalAnswerCount++;
diff --git a/tests/ContextMemory.Api.Tests/ProseToolCallParserTests.cs b/tests/ContextMemory.Api.Tests/ProseToolCallParserTests.cs
index 296a2f5..9f73d9c 100644
--- a/tests/ContextMemory.Api.Tests/ProseToolCallParserTests.cs
+++ b/tests/ContextMemory.Api.Tests/ProseToolCallParserTests.cs
@@ -176,6 +176,34 @@ public void FilterAgainstCatalog_KeepsKnownTools_CapsAndDropsUnknown()
Assert.Equal("canvas_upsert", filtered[1].Function.Name);
}
+ [Fact]
+ public void FilterAgainstCatalog_AllowsUnpinnedMcpQualifiedNames()
+ {
+ var catalog = new List
+ {
+ new("function", new OllamaFunction("tool_search", null, null))
+ };
+
+ var raw = new List
+ {
+ new(new OllamaFunctionCall("zuora__invented_tool", """{"x":1}""")),
+ new(new OllamaFunctionCall("tool_search", """{"query":"account"}"""))
+ };
+
+ var filtered = ProseToolCallParser.FilterAgainstCatalog(
+ raw,
+ catalog,
+ maxPerTurn: 6,
+ out var droppedUnknown,
+ out _,
+ out _);
+
+ Assert.NotNull(filtered);
+ Assert.Equal(2, filtered!.Count);
+ Assert.Equal(0, droppedUnknown);
+ Assert.Equal("zuora__invented_tool", filtered[0].Function.Name);
+ }
+
[Fact]
public void FilterAgainstCatalog_ReturnsNull_WhenCatalogEmpty()
{
diff --git a/tests/ContextMemory.Api.Tests/SessionWikiSettingsTests.cs b/tests/ContextMemory.Api.Tests/SessionWikiSettingsTests.cs
index 485b56a..8528c0a 100644
--- a/tests/ContextMemory.Api.Tests/SessionWikiSettingsTests.cs
+++ b/tests/ContextMemory.Api.Tests/SessionWikiSettingsTests.cs
@@ -105,4 +105,30 @@ public void ResolveAgentCompactionTokenBudget_UsesWikiBudgetWhenNumCtxUnset()
Assert.Equal(24_000, SessionWikiSettings.ResolveAgentCompactionTokenBudget(config, defaults, null));
}
+
+ [Fact]
+ public void ResolveWikiInjectBudgetChars_CapsToNumCtxHeadroom()
+ {
+ var defaults = new ContextMemory.Core.Configuration.ContextMemoryOptions { MaxWikiContextChars = 24_000 };
+ var config = new AppRuntimeConfig
+ {
+ AppId = "a",
+ MaxWikiContextChars = 24_000,
+ LlmOptions = new LlmGenerationConfig { NumCtx = 4096 }
+ };
+
+ // FitBudget(4096)=3072 tokens → ×4 = 12288 chars
+ Assert.Equal(12_288, SessionWikiSettings.ResolveWikiInjectBudgetChars(config, defaults));
+ // request override 8192 → Fit=6144 → ×4 = 24576, but budget is 24000
+ Assert.Equal(24_000, SessionWikiSettings.ResolveWikiInjectBudgetChars(config, defaults, 8192));
+ }
+
+ [Fact]
+ public void ResolveWikiInjectBudgetChars_UsesConfiguredWhenNumCtxUnset()
+ {
+ var defaults = new ContextMemory.Core.Configuration.ContextMemoryOptions { MaxWikiContextChars = 4_000 };
+ var config = new AppRuntimeConfig { AppId = "a", MaxWikiContextChars = 0 };
+
+ Assert.Equal(4_000, SessionWikiSettings.ResolveWikiInjectBudgetChars(config, defaults));
+ }
}
From a1baf6823f42b4c5c50fa5d65aead05cbba3c4e5 Mon Sep 17 00:00:00 2001
From: Vitor Castro
Date: Sun, 20 Sep 2026 20:59:43 +0100
Subject: [PATCH 2/2] Position ContextMemory as BYO-LLM for HN: LlmEndpoint
alias and engine-agnostic docs.
---
.env.example | 8 +-
README.md | 148 ++++++------------
docker-compose.ghcr.yml | 3 +
docker-compose.network.yml | 1 +
docker-compose.yml | 3 +
docs/README.md | 3 +-
docs/admin-ui.md | 2 +-
docs/ops.md | 4 +-
docs/self-host.md | 46 ++++--
docs/show-hn.md | 44 ++++++
mcp-server/README.md | 6 +-
.../LlmAdapterResolver.cs | 6 +-
src/ContextMemory.Adapters/OllamaAdapter.cs | 9 +-
.../Models/AdminModels.cs | 1 +
.../Pages/Apps/Config/AppConfigLlm.razor | 10 +-
.../Pages/Apps/RegisterApp.razor | 2 +-
.../Pages/Settings.razor | 4 +
.../Endpoints/HealthEndpoint.cs | 3 +
.../Endpoints/LlmBackendModelListing.cs | 2 +-
.../AppConfigBootstrapHostedService.cs | 3 +
src/ContextMemory.Api/appsettings.json | 3 +-
.../Configuration/ContextMemoryOptions.cs | 22 +++
.../Models/AppRuntimeConfig.cs | 2 +-
23 files changed, 205 insertions(+), 130 deletions(-)
create mode 100644 docs/show-hn.md
diff --git a/.env.example b/.env.example
index 79db4bf..2b5e60a 100644
--- a/.env.example
+++ b/.env.example
@@ -5,11 +5,15 @@ ADMIN_PORT=5200
PUBLIC_API_URL=http://localhost:5100
ADMIN_PUBLIC_URL=http://localhost:5200
-# Host Ollama — ContextMemory talks to it via OpenAI-compatible /v1
+# Preferred host-level LLM base URL (any OpenAI-compatible or Ollama-compatible engine).
+# When empty, Compose falls back to OLLAMA_ENDPOINT below.
+# LLM_ENDPOINT=http://host.docker.internal:8000
+
+# DX default: Ollama on the host (used when LLM_ENDPOINT is unset)
OLLAMA_ENDPOINT=http://host.docker.internal:11434
DEFAULT_LLM_MODEL=qwen3.5:9b
-# Optional: OpenAI / vLLM / any OpenAI-compatible host defaults
+# Optional: OpenAI / vLLM / ExLlamaSharp / any OpenAI-compatible host defaults
# OPENAI_ENDPOINT=http://host.docker.internal:8000
# OPENAI_API_KEY=
diff --git a/README.md b/README.md
index 4226f1c..38af2bd 100644
--- a/README.md
+++ b/README.md
@@ -11,14 +11,16 @@
·
Docs
·
- Demo
+ Aha storyboard (GIF)
+ ·
+ Show HN
-
+
@@ -29,7 +31,8 @@
One OpenAI-compatible /v1 URL: wiki memory, agentic tool loop, skills/guardrails, MCP, sandbox, and HITL —
- self-hosted or Cloud. Not a vector black box. Not classic RAG inject.
+ self-hosted or Cloud. Bring your own LLM (any OpenAI-compatible engine).
+ Not a vector black box. Not classic RAG inject.
---
@@ -53,7 +56,7 @@ Your client (OpenAI SDK / Cursor MCP / curl)
│ ContextMemory (.NET 9) │
│ Auth · session wiki · Global Wiki tool │
│ Agentic loop · skills · guardrails · HITL │
-│ LLM backend (per app, OpenAI-compatible) │
+│ LLM backend (per app — BYO engine) │
└───────┬──────────────────┬─────────────────┘
▼ ▼
sandbox-runtime mcp-runtime / MCP servers
@@ -63,6 +66,8 @@ Your client (OpenAI SDK / Cursor MCP / curl)
**Honest boundaries:** this is a **gateway + server-side harness**, not a client agent framework (LangGraph/CrewAI) and not an agent OS (Letta). You keep your OpenAI client; the loop runs on the server.
+**LLM engines:** ContextMemory does **not** ship or lock to one inference stack. Per tenant you pick any OpenAI-compatible `/v1` host — Ollama, vLLM, LM Studio, [ExLlamaSharp](https://github.com/Kortexio/ExLlamaSharp), OpenAI, Azure-compatible, LiteLLM, custom. Compose defaults to Ollama only for zero-friction local DX; swap in **Admin → Config → LLM**.
+
How we compare (Mem0 / Zep / Letta / **why we are not RAG**): [`docs/compare.md`](docs/compare.md).
---
@@ -74,77 +79,26 @@ How we compare (Mem0 / Zep / Letta / **why we are not RAG**): [`docs/compare.md`
| Memory that survives turns without rewriting your client | Session markdown wiki + history inject; send only the new message |
| Memory you can open, edit, audit | Files on disk / Postgres — not opaque embeddings |
| Shared company/docs knowledge in chat | **Global Wiki** digests + on-demand `wiki_search` / `wiki_grep` (**not** classic RAG / embeddings) |
-| Point-in-time facts | Temporal revisions (`asOf` / supersede) on Global Wiki |
-| Tools without a second orchestrator | Same `/v1`: sandbox + MCP integrations + wiki tools |
-| Safer agents | Skills & guardrail packs, validators, confirmation keywords, HITL `[CONFIRM:id]` |
-| Cursor / Claude permanent memory fast | MCP wedge: `memory_save` / `memory_search` / `memory_get` (+ `wiki_search`, `session_recall`) |
-| Any LLM per tenant | Ollama, vLLM, LM Studio, OpenAI, Azure-compatible `/v1`, custom |
-| Operate without writing a test client | **Admin** + **Playground** (timeline, todos, artifacts, HITL) |
-| Full control on your infra | Docker/Compose self-host (API + Admin + mcp-runtime + sandbox) |
-| Zero ops | [Kortexio Cloud](https://kortexio.io) (`cmk_live_…`) |
+| Tools without a second orchestrator | Same `/v1`: sandbox + MCP + wiki tools |
+| Safer agents | Skills & guardrail packs, validators, HITL `[CONFIRM:id]` |
+| Cursor / Claude permanent memory fast | MCP wedge: `memory_save` / `memory_search` / `memory_get` |
+| Any LLM per tenant | BYO `/v1` — Ollama, vLLM, LM Studio, ExLlamaSharp, OpenAI, Azure-compatible, custom |
+| Operate without a test client | **Admin** + **Playground** |
+| Full control / zero ops | Docker self-host · [Kortexio Cloud](https://kortexio.io) (`cmk_live_…`) |
---
-## Capabilities (full surface)
-
-### Memory
-
-- **Session wiki** — markdown pages, index, execution log; compaction; update every N turns; optional dedicated maintainer model; **rolling summary** in the system prompt
-- **History** — last N messages (per-app budget); mid-turn **compaction** archives long transcripts as artifacts when over `MaxContextTokens`
-- **Persona & rules** — `basePersona`, `businessRules`, `formatRules`, `wikiSchema` per app
-- **Global Wiki** — app-scoped docs; ingest/batch APIs; digests; FTS; tools `wiki_search` / `wiki_grep`; revisions / audit / `asOf`
-- **No vector RAG** — discovery is digests + lexical/FTS + tools (Cursor-style), not embeddings
-- **Web search** (optional) — enrich turns; can persist into wiki
-
-### Agentic harness (server-side)
-
-When agentic tools are enabled, the gateway runs a tool loop:
-
-- **Iterations / timeout** — max steps, loop timeout, partial answer on timeout; mid-turn compaction phase
-- **Built-in tools** — `wiki_search`, `wiki_grep`; sandbox `shell_execute` / `python_execute` / `node_execute` / `container_execute` (self-hosted or ACA); discovery helpers (`artifact_*`, `skill_*`, `rule_*`, `tool_describe`, `session_log_search`, `delegate_task`, `todo_write`)
-- **Lazy tool schemas** — MCP and built-ins listed with short/open schemas; `tool_describe` for full args
-- **Artifacts** — long outputs (and all sandbox runs) stored per session; loop keeps a short preview + `artifactId`
-- **Subagents** — `delegate_task` (depth 1, isolated child session)
-- **MCP tools** — per-app catalog (`server__tool`), allow/deny, max tools per turn, OAuth/credentials
-- **Validation modes** — `deterministic` · `hybrid` · `llm-judge`
-- **Hooks** — PreToolUse / PostToolUse guardrail kinds
-- **HITL** — pause before destructive tools; `[CONFIRM:id]` / cancel; checkpoint in session log
-- **Progress** — `context_memory.agentic` phases (incl. Compacting / Subagent*) + `context_memory.discovery` counters
-- **Prompt profiles** — `auto` / `ollama` / `openai` / `claude` / `qwen` / `composer`
-- **Network egress policy** — restricted/allowed + host allowlists
-
-### Skills & guardrails
-
-- **Platform catalog** — shared skills/guardrails (Admin → Skills); import `.skill.json` / `.guardrail.json`
-- **Activation** — `skill` | `always_on` | `requestable` (rules loaded via `rule_search` / `rule_read`)
-- **Per-app policies** — additive inventory on top of platform defaults
-- Seeded examples include anti-hallucination, tool-calling discipline, wiki-first-for-docs, privacy/secrets, transparent failures, and more
-- Guardrail kinds include URL fetch, sandbox claims, tool-failure disclosure, blocked patterns, pre/post tool-use hooks
+## Capabilities (summary)
-### MCP (two directions)
+Full detail: [`docs/architecture-and-features.md`](docs/architecture-and-features.md) · Admin: [`docs/admin-ui.md`](docs/admin-ui.md) · HITL: [`docs/hitl.md`](docs/hitl.md).
-| Direction | Role |
+| Area | Highlights |
|---|---|
-| **Outbound wedge** | Cursor/Claude → ContextMemory (`mcp-server/`) for memory tools |
-| **Inbound catalog** | ContextMemory agent → your MCP servers (HTTP/stdio via `mcp-runtime`, OAuth, catalog rebuild) |
-
-### Admin console
-
-Blazor Admin (`:5200`, Master Key auth) — operators configure tenants without touching JSON by hand:
-
-| Area | What you configure / do |
-|---|---|
-| Dashboard | Apps, requests, wiki/web-search stats |
-| New app / credentials | Register tenant, mint/rotate `cm_live_…` |
-| **Playground** | Chat Lab: agentic timeline (Compacting/Subagent), Todos, Artifacts, wiki refs, HITL |
-| LLM | Backend, model, endpoint, API key, history, streaming, think |
-| Memory & wiki | Session budgets, compaction, maintainer model, Global Wiki on/off + char budget |
-| Web search | Provider, mode, persist-to-wiki |
-| Rate limits | RPM/TPM (+ agentic weight) |
-| Persona & rules | Persona, business/format rules, wiki schema |
-| Agentic | Full gateway knobs: tools, MCP, sandbox, validators, HITL, egress |
-| Skills & policies | Platform + per-app skills/guardrails |
-| Settings | API base URL, Master Key, health |
+| **Memory** | Session wiki + rolling summary; history budgets; Global Wiki digests/FTS/revisions (`asOf`); **no vector RAG** |
+| **Agentic** | Server-side tool loop; sandbox; MCP catalog; artifacts; subagents; validators; HITL; egress policy |
+| **Skills** | Platform + per-app skills/guardrails (`skill` / `always_on` / `requestable`) |
+| **MCP** | Outbound wedge (Cursor → CM) · inbound catalog (CM → your MCP servers) |
+| **Ops** | Admin UI · File or Postgres · Prometheus `/metrics` · Compose (API + Admin + mcp-runtime + sandbox) |