Live testing surfaced three issues:
- Lightpanda numbers targets per-browser, and each conn is its own
browser, so every tab gets the same upstream targetID
("FID-0000000001"). Synthesize globally-unique "lp-N" keys for our
internal map so multi-tenant tab tracking works.
- page.Info() returns valid data once post-open then errors on
subsequent calls, which made ListTabs silently drop tabs. Cache URL
and Title at OpenTab time and read from the cache in ListTabs.
- rod.Browser.Close() calls Browser.close which Lightpanda doesn't
implement; the WS drops cleanly anyway. Swallow the error to quiet
the noisy log line.
Two Lightpanda upstream bugs are documented in docs/browser-backends.md
and exercised by TestLightpanda_KnownUpstreamGaps:
1. Accessibility.getFullAXTree returns nodeId as a JSON number
(CDP spec: string)
2. Runtime.evaluate rejects go-rod's function-apply wrapper
- docker-compose.lightpanda.yml: opt-in overlay running
lightpanda/browser:latest, wires GOCLAW_BROWSER_REMOTE_URL and
GOCLAW_BROWSER_BACKEND so the manager picks the right code path.
- docs/browser-backends.md: compatibility matrix vs Chrome (screenshot,
multi-tab, cookie sharing, etc.) and guidance on when to pick which.
Build-tag gated (//go:build integration), skipped unless
LIGHTPANDA_CDP_URL env is set. Covers:
- Golden path: open → snapshot → list → close
- Multi-tenant isolation: each tenant sees only its own tabs;
cross-tenant CloseTab is rejected
- Backend() reports lightpanda after Start
- Screenshot tool action returns an IsError result mentioning
lightpanda + snapshot
Pass cfg.Tools.Browser.Backend to browser.WithBackend when set, so the
"chrome" or "lightpanda" selector from config / GOCLAW_BROWSER_BACKEND
reaches the manager. Empty value still triggers /json/version
auto-detection in browser.Start().
Lightpanda requires one CDP connection per tab (vs Chrome's shared-WS
model). Add a Backend selector in pkg/browser/, branched lifecycle, and
per-tab connection tracking via pageConns.
- Backend type (chrome | lightpanda), WithBackend option, Backend()
accessor. Auto-detected from /json/version when unset.
- Lightpanda OpenTab mints a fresh conn, calls
Target.createBrowserContext (required per-conn) + createTarget.
- ListTabs / getPage are local-map only on Lightpanda (no /json/list,
no auto-reconnect).
- Shared closeManagedPageLocked helper used by CloseTab, evict, and
the idle-page reaper.
- Screenshot tool action errors out on Lightpanda (returns a
placeholder image upstream; route the agent to snapshot instead).
Introduces an explicit backend selector for the browser automation
tool ("chrome" or "lightpanda"). Empty means auto-detect, which will
be wired in a later commit. Chrome remains the default behavior.
Ollama models like qwq and deepseek-r1 have thinking enabled by default.
Disable it to prevent bloated response generation unless explicitly requested.
- Add isOllamaEndpoint() detection (providerType, name, apiBase)
- Always send think=false in Ollama requests unless OptThinkingLevel is set
- SupportsThinking() and Capabilities().Thinking return false for Ollama
- Add tests covering default-off, explicit-opt-in, and capability flags
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
* feat(ollama): auto-detect context window, allow user override
Query Ollama API (/api/show) to fetch model's actual context_length
on provider startup/hot-registration. Use queried value by default,
but allow user to override via num_ctx field in provider settings.
- backend: query API, store in provider config, use in requests
- frontend: add num_ctx input field to Ollama provider dialog (4 langs)
- fallback: 131072 tokens (128K) if API unreachable
Fixes context-window errors when using large-context models.
* fix(ollama): route to /api/chat endpoint to honor num_ctx
Ollama's /v1/chat/completions (OpenAI-compat shim) ignores options.num_ctx.
Route to /api/chat (native endpoint) which respects num_ctx.
Request body is identical, only endpoint URL changes:
- Before: /v1/chat/completions (ignores options.num_ctx)
- After: /api/chat (honors options.num_ctx)
Fixes context-window errors when using models with large context windows.
* refactor(providers): replace OpenAI-wrapped Ollama with native SDK
Implement dedicated OllamaProvider using github.com/ollama/ollama/api
official client library. Replaces the broken approach of wrapping
Ollama behind OpenAI-compatible parsing.
Benefits:
- Proper response parsing (Ollama native format, not OpenAI)
- Automatic /v1 suffix stripping
- Built-in num_ctx support (injected into every request)
- Tool call support via Ollama's native ToolCall schema
- Token usage mapping from Ollama Metrics
- Clean streaming implementation
Fixes context-window errors by using Ollama native API directly.
Removes dependency on OpenAI response format hacks.
- new: internal/providers/ollama.go (OllamaProvider)
- updated: cmd/gateway_providers.go, internal/http/providers.go
- updated: go.mod (added github.com/ollama/ollama)
* fix(ollama): fetch num_ctx for config-registered providers
Config-file-registered Ollama providers (cfg.Providers.Ollama.Host)
were hardcoding numCtx=nil, so num_ctx was never sent to Ollama.
Add FetchOllamaModelContext call for config providers, same as
DB-registered providers. Now config Ollama providers also auto-detect
and send the model's context window to the Ollama API.
Fixes context-window errors when using config-registered Ollama.
* fix(ollama): always send num_ctx to prevent context window errors
OllamaProvider.buildRequest only injected options.num_ctx when an
explicit override was configured. When resolveOllamaNumCtx saw that
/api/show returned the OllamaDefaultNumCtx value (131072), it returned
nil, causing the provider to omit num_ctx entirely. Ollama then fell
back to the model's baked-in default (4096 tokens), causing requests
with long system prompts to fail with "request exceeds context size".
Fix both layers:
- resolveOllamaNumCtx now always returns &numCtx instead of nil when
/api/show returns the default, so the explicit value is always passed.
- OllamaProvider.buildRequest falls back to OllamaDefaultNumCtx (131072)
when p.numCtx is nil, ensuring every request carries a large context
window even if the startup query was skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The dashboard persists per-group/per-topic overrides (enabled,
require_mention, skills, tools, system_prompt, ...) into the channel
instance config, but telegramInstanceConfig had no groups field, so the
payload was dropped on unmarshal. config.TelegramConfig.Groups stayed
nil, resolveTopicConfig fell back to global defaults, and the bot
replied in every forum topic regardless of the configured per-topic
gating.
Map the groups field through the DB-instance factory so per-topic
config takes effect for dashboard-created channels, matching the static
file-config path.
* feat(skills): display structured security violations in upload error
When skill upload fails due to security violations, show each violation
as a clear list with line number and reason in the error display.
Changes:
- Add ApiError.getViolations() method to extract violations from response
- Update HTTP upload client to capture violations in error details
- Add violations field to SkillEntry type
- Display violations as bulleted list in SkillSubtitle when present
- Add i18n keys for security violations display (en, vi, zh, ko)
New UI behavior: Instead of a generic "Upload failed" message, users
now see structured violation details:
Security violations:
- Line 5: SQL DROP TABLE
- Line 12: curl pipe to shell
Backward compatible - falls back to generic error message if violations
array is absent from response.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(skills): return structured JSON violations array in upload error response
Replace flat string formatting with a structured violations array (line, reason)
so clients can display per-line security rule details instead of a generic error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
An agent's tts_voice_id (e.g. "en-US-GuyNeural" for Edge) was applied
regardless of the selected TTS provider, causing OpenAI and other
providers to reject unknown voice IDs.
- Add internal/audio/voice_compat.go with IsVoiceCompatible,
GetProviderDefaultVoice, and FilterVoiceForProvider helpers
- Update TtsTool.resolveVoiceAndModel to report whether the voice
came from an agent override (voiceFromAgent)
- Apply applyVoiceCompat per-provider branch in Execute so
incompatible agent voices fall back to the provider's default
with a slog.Warn for operator visibility
- Add voice_compat_test.go with table-driven tests for all cases
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Run pruning before ThinkStage so oversized histories compact before the provider request is built.
Persist V3 last_prompt_tokens with the post-flush persisted message count so post-run compaction calibration has current usage data.
Tests: go test ./internal/pipeline -run "TestNewDefaultPipeline_PrunesBeforeThink|TestFinalizeStage_UpdateMetadataReceivesPersistedMessageCount" -count=1; go test ./internal/agent -run "^$" -count=1
Co-authored-by: GoClaw Operator <operator@goclaw>
Follow-up to #1295. After scoping the Open Channel mention gate by
connector, customer messages on 1-to-1 connectors (Facebook Messenger,
Zalo OA, …) were STILL being silently dropped — by the second
mention-gate stanza further down in handleMessage, which checks the
channel-level c.RequireMention() flag without distinguishing Open
Channel traffic from native group chats (CRM deal/task chats).
For a channel like nguyen-dao-openline configured with RequireMention=true,
the flow was:
1. isOpenChannel block → shouldRequireMentionForOpenline("facebook|...")
returns false (facebook isn't in the whitelist) → no drop.
2. isGroup block (isOpenChannel implies isGroup) → c.RequireMention()=true
AND mentioned=false → DROP.
Net effect: the gate-by-connector decision was overridden one stanza
later, and every Facebook customer turn disappeared.
Fix: scope the drop check inside the isGroup block to native-group chats
only (!isOpenChannel). Open Channel sessions already own their mention
policy in the earlier isOpenChannel block; re-applying the channel-level
RequireMention flag here second-guesses that decision.
Importantly, the text-processing that follows the drop check
(MessageOriginal fallback, stripMention, bxConvertUserMentionsToReadable)
stays unconditional, so an Open Channel STAFF turn that does come in with
"[USER=<botID>]Bot[/USER]" BBCode still gets cleaned up the same way it
always did. The only thing the new guard suppresses is the drop itself.
Verified live: Facebook Messenger customer turn (IS_CONNECTOR=Y,
CHAT_ENTITY_ID=facebook|34|…) now reaches the agent loop and the bot
replies, matching the intent from #1295.
Surface parity: backend-only. No API contract / web UI / CLI change.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
Before: every Open Channel message required a bot @mention before the agent
picked it up — including external-customer turns on 1-to-1 connectors
(Facebook Messenger, Zalo OA, …) where the customer has no syntactic way
to produce a mention. The gate, designed for group-style connectors
(Zalo personal pools multiple customers into one Open Channel chat),
was silently dropping every legitimate customer message on every other
connector.
This change scopes the gate by connector:
- Internal staff (IS_CONNECTOR=N) — STILL gate. Operators can @-mention
and we don't want the bot jumping into operator-to-operator chatter
in the same session.
- External customer (IS_CONNECTOR=Y) on a whitelisted connector
(default: synity_zalo_personal) — STILL gate. Multiple customers
share the chat, so untagged turns would otherwise spam them.
- External customer on any other connector (Facebook, Zalo OA, the long
tail of 1-to-1 connectors, legacy payloads without CHAT_ENTITY_ID) —
do NOT gate. Every message is addressed to the bot by construction.
The whitelist is overridable via BITRIX24_REQUIRE_MENTION_CONNECTORS
(comma-separated connector codes — the leading token of CHAT_ENTITY_ID).
Default keeps synity_zalo_personal gated so a fresh deployment is safe.
Adding a future group-style connector (e.g. a Zalo group flavour) is an
env edit + restart, not a code change.
- mention_gate.go (new):
connectorCodeFromEntityID() parses "synity_zalo_personal|20|grp|960"
into "synity_zalo_personal".
requireMentionConnectorSet() reads the env once (sync.Once cache)
and falls back to the hardcoded default {synity_zalo_personal}.
shouldRequireMentionForOpenline() is the policy matrix.
- mention_gate_test.go (new): pins the parser, the env-override path,
and every row of the policy matrix.
- handle.go: replaces the unconditional Open Channel mention gate with
a call to shouldRequireMentionForOpenline(). Adds chat_entity_id to
the drop log so operators can see which connector tripped the gate.
- handle_test.go:
Update existing "ConnectorWithoutMentionDropped" test to set
ChatEntityID to the whitelisted connector — that's what the gate now
keys on.
Add new "OneToOneConnectorWithoutMentionForwarded" test pinning the
new behavior: facebook (not whitelisted) forwards every customer
message without a mention.
Surface parity: backend-only. No API contract / web UI / CLI change.
The env var follows the same operational model as the other BITRIX24_*
vars in docker-compose.yml; documenting it there is a no-op until the
default needs overriding.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
Without these tags the Bitrix-borne PDF / audio / image arrive as bare text
to the agent loop: the LLM never realizes an attachment exists and degrades
to "no file attached" replies (or worse, hallucinates a CRM document id and
fires crm.documentgenerator at it). The other channels (Telegram, Slack,
Discord) all prepend <media:*> tags before publish via the shared
media.BuildMediaTags helper — Bitrix24 was the lone holdout.
The agent loop's enrichInputMedia REPLACES tags it finds (it does NOT
insert new ones), so adding the tag at the channel boundary is the fix
that aligns Bitrix with the other channels AND unlocks enrich's
read_document / read_audio / read_image routing downstream.
- media_tags.go: mediaFilesToInfos() adapter from bus.MediaFile to
media.MediaInfo + classifyMediaType() MIME prefix → media.Type* mapping.
Unknown MIMEs fall back to TypeDocument (read_document handles generic
binaries safely; a bogus audio tag on a non-audio file would mislead the
LLM). Bitrix ships voice notes as audio/* — they're classified as
TypeAudio (read_audio handles both audio and voice identically).
- handle.go: prepend BuildMediaTags(...) output to text right before
HandleMessageMedia. No-op when no media files.
- media_tags_test.go: pins the MIME map, the conversion, and the
end-to-end tag shape through BuildMediaTags.
Surface parity: backend-only. No API / web UI / CLI impact.
Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
* fix(feishu): require exact bot_open_id match for mention detection
- Previously, when bot_open_id was empty, ALL mentions were treated as bot mentions
- This caused multiple agents in same group to all respond to any mention
- Now requires bot_open_id to be set AND match exactly for mentionedBot=true
- Fixes issue where CPPAI PM would respond even when other agent was mentioned
* fix(feishu): gate group pairing by target bot
---------
Co-authored-by: ntduc <ntduc@cpp.ai.vn>
WhatsApp LID-format chat IDs contain @ (e.g. 551152861098:5@s.whatsapp.net)
which is invalid in Docker container names, causing sandbox creation to fail
for any WhatsApp-triggered agent session.
Add @ to the sanitizeKey replacer alongside the existing : / . and space
characters. Adds a test case with a realistic WhatsApp LID key.
Fixes#1029
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 백엔드: LocaleKO 상수, IsSupported에 ko 추가, catalog_ko.go 185개 문자열
- 웹 UI: 38개 네임스페이스 JSON 파일 (locales/ko/)
- constants.ts SUPPORTED_LANGUAGES에 ko 추가
- index.ts에 ko 리소스 등록 및 getInitialLanguage에 ko 감지
- en/topbar.json languages에 한국어 옵션 추가
- webui handler: index.html에 no-cache 헤더 추가하여 배포 후 캐시 문제 방지
Both WS pairing (router.go Path 3a) and HTTP X-GoClaw-Sender-Id auth
were pinned to RoleOperator. Now they map tenant_users.role ->
permissions.Role (owner/admin/operator|member/viewer) so a tenant admin
who logs in via pairing actually gets admin in the dashboard. Falls
back to RoleOperator when the user has no membership row (backward
compatible). Tenant is inferred from a single membership when no hint
is provided.
Signed-off-by: imcvampire <anhnq0@gmail.com>
Mirror the existing /addwriter /removewriter /writers UX for the cron
permission type so group admins can grant cron-management access without
elevating to full file_writer. Bootstrap policy: first call in a group
is free (no cron managers yet); subsequent calls require an existing
cron manager OR a file_writer.
- new commands_cron_perm.go: handleCronPermCommand (add/remove),
handleListCronPerm; uses store.ConfigTypeCron with scope
"group:<channel>:<chatID>".
- commands.go: route /addcron, /removecron, /croners.
- commands_pairing.go: register the 3 commands in the bot menu.
* fix(skills): normalize backslash in ZIP entry names for Windows compatibility
ZIP archives created on Windows may use backslash (\) as path separator
instead of forward slash (/). When extracted on Linux, this causes files
like 'scripts\search.py' to be created as a single flat file instead of
the proper 'scripts/search.py' directory structure.
The ZIP specification (PKZIP APPNOTE) requires forward slashes, but many
Windows tools produce non-conforming archives.
This adds a strings.ReplaceAll backslash normalization step during skill
ZIP extraction to handle this edge case.
* test(skills): cover backslash zip extraction
---------
Co-authored-by: buidackim <221586880+buidackim@users.noreply.github.com>
Co-authored-by: Mỹ Ngân <goclaw-maintain@users.noreply.github.com>
* docs(openapi): add vault endpoints + schemas
The committed OpenAPI spec only documented 41 of ~286 actual routes. This
adds the 19 Vault endpoints — 16 under /v1/vault/* and 3 under
/v1/agents/{agentID}/vault/* — plus their request/response schemas.
Routes covered (matches internal/http/vault_handler_*.go and
vault_graph_handler.go registrations):
- documents: list, create, get, update, delete (per-agent and cross-agent)
- documents/{docID}/links: outlinks + backlinks + name lookup
- links: create, delete, batch get outlinks
- upload: multipart, allowed exts whitelist
- rescan: per-tenant workspace rescan w/ concurrency guard (409)
- tree: lazy sidebar listing
- search: hybrid FTS + vector
- enrichment: status + stop
- graph: lightweight nodes/edges for visualization
Schemas: VaultDocument, VaultDocumentInput, VaultDocumentUpdateInput,
VaultLink, VaultBacklink, VaultSearchResult, VaultTreeEntry,
VaultGraphNode, VaultGraphEdge. Tags: Vault, Vault Graph.
Pure spec addition — no handler changes. The insertion is surgical
(no reformatting of existing entries), so the diff is additive only.
* docs(openapi): cover the remaining HTTP endpoints
Continues the gap-fill started for Vault. Adds 150 more path operations
spanning every other HTTP-route file under internal/http/, with 19 new
component schemas and 8 new tags. After this commit the spec covers 210
paths vs 60 previously committed (the actual handler surface is ~258).
Coverage by area (file → routes added):
knowledge_graph_handlers.go 9 (Knowledge Graph: extract, graph, stats,
dedup scan/dismiss, merge, single entity)
vault_graph_handler.go 1 (kg/graph/compact)
memory_handlers.go 6 (documents CRUD with {path...}, chunks,
index, index-all)
oauth.go 8 (chatgpt + openai: status, start,
callback, logout × 2)
storage.go 4 (files {path...} read/delete, size, move;
POST upload merged into the existing
/v1/storage/files block so the path key
stays unique and YAML-strict parsers like
Swagger UI's loader accept the spec)
agents.go 22 (sync-workspace, shares CRUD, regenerate /
resummon / cancel-summon, system-prompt-
preview, export {preview,download/token},
import {preview,merge}, teams export/import,
instances + metadata, orchestration,
v3-flags)
evolution_handlers.go 3 (metrics, suggestions, suggestion review)
episodic_handlers.go 2 (list, search)
skills.go 18 (versions, files, files/{path...}, upload,
grants agent/user × revoke, install-deps
trio, runtimes, toggle, tenant-config,
export {preview}, import)
mcp.go + mcp_user_credentials.go 16 (test, reconnect, grants + per-user creds,
requests CRUD + review, export {preview},
import, agent-grants list)
providers.go 4 (verify-embedding, codex-pool-activity,
claude-cli auth-status, embedding/status)
tenants.go + users 7 (CRUD + tenant users + tenant-users list,
users/search)
secure_cli.go + agent_grants 17 (cli-credentials full CRUD + presets +
check-binary + test + user-credentials +
agent-grants)
system_configs.go 4 (list, get, put, delete)
backup_handler.go + s3 + tenant 12 (system backup preflight/download/s3 set,
tenant backup + restore)
packages.go 6 (list, install/uninstall, runtimes,
github-releases, shell-deny-groups)
builtin_tools.go 3 (tenant-config get/set/delete)
tts.go + voices.go + caps 6 (config, synthesize, test-connection,
capabilities, voices, voices/refresh)
channel_instances.go +contacts +pending 10 (channel writers, writer groups,
contacts resolve / merge / unmerge / merged,
pending-messages)
files.go + workspace_upload 3 (files {path...} read, files/sign,
team workspace upload + move, team events,
team task attachment download)
edition.go + openapi.go 2 (edition info, openapi.json)
api-keys + traces + usage 3 (revoke, traces/{traceID}/export,
usage/breakdown)
New schemas: KGEntity, KGRelation, KGDedupCandidate, KGStats, TraversalResult,
MemoryDocument, MemoryChunk, MemorySearchHit, OAuthStatus, OAuthStartResponse,
Skill, McpServer, Tenant, TenantUser, CLICredential, Package, EvolutionMetric,
EvolutionSuggestion, EpisodicSummary.
New tags: Tenants, CLI Credentials, Packages, Contacts, Teams, TTS, Evolution,
Episodic Memory.
Purely additive — no existing operation, schema, or tag is modified. The one
overlap (`/v1/storage/files`, previously documented as GET-only) is handled
by inserting the new POST method inside the existing path block in place
rather than re-declaring the key, so each path key appears exactly once and
the spec parses cleanly under both JSON and strict YAML loaders.
Verified by parsing HEAD and the new file with python json.load + yaml.safe_load
and diffing entry-by-entry. Under `git diff --histogram`/`--patience` the diff
shows zero deletions; the default myers algorithm reports cosmetic deletion
lines but they are diff-algorithm noise, not real changes.
Where a body field or response shape was clear from the handler, the spec
captures it; where the handler accepts free-form / additive JSON payloads
the request body is documented as a generic object (additionalProperties:
true) rather than a fabricated rigid schema. Better to be honest about
flexibility than to lock callers into a wrong type.
* docs(openapi): drop nonexistent DELETE /v1/api-keys/{id}
The spec carried a DELETE /v1/api-keys/{id} operation, but
internal/http/api_keys.go registers no such route — only
GET /v1/api-keys, POST /v1/api-keys, and
POST /v1/api-keys/{id}/revoke. The DELETE entry predates this PR
(it was already on dev), so the semantic-preservation check left
it untouched; this removes the stale op so the spec matches the
registered routes. The correct POST /v1/api-keys/{id}/revoke
operation is unchanged.
Verified: json.load parses, go build ./internal/http/ and
go vet ./internal/http/ both clean.
---------
Co-authored-by: Aaron Vu <aaron-tsar@users.noreply.github.com>
* fix(pipeline): defer non-tool messages until all tool results are emitted
Multi-tool responses could interleave synthetic user messages (loop
warnings, nudges) between tool results, breaking OpenAI-compatible
providers that require contiguous tool_result grouping.
Fix both sequential and parallel tool execution paths in tool_stage.go
to defer non-tool messages until after all tool results are appended.
Fix sanitizeHistory lookahead in loop_history_sanitize.go to reorder
interleaved non-tool messages in persisted session history.
Closes#1177
* perf(agent): cache BuildFilteredTools result per run
All filtering inputs (policy, disabled tools, bootstrap, channel,
orchestration mode, user MCP tools) are fixed for the lifetime of a
run. Only the final iteration differs (strips all tools). Cache the
tool definitions after the first call and reuse for iterations
0..maxIter-1, eliminating N-1 redundant 7-step policy evaluations per
agent run.
Currently the web_search tool resolves a tenant-configured provider
chain (e.g. tavily → exa → brave) and stops at the first success.
That's the right default for cost/latency, but it makes cross-engine
corroboration impossible at the agent level: the caller has no way to
ask "search the same query on Exa specifically" once Tavily has
already returned a hit.
Add an optional `provider` argument. When set, the chain is narrowed
to the named provider (case-insensitive); other providers are not
queried. The first-success-wins fallback is preserved when the arg
is omitted.
Use case (concrete): a research agent verifying source freshness
calls web_search twice for the same query — once with provider="tavily"
and once with provider="exa" — and compares the URLs returned. URLs
that appear in both engines are high-confidence original primary
sources; URLs only in one signal a republish or low-circulation outlet
worth verifying further.
Other changes:
- Cache key now includes the requested provider so per-engine
results don't collide. Without this, the second call would just
replay the first engine's cached result, defeating the purpose.
- Unknown provider returns a clear error listing what IS configured
for the tenant — better DX than a silent no-op.
Tests: 5 new cases covering narrowing, case-insensitivity, unknown
provider, default first-success behaviour preservation, and cache
isolation across providers.