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

License: AGPL-3.0 .NET 9 Docker GHCR - Docker CI + Docker CI Tests GitHub commit activity

@@ -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) |

ContextMemory Admin dashboard @@ -153,41 +107,33 @@ Blazor Admin (`:5200`, Master Key auth) — operators configure tenants without

LLM backend picker   - Agentic gateway config -

- -

Admin Playground -   - Skills and guardrails

-Guide: [`docs/admin-ui.md`](docs/admin-ui.md) · HITL: [`docs/hitl.md`](docs/hitl.md) - -### Self-host stack - -Docker Compose brings up a full local platform: - -| Service | Role | -|---|---| -| **API** (`:5100`) | Gateway `/v1`, admin APIs, metrics | -| **Admin** (`:5200`) | Operator UI | -| **mcp-runtime** | Stdio/HTTP MCP sidecar | -| **sandbox-runtime** | Isolated shell/python/node execution | - -Persistence: **File** (single-node) or **Postgres** (HA + FTS). Images: `ghcr.io/kortexio/contextmemory` · `ghcr.io/kortexio/contextmemory-admin`. +--- -### Observability +## Quickstart (5 minutes) -Prometheus `/metrics` · OpenTelemetry (Aspire) · per-app telemetry in Admin. +**Bring your own LLM.** The gateway talks OpenAI-compatible `/v1` (and Ollama native `/api/chat` when you need `num_ctx`). Compose/GHCR defaults point at Ollama on the host for DX — change anytime in **Admin → Config → LLM** or `PATCH /admin/apps/{id}/config`. ---- +### 1a. Start with any `/v1` engine (vLLM, LM Studio, ExLlamaSharp, OpenAI, …) -## Quickstart (5 minutes) +```bash +docker run --rm -p 5100:8080 \ + -v contextmemory-data:/app/data \ + -e ContextMemory__MasterKey=cm_master_dev_key_change_me \ + -e ContextMemory__Apps__demo-dev__ApiKey=cm_live_dev_key_change_me \ + -e ContextMemory__Apps__demo-dev__LlmBackend=openai-compatible \ + -e ContextMemory__Apps__demo-dev__LlmModel=my-model \ + -e ContextMemory__Apps__demo-dev__LlmEndpoint=http://host.docker.internal:8000 \ + -e ContextMemory__OpenAiEndpoint=http://host.docker.internal:8000 \ + --add-host=host.docker.internal:host-gateway \ + ghcr.io/kortexio/contextmemory:latest +``` -### 1. Start the gateway +Prefer a host-level default for all apps: set `ContextMemory__LlmEndpoint` (alias; falls back to `OllamaEndpoint` for older Compose files). -Default demo points at Ollama on the host. Swap the backend anytime in **Admin → Config → LLM** or `PATCH /admin/apps/{id}/config`. +### 1b. Or start with Ollama on the host (zero-friction DX) ```bash docker run --rm -p 5100:8080 \ @@ -195,14 +141,12 @@ docker run --rm -p 5100:8080 \ -e ContextMemory__MasterKey=cm_master_dev_key_change_me \ -e ContextMemory__Apps__demo-dev__ApiKey=cm_live_dev_key_change_me \ -e ContextMemory__Apps__demo-dev__LlmModel=qwen3.5:9b \ - -e ContextMemory__OllamaEndpoint=http://host.docker.internal:11434 \ + -e ContextMemory__LlmEndpoint=http://host.docker.internal:11434 \ --add-host=host.docker.internal:host-gateway \ ghcr.io/kortexio/contextmemory:latest ``` -Full stack (API + Admin + MCP + sandbox): see [`docs/self-host.md`](docs/self-host.md) / `docker-compose.yml`. - -Admin UI: typically `http://localhost:5200`. +Full stack (API + Admin + MCP + sandbox): [`docs/self-host.md`](docs/self-host.md) / `docker-compose.yml`. Admin: `http://localhost:5200`. No Docker? Use **[Kortexio Cloud](https://kortexio.io)** (`cmk_live_…`) and set `CONTEXTMEMORY_BASE_URL` to the cloud API. @@ -222,7 +166,7 @@ Paste into **Cursor → Settings → MCP** (or `~/.cursor/mcp.json`). Same snipp | **A** | `Remember: staging DB is postgres-staging-01` | `memory_save` | | **B** (new) | `What is our staging DB?` | `memory_search` + answer | -CLI: `./scripts/aha-demo.sh` or `.\scripts\aha-demo.ps1` · storyboard: [`docs/aha-demo.html`](docs/aha-demo.html) +CLI: `./scripts/aha-demo.sh` or `.\scripts\aha-demo.ps1` · storyboard (for GIF recording): [`docs/aha-demo.html`](docs/aha-demo.html) ### Cloud vs self-host @@ -231,6 +175,7 @@ CLI: `./scripts/aha-demo.sh` or `.\scripts\aha-demo.ps1` · storyboard: [`docs/a | Best for | Zero ops | Full control (API + Admin + MCP + sandbox) | | Key | `cmk_live_…` (no `X-App-Id`) | `cm_live_…` + `X-App-Id` | | Chat body | Identical OpenAI `/v1` | Identical OpenAI `/v1` | +| LLM | BYO provider in dashboard | BYO engine in Admin / env | Guides: [Cloud](docs/cloud.md) · [Self-host](docs/self-host.md) @@ -253,7 +198,8 @@ Thin header helpers (**not** full SDKs): [`@kortexio/contextmemory`](https://www | Doc | Topic | |---|---| | [docs/compare.md](docs/compare.md) | Why it exists · vs Mem0 / Zep / Letta · **why we are not RAG** | -| [docs/architecture-and-features.md](docs/architecture-and-features.md) | Wiki, temporal facts, agentic, skills | +| [docs/show-hn.md](docs/show-hn.md) | Suggested Show HN title + blurb | +| [docs/architecture-and-features.md](docs/architecture-and-features.md) | Wiki, temporal facts, agentic, skills, **LLM backends** | | [docs/admin-ui.md](docs/admin-ui.md) | Admin UI map | | [docs/hitl.md](docs/hitl.md) | Human-in-the-loop | | [docs/api.md](docs/api.md) | HTTP API | diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index e84bc5d..2433644 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -20,7 +20,10 @@ services: ASPNETCORE_URLS: http://+:8080 ContextMemory__PersistenceProvider: ${PERSISTENCE_PROVIDER:-File} ContextMemory__DataPath: /app/data + ContextMemory__LlmEndpoint: ${LLM_ENDPOINT:-} ContextMemory__OllamaEndpoint: ${OLLAMA_ENDPOINT:-http://host.docker.internal:11434} + ContextMemory__OpenAiEndpoint: ${OPENAI_ENDPOINT:-https://api.openai.com} + ContextMemory__OpenAiApiKey: ${OPENAI_API_KEY:-} ContextMemory__DefaultLlmModel: ${DEFAULT_LLM_MODEL:-qwen3.5:9b} ContextMemory__MasterKey: ${MASTER_KEY:-cm_master_dev_key_change_me} ContextMemory__AdminCorsOrigins__0: ${ADMIN_PUBLIC_URL:-http://localhost:5200} diff --git a/docker-compose.network.yml b/docker-compose.network.yml index a47bb7c..80adaf4 100644 --- a/docker-compose.network.yml +++ b/docker-compose.network.yml @@ -27,6 +27,7 @@ services: Username=${POSTGRES_USER:-contextmemory};Password=${POSTGRES_PASSWORD:-contextmemory_dev} ContextMemory__WikiPath: /app/wikis ContextMemory__OllamaEndpoint: ${OLLAMA_ENDPOINT:-https://server.kortexio.io} + ContextMemory__LlmEndpoint: ${LLM_ENDPOINT:-} ContextMemory__DefaultLlmModel: ${DEFAULT_LLM_MODEL:-qwen3.5:9b} ContextMemory__MasterKey: ${MASTER_KEY:-cm_master_local_dev_change_me} ContextMemory__McpRuntimeUrl: http://mcp-runtime:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 731445b..471502a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,7 +70,10 @@ services: ASPNETCORE_URLS: http://+:8080 ContextMemory__PersistenceProvider: ${PERSISTENCE_PROVIDER:-File} ContextMemory__DataPath: /app/data + ContextMemory__LlmEndpoint: ${LLM_ENDPOINT:-} ContextMemory__OllamaEndpoint: ${OLLAMA_ENDPOINT:-http://host.docker.internal:11434} + ContextMemory__OpenAiEndpoint: ${OPENAI_ENDPOINT:-https://api.openai.com} + ContextMemory__OpenAiApiKey: ${OPENAI_API_KEY:-} ContextMemory__DefaultLlmModel: ${DEFAULT_LLM_MODEL:-qwen3.5:9b} ContextMemory__MasterKey: ${MASTER_KEY:-cm_master_dev_key_change_me} ContextMemory__McpRuntimeUrl: http://mcp-runtime:8080 diff --git a/docs/README.md b/docs/README.md index 58b295e..a476266 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,5 +16,6 @@ Short entry point. Start at the [README](../README.md) for the 5-minute Cursor w | [ops.md](ops.md) | Persistence, security, troubleshooting | | [license-and-support.md](license-and-support.md) | License & contributing | | [blueprint-01-upstream.md](blueprint-01-upstream.md) | Blueprint 01 upstream enrichments (CM-1…CM-7) | -| [aha-demo.html](aha-demo.html) | 20s GIF storyboard | +| [show-hn.md](show-hn.md) | Suggested Show HN title + blurb (BYO LLM) | +| [aha-demo.html](aha-demo.html) | 20s GIF storyboard (not a live sandbox) | diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 6a7c5f8..f37e0fc 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -46,7 +46,7 @@ Per-app pages (from Applications): - Docker Compose / GHCR Admin container: `http://api:8080` (server-side calls stay on the Docker network; the browser still opens Admin on `localhost:5200`) 3. Paste the **Master Key** (demo: `cm_master_dev_key_change_me`). 4. Click **Save**, then **Test connection**. -5. Confirm Health: Ollama, Persistence, Apps loaded, optional Database. +5. Confirm Health: Persistence, Apps loaded, default model / LLM endpoint, optional Database. **Save** stores values in this browser. **Reset to defaults** clears browser overrides and restores host defaults (`Admin__DefaultApiBaseUrl`, `Admin__DefaultMasterKey`). A link to public Prometheus metrics (`GET /metrics`) appears when connected. 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/docs/ops.md b/docs/ops.md index e9c9d32..82d39c8 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -59,8 +59,8 @@ The seed app in `appsettings.json` uses `en-US`. Tenants can set `DefaultLanguag | Symptom | Likely cause | Fix | |---|---|---| -| `docker pull` / health OK but chat hangs or 502/503 | Ollama not reachable from the container | On the **host**: `ollama serve` + `ollama pull qwen3.5:9b`. Keep `ContextMemory__OllamaEndpoint=http://host.docker.internal:11434` (Compose/scripts already set `host-gateway`). On Linux without Docker Desktop, confirm `host.docker.internal` resolves. | -| Health shows Ollama unhealthy | Wrong endpoint or firewall | From the API container or host, `curl http://host.docker.internal:11434/api/tags`. Override with `-e ContextMemory__OllamaEndpoint=...` or `OLLAMA_ENDPOINT` in `.env`. | +| `docker pull` / health OK but chat hangs or 502/503 | LLM not reachable from the container | Point `ContextMemory__LlmEndpoint` (or legacy `OllamaEndpoint` / `OLLAMA_ENDPOINT`) at a reachable engine. For Ollama on the **host**: `ollama serve` + `ollama pull qwen3.5:9b`, keep `http://host.docker.internal:11434` (Compose already sets `host-gateway`). For vLLM / ExLlamaSharp / LM Studio, set `LLM_ENDPOINT` or per-app `llmBackend=openai-compatible` + `llmEndpoint`. On Linux without Docker Desktop, confirm `host.docker.internal` resolves. | +| Health shows apps/persistence unhealthy | Wrong Master Key path or empty seed | From the API container or host, `curl http://localhost:5100/health` and check `checks.defaultLlmEndpoint`. Override with `-e ContextMemory__LlmEndpoint=...` or `LLM_ENDPOINT` / `OLLAMA_ENDPOINT` in `.env`. | | Agentic prompt exceeds context (`n_ctx=4096`) | Playground sent `options.num_ctx=4096` (old default) **or** Ollama `/v1` ignored `num_ctx` | Leave Playground `num_ctx` empty (uses app LLM config) or set 8192/32768. Also set `llmOptions.numCtx` on the app (gateway auto-uses `ollama-native`) **or** `OLLAMA_CONTEXT_LENGTH` / Modelfile `PARAMETER num_ctx`. | | Jinja `No user query found in messages` | Strict Qwen/Bonsai chat_template | Patch model `TEMPLATE` (drop `raise_exception`); gateway already merges compaction into one system + ensures a user message. | | Model invents Zuora accounts / `0 tool(s)` | Weak model / missing evidence guardrail | Enable `live-data-evidence`; use `harnessMode=weak` or Qwen profile; smoke with `scripts/smoke-multi-model.ps1`. | diff --git a/docs/self-host.md b/docs/self-host.md index 1f892e7..534cfa7 100644 --- a/docs/self-host.md +++ b/docs/self-host.md @@ -13,19 +13,38 @@ Public images (published on every push to `main`): | `ghcr.io/kortexio/contextmemory` | [API](https://github.com/users/Kortexio/packages/container/package/contextmemory) | | `ghcr.io/kortexio/contextmemory-admin` | [Admin UI](https://github.com/users/Kortexio/packages/container/package/contextmemory-admin) | -**API only** (needs [Docker](https://docs.docker.com/get-docker/) + Ollama on the host): +**API only** (needs [Docker](https://docs.docker.com/get-docker/) + any reachable LLM): + +**Bring-your-own `/v1` engine** (vLLM, LM Studio, [ExLlamaSharp](https://github.com/Kortexio/ExLlamaSharp), OpenAI, LiteLLM, …): + +```bash +docker run --rm -p 5100:8080 \ + --add-host=host.docker.internal:host-gateway \ + -v contextmemory-data:/app/data \ + -e ContextMemory__MasterKey=cm_master_dev_key_change_me \ + -e ContextMemory__Apps__demo-dev__ApiKey=cm_live_dev_key_change_me \ + -e ContextMemory__Apps__demo-dev__LlmBackend=openai-compatible \ + -e ContextMemory__Apps__demo-dev__LlmModel=my-model \ + -e ContextMemory__Apps__demo-dev__LlmEndpoint=http://host.docker.internal:8000 \ + -e ContextMemory__OpenAiEndpoint=http://host.docker.internal:8000 \ + ghcr.io/kortexio/contextmemory:latest +``` + +**Ollama on the host** (zero-friction DX default): ```bash docker run --rm -p 5100:8080 \ --add-host=host.docker.internal:host-gateway \ -v contextmemory-data:/app/data \ - -e ContextMemory__OllamaEndpoint=http://host.docker.internal:11434 \ + -e ContextMemory__LlmEndpoint=http://host.docker.internal:11434 \ -e ContextMemory__MasterKey=cm_master_dev_key_change_me \ -e ContextMemory__Apps__demo-dev__ApiKey=cm_live_dev_key_change_me \ -e ContextMemory__Apps__demo-dev__LlmModel=qwen3.5:9b \ ghcr.io/kortexio/contextmemory:latest ``` +`ContextMemory__LlmEndpoint` is the preferred host default; `OllamaEndpoint` remains a legacy alias when `LlmEndpoint` is empty. + Then: http://localhost:5100/health **API + Admin from GHCR (no local build):** @@ -48,7 +67,7 @@ Or the helper scripts: ### Build from source: Docker Compose -Builds and starts the **API** (`:5100`), **Admin** (`:5200`), **mcp-runtime** (stdio MCP host), and **sandbox-runtime** (shell/python/node) locally. Requires [Docker](https://docs.docker.com/get-docker/) and an LLM reachable from the containers (Ollama on the host by default). +Builds and starts the **API** (`:5100`), **Admin** (`:5200`), **mcp-runtime** (stdio MCP host), and **sandbox-runtime** (shell/python/node) locally. Requires [Docker](https://docs.docker.com/get-docker/) and an LLM reachable from the containers (Compose DX default = Ollama on the host; point `LLM_ENDPOINT` / `OPENAI_ENDPOINT` at any `/v1` engine instead). ```bash git clone https://github.com/Kortexio/ContextMemory.git @@ -77,19 +96,23 @@ Ops triage (Azure Monitor / GitHub): see [inbound-mcp-guide.md](inbound-mcp-guid For a Postgres-backed network overlay (shared Docker network, extra tenants), see `docker-compose.network.yml`. -**Ollama on the host** +**LLM on the host** ```bash +# Example: Ollama DX default ollama pull qwen3.5:9b -# Compose default: ContextMemory__OllamaEndpoint=http://host.docker.internal:11434 +# Compose: OLLAMA_ENDPOINT=http://host.docker.internal:11434 +# Or any /v1 engine: LLM_ENDPOINT=http://host.docker.internal:8000 + Admin backend openai-compatible ``` -**Useful Compose env vars** (see [`.env.example`](.env.example)): +**Useful Compose env vars** (see [`.env.example`](../.env.example)): | Variable | Default | Meaning | |---|---|---| | `API_PORT` / `ADMIN_PORT` | `5100` / `5200` | Host ports | -| `OLLAMA_ENDPOINT` | `http://host.docker.internal:11434` | LLM from inside the API container | +| `LLM_ENDPOINT` | _(empty)_ | Preferred host LLM base URL → `ContextMemory__LlmEndpoint` | +| `OLLAMA_ENDPOINT` | `http://host.docker.internal:11434` | Legacy local default when `LLM_ENDPOINT` is empty | +| `OPENAI_ENDPOINT` / `OPENAI_API_KEY` | OpenAI cloud / empty | Host defaults for `openai-compatible` / `vllm` / `openai` | | `DEFAULT_LLM_MODEL` | `qwen3.5:9b` | Seed + default model | | `MASTER_KEY` | `cm_master_dev_key_change_me` | Admin Master Key | | `DEMO_APP_API_KEY` | `cm_live_dev_key_change_me` | Seed app API key | @@ -111,7 +134,7 @@ curl -X POST http://localhost:5100/v1/chat/completions \ ### Prerequisites (dotnet run) - .NET 9 SDK -- Ollama (or another configured backend) reachable on the network +- Ollama, [ExLlamaSharp](https://github.com/Kortexio/ExLlamaSharp), vLLM, LM Studio, or any OpenAI-compatible `/v1` backend reachable on the network - Optional: PostgreSQL 14+ for production / multi-instance HA ### 1. Configure @@ -136,7 +159,9 @@ For **PostgreSQL** in production or multi-instance HA: "ContextMemory": { "PersistenceProvider": "Postgres", "DataPath": "../../data", + "LlmEndpoint": "", "OllamaEndpoint": "http://localhost:11434", + "OpenAiEndpoint": "https://api.openai.com", "MasterKey": "your-master-key", "Apps": { "my-app": { @@ -158,7 +183,10 @@ Use `"Postgres"` exactly (not `Postgresql`). Relative `DataPath` values resolve | `ConnectionStrings:ContextMemory` | PostgreSQL connection string when `PersistenceProvider` is `Postgres` | | `ContextMemory:PersistenceProvider` | `File` (default) or `Postgres` | | `ContextMemory:DataPath` | Root for file-based persistence (apps, sessions, wiki) | -| `ContextMemory:OllamaEndpoint` | Default Ollama (or Ollama-compatible) backend base URL | +| `ContextMemory:LlmEndpoint` | Preferred host default LLM base URL (any OpenAI- or Ollama-compatible engine). Empty = use `OllamaEndpoint` | +| `ContextMemory:OllamaEndpoint` | Legacy alias for the local/Ollama-compatible default when `LlmEndpoint` is empty | +| `ContextMemory:OpenAiEndpoint` | Host default for `openai` / `openai-compatible` / `vllm` / `custom` when the app has no `llmEndpoint` | +| `ContextMemory:LmStudioEndpoint` | Host default for `lmstudio` | | `ContextMemory:DefaultLlmModel` | Fallback model when an app has no `LlmModel` | | `ContextMemory:MasterKey` | Secret for Admin API / admin dashboard | | `ContextMemory:AdminCorsOrigins` | Allowed browser origins for Admin UI CORS | diff --git a/docs/show-hn.md b/docs/show-hn.md new file mode 100644 index 0000000..c01ebee --- /dev/null +++ b/docs/show-hn.md @@ -0,0 +1,44 @@ +> Part of the ContextMemory docs. [Back to README](../README.md). + +# Show HN — suggested blurb + +One product only. Link the GitHub repo. Keep Cloud / other Kortexio apps as a single closing line. + +## Title + +``` +Show HN: ContextMemory – OpenAI-compatible gateway with wiki memory (not RAG) +``` + +## Body + +``` +Agents forget. Most “memory” products hide facts in vector stores you can’t open. + +ContextMemory is an open-source agentic memory gateway: your app keeps calling a +normal OpenAI-compatible /v1/chat/completions URL. The gateway attaches a session +markdown wiki + history, can run a server-side tool loop (sandbox, MCP, wiki search), +and returns a standard chat.completions response. + +Bring your own LLM — Ollama, vLLM, LM Studio, ExLlamaSharp, OpenAI, Azure-compatible, +or any /v1 host. Compose defaults to Ollama only for local DX; swap the backend per +tenant in Admin. + +Not classic RAG inject. Not a client agent framework. Memory you can open like a wiki. + +Aha with the Cursor MCP wedge (two chats): + A) “Remember: staging DB is postgres-staging-01” → memory_save + B) new chat “What is our staging DB?” → memory_search + +Repo: https://github.com/Kortexio/ContextMemory +Self-host / Cloud: see README. GIF storyboard: docs/aha-demo.html +``` + +## Do / don’t + +| Do | Don’t | +|---|---| +| Lead with wiki memory + `/v1` drop-in | List CompanyBrain / TriageHub / Fincheck in the title | +| Say BYO LLM explicitly | Imply the product is “an Ollama app” | +| Point to Compose + Admin LLM picker | Promise a public live chat sandbox unless it exists | +| One soft line on Kortexio Cloud at the end | Dump six repos into the first paragraph | diff --git a/mcp-server/README.md b/mcp-server/README.md index 750de43..1adc486 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -6,13 +6,17 @@ ### 1. Start the gateway (Docker) +**Any `/v1` engine** — set `LlmBackend=openai-compatible` + `LlmEndpoint` (see [main README](../README.md)). + +**Ollama DX default:** + ```bash docker run --rm -p 5100:8080 \ -v contextmemory-data:/app/data \ -e ContextMemory__MasterKey=cm_master_dev_key_change_me \ -e ContextMemory__Apps__demo-dev__ApiKey=cm_live_dev_key_change_me \ -e ContextMemory__Apps__demo-dev__LlmModel=qwen3.5:9b \ - -e ContextMemory__OllamaEndpoint=http://host.docker.internal:11434 \ + -e ContextMemory__LlmEndpoint=http://host.docker.internal:11434 \ --add-host=host.docker.internal:host-gateway \ ghcr.io/kortexio/contextmemory:latest ``` diff --git a/src/ContextMemory.Adapters/LlmAdapterResolver.cs b/src/ContextMemory.Adapters/LlmAdapterResolver.cs index 38ffe50..020e18b 100644 --- a/src/ContextMemory.Adapters/LlmAdapterResolver.cs +++ b/src/ContextMemory.Adapters/LlmAdapterResolver.cs @@ -78,10 +78,10 @@ private ILlmAdapter Resolve( _serviceProvider.GetRequiredService() .WithConnection(endpointOverride, apiKeyOverride), - // Default: OpenAI-compatible /v1 (Ollama /v1, vLLM, OpenAI, Azure-compatible, custom). + // Default: OpenAI-compatible /v1 (Ollama /v1, vLLM, ExLlamaSharp, OpenAI, Azure-compatible, custom). "ollama" => _serviceProvider.GetRequiredService() - .WithConnection(endpointOverride ?? _options.OllamaEndpoint, apiKeyOverride), + .WithConnection(endpointOverride ?? _options.ResolveLocalLlmEndpoint(), apiKeyOverride), "vllm" or "openai" or "openai-compatible" or "custom" => _serviceProvider.GetRequiredService() @@ -89,7 +89,7 @@ private ILlmAdapter Resolve( _ => _serviceProvider.GetRequiredService() - .WithConnection(endpointOverride ?? _options.OllamaEndpoint, apiKeyOverride) + .WithConnection(endpointOverride ?? _options.ResolveLocalLlmEndpoint(), apiKeyOverride) }; } } diff --git a/src/ContextMemory.Adapters/OllamaAdapter.cs b/src/ContextMemory.Adapters/OllamaAdapter.cs index 457d804..f7921c0 100644 --- a/src/ContextMemory.Adapters/OllamaAdapter.cs +++ b/src/ContextMemory.Adapters/OllamaAdapter.cs @@ -11,7 +11,14 @@ namespace ContextMemory.Adapters; public sealed class OllamaAdapterOptions { public const string SectionName = "ContextMemory"; + + /// Legacy bind target; prefer / . public string OllamaEndpoint { get; set; } = "http://localhost:11434"; + + public string LlmEndpoint { get; set; } = string.Empty; + + public string ResolveBaseUrl() => + !string.IsNullOrWhiteSpace(LlmEndpoint) ? LlmEndpoint.Trim() : OllamaEndpoint.Trim(); } public sealed class OllamaAdapter : ILlmAdapter @@ -27,7 +34,7 @@ public sealed class OllamaAdapter : ILlmAdapter private readonly string? _apiKey; public OllamaAdapter(HttpClient httpClient, IOptions options) - : this(httpClient, options.Value.OllamaEndpoint, apiKey: null) + : this(httpClient, options.Value.ResolveBaseUrl(), apiKey: null) { } 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.Admin.UI/Models/AdminModels.cs b/src/ContextMemory.Admin.UI/Models/AdminModels.cs index d145945..18b8693 100644 --- a/src/ContextMemory.Admin.UI/Models/AdminModels.cs +++ b/src/ContextMemory.Admin.UI/Models/AdminModels.cs @@ -197,6 +197,7 @@ public sealed class HealthChecksDto public bool ProfilesReady { get; set; } public string? SessionsPath { get; set; } public string? DefaultModel { get; set; } + public string? DefaultLlmEndpoint { get; set; } } public sealed class AdminSettings diff --git a/src/ContextMemory.Admin.UI/Pages/Apps/Config/AppConfigLlm.razor b/src/ContextMemory.Admin.UI/Pages/Apps/Config/AppConfigLlm.razor index 86a23e5..5775eea 100644 --- a/src/ContextMemory.Admin.UI/Pages/Apps/Config/AppConfigLlm.razor +++ b/src/ContextMemory.Admin.UI/Pages/Apps/Config/AppConfigLlm.razor @@ -40,13 +40,13 @@ else
- + + + + - - - - +
diff --git a/src/ContextMemory.Admin.UI/Pages/Apps/RegisterApp.razor b/src/ContextMemory.Admin.UI/Pages/Apps/RegisterApp.razor index ef4ab81..2a0ca06 100644 --- a/src/ContextMemory.Admin.UI/Pages/Apps/RegisterApp.razor +++ b/src/ContextMemory.Admin.UI/Pages/Apps/RegisterApp.razor @@ -64,7 +64,7 @@ - + diff --git a/src/ContextMemory.Admin.UI/Pages/Settings.razor b/src/ContextMemory.Admin.UI/Pages/Settings.razor index d64ae72..2278776 100644 --- a/src/ContextMemory.Admin.UI/Pages/Settings.razor +++ b/src/ContextMemory.Admin.UI/Pages/Settings.razor @@ -102,6 +102,10 @@
Persistence@_health.Checks.Persistence
Apps loaded@_health.Checks.AppsLoaded
Default model@_health.Checks.DefaultModel
+ @if (!string.IsNullOrWhiteSpace(_health.Checks.DefaultLlmEndpoint)) + { +
Default LLM endpoint@_health.Checks.DefaultLlmEndpoint
+ }
} diff --git a/src/ContextMemory.Api/Endpoints/HealthEndpoint.cs b/src/ContextMemory.Api/Endpoints/HealthEndpoint.cs index dc06564..71e87fb 100644 --- a/src/ContextMemory.Api/Endpoints/HealthEndpoint.cs +++ b/src/ContextMemory.Api/Endpoints/HealthEndpoint.cs @@ -53,8 +53,11 @@ private static async Task GetHealthAsync( profilesReady, sessionsPath = Path.Combine(config.DataPath, "sessions"), defaultModel = config.DefaultLlmModel, + defaultLlmEndpoint = config.ResolveLocalLlmEndpoint(), harnessHints = new { + byoLlmNote = + "Bring your own engine: set llmBackend + llmEndpoint per app, or host LlmEndpoint / OpenAiEndpoint / LmStudioEndpoint. OllamaEndpoint remains a legacy alias for the local default.", ollamaNumCtxNote = "When llmBackend=ollama and llmOptions.numCtx is set, gateway uses ollama-native (/api/chat) because Ollama /v1 ignores options.num_ctx.", formatJsonNote = diff --git a/src/ContextMemory.Api/Endpoints/LlmBackendModelListing.cs b/src/ContextMemory.Api/Endpoints/LlmBackendModelListing.cs index 2d109f8..4274549 100644 --- a/src/ContextMemory.Api/Endpoints/LlmBackendModelListing.cs +++ b/src/ContextMemory.Api/Endpoints/LlmBackendModelListing.cs @@ -24,7 +24,7 @@ public static async Task TryAddBackendModelsAsync( CancellationToken cancellationToken) { var rawBase = string.IsNullOrWhiteSpace(llmEndpoint) - ? hostOptions.OllamaEndpoint + ? hostOptions.ResolveLocalLlmEndpoint() : llmEndpoint; if (string.IsNullOrWhiteSpace(rawBase)) return; diff --git a/src/ContextMemory.Api/Hosting/AppConfigBootstrapHostedService.cs b/src/ContextMemory.Api/Hosting/AppConfigBootstrapHostedService.cs index 00ef7da..6d3497a 100644 --- a/src/ContextMemory.Api/Hosting/AppConfigBootstrapHostedService.cs +++ b/src/ContextMemory.Api/Hosting/AppConfigBootstrapHostedService.cs @@ -37,6 +37,9 @@ public async Task StartAsync(CancellationToken cancellationToken) FormatRules = GetDefaultFormatRules(), DefaultLanguage = entry.DefaultLanguage, LlmModel = string.IsNullOrWhiteSpace(entry.LlmModel) ? _options.DefaultLlmModel : entry.LlmModel, + LlmBackend = string.IsNullOrWhiteSpace(entry.LlmBackend) ? "ollama" : entry.LlmBackend.Trim(), + LlmEndpoint = entry.LlmEndpoint?.Trim() ?? string.Empty, + LlmApiKey = entry.LlmApiKey?.Trim() ?? string.Empty, MaxHistoryMessages = entry.MaxHistoryMessages > 0 ? entry.MaxHistoryMessages : _options.MaxHistoryMessages, diff --git a/src/ContextMemory.Api/appsettings.json b/src/ContextMemory.Api/appsettings.json index 6e2872d..0155b55 100644 --- a/src/ContextMemory.Api/appsettings.json +++ b/src/ContextMemory.Api/appsettings.json @@ -13,7 +13,8 @@ "ContextMemory": { "PersistenceProvider": "File", // File or Postgresql (default: File) "DataPath": "../../data", // Path to the data directory (default: ../../data) - "OllamaEndpoint": "http://localhost:11434", // Used as OpenAI-compatible /v1 base when LlmBackend=ollama + "LlmEndpoint": "", // Preferred host default LLM base URL (any OpenAI/Ollama-compatible engine). Empty = use OllamaEndpoint + "OllamaEndpoint": "http://localhost:11434", // Legacy alias for local/Ollama-compatible default when LlmEndpoint is empty "OpenAiEndpoint": "https://api.openai.com", // Default for openai / vllm / openai-compatible "OpenAiApiKey": "", "LmStudioEndpoint": "http://localhost:1234", 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/Configuration/ContextMemoryOptions.cs b/src/ContextMemory.Core/Configuration/ContextMemoryOptions.cs index 789ffb0..c912d2a 100644 --- a/src/ContextMemory.Core/Configuration/ContextMemoryOptions.cs +++ b/src/ContextMemory.Core/Configuration/ContextMemoryOptions.cs @@ -6,7 +6,23 @@ public class ContextMemoryOptions public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public string DataPath { get; set; } = "./data"; + + /// + /// Preferred host-level default LLM base URL (any OpenAI-compatible or Ollama-compatible engine). + /// When set, takes precedence over . + /// + public string LlmEndpoint { get; set; } = string.Empty; + + /// + /// Legacy alias for the default local / Ollama-compatible base URL. + /// Prefer in new config; kept for Compose and older docs. + /// public string OllamaEndpoint { get; set; } = "http://localhost:11434"; + + /// Resolved default for backends that use the local/Ollama-compatible host URL. + public string ResolveLocalLlmEndpoint() => + !string.IsNullOrWhiteSpace(LlmEndpoint) ? LlmEndpoint.Trim() : OllamaEndpoint.Trim(); + public int OllamaRequestTimeoutSeconds { get; set; } = 600; public int DefaultAgenticLoopTimeoutSeconds { get; set; } = 120; public int MaxHistoryMessages { get; set; } = 6; @@ -65,5 +81,11 @@ public class AppOptionsEntry public string SystemPrompt { get; set; } = string.Empty; public string DefaultLanguage { get; set; } = "en-US"; public string LlmModel { get; set; } = "qwen3.5:9b"; + /// Optional seed backend (ollama, openai-compatible, vllm, …). + public string LlmBackend { get; set; } = string.Empty; + /// Optional per-app LLM base URL seed (overrides host defaults when set). + public string LlmEndpoint { get; set; } = string.Empty; + /// Optional per-app LLM API key seed. + public string LlmApiKey { get; set; } = string.Empty; public int MaxHistoryMessages { get; set; } = 6; } 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/Models/AppRuntimeConfig.cs b/src/ContextMemory.Core/Models/AppRuntimeConfig.cs index aee023c..69860df 100644 --- a/src/ContextMemory.Core/Models/AppRuntimeConfig.cs +++ b/src/ContextMemory.Core/Models/AppRuntimeConfig.cs @@ -27,7 +27,7 @@ public record AppRuntimeConfig /// /// Optional per-app LLM base URL. Empty = use the host default for - /// (OllamaEndpoint / LmStudioEndpoint / OpenAiEndpoint). + /// (LlmEndpoint / OllamaEndpoint / LmStudioEndpoint / OpenAiEndpoint). /// public string LlmEndpoint { get; init; } = string.Empty; 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)); + } }