Compare commits

...

200 Commits

Author SHA1 Message Date
Edward Paget
a84a8f1f96 Improve OAuth server and MCP integration test coverage
Strengthen assertions across 5 test files by replacing weak `some?`/`string?`/
`contains?` existence checks with `=?` pattern assertions that validate actual
values. Consolidate scattered single-field `(is ...)` calls into single map
assertions.

Add new security-critical tests: expired OAuth bearer tokens, scope enforcement
on tool calls, PKCE S256 flow (happy + missing verifier), expired authorization
codes, and revoked refresh tokens.

Fix invalid-bearer-token test to set site-url and reset the OAuth provider.
2026-03-16 09:07:19 -05:00
Chris Truter
ec4b07e9f0 User id and scopes should not be optional 2026-03-16 14:23:46 +02:00
Chris Truter
c49e730f4c Explicitly unrestricted - less surprising 2026-03-16 14:20:42 +02:00
Edward Paget
51a79f66e5 Fix discovery docs 2026-03-16 14:16:52 +02:00
Edward Paget
445071f9b1 Compact OAuth migrations and remove debug statements
Fold addColumn changesets (client_type, expiry) into their original
createTable changesets since these tables haven't shipped yet. Remove
leftover #p debug macros from the OAuth API handlers.
2026-03-16 14:16:19 +02:00
Edward Paget
7c1cb845db Add OAuth bearer token auth for MCP endpoint with scope filtering
Replace the temporary superuser fallback in the MCP handler with proper
OAuth 2.0 bearer token validation. Add RFC 9728 protected resource
metadata discovery, thread token scopes through MCP→Agent API dispatch,
and filter tools/list by granted scopes.

Key changes:
- Emit :scope from endpoint metadata into tools manifest definitions
- Add /.well-known/oauth-protected-resource endpoint (RFC 9728)
- Fix resource parameter forwarding through OAuth consent form
- Validate bearer tokens against the OAuth token store
- Store user-id and scopes on MCP sessions for subsequent requests
- Filter tools/list visibility based on token scopes
- Fix JSON deserialization of resource field (list→vector)
- Add OAuth/.well-known routes to request logging middleware
- Add tests for auth, scope filtering, and manifest generation
2026-03-16 14:16:18 +02:00
Edward Paget
c88f5b715a Add server-rendered HTML consent page for OAuth authorize endpoint
Replace the JSON response from GET /oauth/authorize with a Hiccup-rendered
HTML consent page that shows the client name, requested scopes, and
Allow/Deny buttons. The decision endpoint now accepts form-encoded POST
params instead of JSON.

The consent page respects Metabase appearance settings (application-font,
application-colors brand, application-logo-url) and includes the CSP nonce
on the style tag so inline styles work with Metabase's Content-Security-Policy.

Also includes: frontend route guard for /oauth/ paths to prevent SPA
interception, refresh token expiry column migration, and store protocol
updates for the new expiry parameter.
2026-03-16 14:02:12 +02:00
Edward Paget
d6a6030cdb Move .well-known/openid-configuration to top-level per RFC 8414
The OIDC discovery endpoint must be served at /.well-known/openid-configuration
relative to the issuer URL, not under /oauth/. Extract well-known-routes as a
separate top-level route handler and update tests accordingly.
2026-03-16 14:02:12 +02:00
Edward Paget
b50b21291e Add token endpoint and user-id string conversion (Phase 7)
Implement POST /oauth/token endpoint to exchange authorization codes for
access/refresh/ID tokens, completing the core OAuth authorization code flow.
Also fix user-id handling: convert to string for oidc-provider library
compatibility and parse back to integer for DB storage.
2026-03-16 14:02:12 +02:00
Edward Paget
bf09a681d6 Add authorization endpoint and decision handling (Phase 6)
Implement GET /oauth/authorize (validates request, returns consent data)
and POST /oauth/authorize/decision (processes approve/deny, redirects with
code or error). Add client_type column to oauth_client so the oidc-provider
library can distinguish confidential from public clients without relying on
the presence of a client-secret field.
2026-03-16 14:02:12 +02:00
Edward Paget
f53f7b4006 Add admin CRUD API for OAuth client management (Phase 5)
Adds /api/ee/oauth-server/clients endpoints for admin-only static client
registration, listing, reading, updating, and deleting. Uses defendpoint
with Malli response schemas, PBKDF2 secret hashing via oidc-provider, and
metabot-v3 feature gating.
2026-03-16 14:02:12 +02:00
Edward Paget
262f6e2549 Add dynamic client registration and client read endpoints (Phase 4)
Implement RFC 7591 dynamic client registration (POST /oauth/register) and
RFC 7592 client configuration read (GET /oauth/register/:client-id) for the
embedded OIDC provider. Registration access tokens are hashed via PBKDF2
before storage, with hash verification used for client read authentication.

Also simplify store key conversion helpers to use update-keys with
u/->kebab-case-en and u/->snake_case_en instead of manual field-by-field
mapping.
2026-03-16 14:02:12 +02:00
Edward Paget
275600ed3f Add database-backed OAuth/OIDC provider storage (Phase 3)
Replace in-memory stores with persistent DB implementations so OAuth
state survives restarts and works across multiple app instances.
Adds Liquibase migrations for four tables (oauth_client,
oauth_authorization_code, oauth_access_token, oauth_refresh_token),
Toucan 2 models, protocol implementations (ClientStore,
AuthorizationCodeStore, TokenStore, ClaimsProvider), and wires them
into the provider construction.
2026-03-16 14:02:11 +02:00
Edward Paget
5b55900072 Add OIDC discovery and JWKS endpoints (Phase 2)
Expose /oauth/.well-known/openid-configuration and /oauth/jwks using the
defenterprise pattern: OSS route handlers return 404 when the enterprise
functions return nil; EE implementations serve responses from the
oidc-provider library gated behind :metabot-v3.
2026-03-16 14:02:11 +02:00
Edward Paget
cc52f9012c Add embedded OAuth/OIDC provider enterprise module (Phase 1)
Wire oidc-provider as a local dependency and create the
metabase-enterprise.oauth-server module skeleton gated behind
the :metabot-v3 feature flag. The provider boots lazily on first
access, generates and persists an RSA signing key via settings,
and uses in-memory stores (database-backed stores deferred to
Phase 5). No routes or endpoints yet — just the foundation.
2026-03-16 14:02:11 +02:00
Edward Paget
f437f0b011 Add OIDC provider integration roadmap
Phased plan for embedding an OAuth/OIDC server in Metabase Enterprise,
with database-backed storage prioritized early (Phase 3) so all
subsequent client registration and auth flows build on production-grade
persistence from the start.
2026-03-16 14:02:11 +02:00
Chris Truter
02db0e386b Simplify 2026-03-16 13:59:46 +02:00
Chris Truter
26688d3c72 Pass on scopes from MCP session to synthetic request 2026-03-16 13:59:46 +02:00
Chris Truter
6ad661b3ed MCP tools: URL-encode path params, cache manifest, and add doc comments
- URL-encode path parameter values in interpolate-path
- Cache manifest in prod (matching tool-index pattern)
- Document StreamingResponse lifecycle bypass (BOT-1120)
- Document unrestricted token scopes on synthetic requests
2026-03-16 13:59:46 +02:00
Chris Truter
28b36ec2d5 MCP spec compliance: session error codes, Origin validation, and protocol fixes
- Return 404 (not 400) for invalid/expired sessions per MCP spec
- Add Origin header validation to prevent DNS rebinding attacks
- Log client info during initialize handshake
- Use JSON-RPC error format for 405 responses
- Omit Content-Type on 202 notification-only responses
2026-03-16 13:59:46 +02:00
Chris Truter
5bd2f14f99 Actually we don't need the fallback, can configure the api key with Claude after all 2026-03-16 13:59:46 +02:00
Chris Truter
4713932b8f Update module config 2026-03-16 13:59:46 +02:00
Chris Truter
76fd56dfc8 Don't statically cache tools in dev 2026-03-16 13:59:46 +02:00
Chris Truter
25506cc476 lints 2026-03-16 13:59:46 +02:00
Chris Truter
7d5967016a Verify execute-query works and uses streaming 2026-03-16 13:59:46 +02:00
Chris Truter
e5809217e0 unify tool dispatch + tell roboreviewers to back off
Replace the soft TODO comment on the MCP superuser fallback with an
explicit warning block so automated reviewers stop suggesting its
removal.

Also adds query param support for GET/DELETE tool calls and
unifies routing through dispatch-via-agent-api.
2026-03-16 13:59:46 +02:00
Chris Truter
c692fb87fd securtiy: scope sessions by user to avoid theft 2026-03-16 13:59:46 +02:00
Edward Paget
560f783f48 fix: correctly get streaming response callback 2026-03-16 13:59:46 +02:00
Noah Moss
baa7022a60 fix tests 2026-03-16 13:59:45 +02:00
Noah Moss
7c475cadcb swap out static manifest for generated on 2026-03-16 13:59:45 +02:00
Noah Moss
13c82c757b Fix tests, new tests, other small tweaks 2026-03-16 13:59:45 +02:00
Noah Moss
2cbaf9b97e better batch error handling 2026-03-16 13:59:45 +02:00
Noah Moss
fc6b2a7bfb small cleanups in sse-response and handle-delete; add initialized check in handle-get 2026-03-16 13:59:45 +02:00
Noah Moss
bf9f3fbe15 simplify json-rpc dispatch, particularly initialization, and reject initialize if part of a batch per spec 2026-03-16 13:59:45 +02:00
Noah Moss
5871e5acdd Fix lint; light refactor of dispatch-request with new when-initialized macro 2026-03-16 13:59:45 +02:00
Chris Truter
c1053dbd1c Unneeded 2026-03-16 13:59:45 +02:00
Chris Truter
20bc6fcb11 Fixes 2026-03-16 13:59:45 +02:00
Chris Truter
631cdab28b Missing import 2026-03-16 13:59:45 +02:00
Chris Truter
2c84483193 Shame on you 2026-03-16 13:59:45 +02:00
Chris Truter
054f599932 It's not naughty, it's intentional 2026-03-16 13:59:45 +02:00
Chris Truter
c0b96dda99 Tweaks and concessions 2026-03-16 13:59:45 +02:00
Chris Truter
53e630df61 Towards being more generic 2026-03-16 13:59:45 +02:00
Chris Truter
df7e47e4c0 Towards no custom wrappers, but not quite there 2026-03-16 13:59:45 +02:00
Chris Truter
2e27d3e8f8 Built-in MCP server 2026-03-16 13:59:45 +02:00
Oleksandr Solovyov
75e1ac2a20 properly fetch data for user-is-viewing context (#70857) 2026-03-16 11:40:10 +02:00
Chris Truter
b77edbe138 Generate tool manifest from endpoint metadata (#70780)
* Generate tool manifest from endpoint metadta

* Delete reference file

* tweak-and-tidy

* More testing, more robustness

* Fix kondo and cljfmt

* Scrap name inference

* Clean up tool assertions

* Remove silly separators

* Switch away from tool registry approach

* rename + slightly simplify prefer-tool-descriptions

* Replace definitions binding with an explicitly threaded atom

* Minor cleanups/refactor

* Pull over some annotations from my prior PR; tweak test comments

* address comments

---------

Co-authored-by: Noah Moss <noahbmoss@gmail.com>
2026-03-16 09:58:15 +02:00
Oleksandr Solovyov
38b69b277f API keys are sensitive (#70847) 2026-03-16 09:58:14 +02:00
Oleksandr Solovyov
5b976c3559 surface openrouter error messages 2026-03-16 09:58:14 +02:00
lbrdnk
1c0f71683a Ai service port validate sql (#70259)
* metabot-v3.tools.util/validate-sql stub

* Add query to dialect mapping

* Hook validation into tools code

* Add validation error instruction

* query-by-id helper

* Move sql validation to agent/tools/sql.clj

* WIP convert edit-sql-query-tool

* Revert edit-sql-query tool

* Drop validation code from tools/util.clj

* Add tools/sql_validation.clj

* Use sql-validation in tools/create_sql_query.clj

* Use sql-validation in agent.tools.sql/create-sql-query-tool

* Add transpile_sql to sql_tools.py

* Add transpile-sql to sql-parsing

* Macaw implementation stub

* Add transpile-sql interface multimethod

* Update transpile_sql in sql_tools.py

* sql-tools/transpile-sql stub

* Add template tag sentinel to sql_tools.py

* sql-tools/transpile-sql schema

* Sqlglot transpile-sql wrapper

* sql-parsing/transpile-sql result normalization

* Hook sql-tools/transpile-sql into metabot-v3.tools.sql-validation/validate-sql

* Adjust create-sql-query-tool to validate-sql changes

* Adjust reason in sql_tools.py

* Add create-sql-query-validation-failure-test

* Addjust validate-sql

* Revert edit-sql-query tool

* Add validation to metabot_v3/tools/edit_sql_query.clj

* Add schema and update returns for create-sql-query

* Add metabot-v3.tools.sql.common

* Update edit_sql_query

* Update edit_sql_query tool

* Add validation failure test for edit-sql-query-tool

* Adjust edit-sql-query-code-edit-tool

* Adjust create-sql-query-code-edit-tool

* Update replace-sql-query and related

* Adjust replace-sql-query-tool

* Update replace-sql-query-code-edit-tool

* Adjsutment for test

* metabot-v3.tools.sql.validation ns

* validate-sql sentinels

* Hoist template tags sentinel into tools.sql.validation

* Update schema

* Validation tests

* Add transpilation tests and adjust sql_tools.py

* Add dialect mapping

* Adjust validation and tests

* Adjust test

* linter

* Cleanup

* Add tests

* Tests and validation

* More tests and cleanup

* Drop redundant tests

* Use original dialect

* Adjust tests

* whitespace

* Remove direct query manipulation from tests

* Remove direct mbql manipulation from create-sql-query

* Remove more of direct query manipulation

* More remarks
2026-03-16 09:58:14 +02:00
Chris Truter
e7ec20e89b Fix incorrect docstrings and copy-paste errors in settings and tools (#70793) 2026-03-16 09:58:14 +02:00
appleby
99836a4b35 BOT-1085 Allow user-intent classification to be disabled (#70521)
Add a new ee-ai-metabot-internal-tasks-enabled? setting to allow disabling metabot internal "bookkeeping" tasks that might make additional llm calls, like user intent classification.
2026-03-16 09:58:14 +02:00
Mike Appleby
3462b410ef Add ai-service-port to branches list in uberjar.yml 2026-03-16 09:58:14 +02:00
Oleksandr Solovyov
7e7d2d86bc revert back renames for ai-service embedding provider to evade migration (#70649) 2026-03-16 09:58:14 +02:00
Oleksandr Solovyov
bf9b151031 fix snowplow test 2026-03-16 09:58:14 +02:00
Oleksandr Solovyov
85f9489b62 sql profile requires tool call to be forced (#70497) 2026-03-16 09:58:14 +02:00
Maksym Yakubych
6f63500e93 fix: metabot has no context on the currently open transform (#70506) 2026-03-16 09:58:14 +02:00
Oleksandr Solovyov
779dd110e3 fix modules config 2026-03-16 09:58:14 +02:00
Oleksandr Solovyov
68715eefe2 use embeddings proxy directly rather than via ai-service (#70419) 2026-03-16 09:58:14 +02:00
appleby
247e711992 BOT-1054 Add snowplow events for user intent classification (#70351)
* BOT-1054 Add snowplow events for user intent classification

* Move run-agent-loop :conversation-id into :tracking-opts :session-id

It does make some sense as a top-level argument, but it's currently only used for snowplow tracking, so push it down
into the tracking-opts.

* Move run-agent-loop :track-user-intent? into :tracking-opts

* Add ee-ai-metabot-provider-lite defsetting and use it in classify-user-intent!

This allows using a lighter weight model for simple tasks like user intent and/or agent response
classification. Defaults to openrouter/openai/gpt-oss-20b which is the same model used by ai-service for these tasks.

* Add run-agent-loop! test helper to run the agent for side effects

* whitespace: fix alignment

* Extract :tracking-opts schema into an mr/def similar to other agent opts

* Rename :profile-name -> :profile-id in tracking-opts
2026-03-16 09:58:14 +02:00
appleby
16c287cd71 BOT-1053 Add snowplow events for agent tool calls (#70338)
* BOT-1039 Create metabase.analytics.util

And move some func there from metabase.analytics.llm-token-usage that will also be needed for agent_used_tool events.

* Add ai_service_event snowplow schema

In preparation for tracking the following events:

- agent_tool_used
- user_intent
- agent_action_summary

* Track snowplow agent_used_tool events in metabase-enterprise.metabot-v3.self/call-llm

- add agent loop :iteration to tracking-opts
- add :duration-ms to :tool-output parts
- add report-tool-usage-xf to transducer chain in metabase-enterprise.metabot-v3.self/call-llm to track
  :snowplow/ai_service_event with :event "agent_used_tool"
2026-03-16 09:58:14 +02:00
Maksym Yakubych
8ab065af94 fix: common library not used by agent 2026-03-16 09:58:14 +02:00
Maksym Yakubych
5e00360fa2 fix: add error field to transforms 2026-03-16 09:58:14 +02:00
Oleksandr Solovyov
974b595784 drop unused tool find-outliers (#70424) 2026-03-16 09:58:14 +02:00
Maksym Yakubych
e1a5fb6191 fix: context not included in sql profile 2026-03-16 09:58:13 +02:00
appleby
591b2f480e BOT-1039 Move token-usage funcs into analytics module (#70213)
* BOT-1052 Move token-usage funcs into analytics module

So they can be shared between oss sql gen and metabot.

* Add some snowplot tests for self/call-llm and call-llm-structured

* Add prometheus tests for call-llm-structured

* Add basic snowplow and prometheus tests for example question generation

* Modify agent loop snowplot test to use with-fake-snowplow-collector

* Modify native-generate-content-snowplow-test to use with-fake-snowplow-collector

* Fix whitespace
2026-03-16 09:58:13 +02:00
appleby
1c4ba0d1ab BOT-1039 Add snowplow token_usage event tracking to the clojure agent (#70049)
* BOT-1039 Include the profile :name in the profile map in register-profile!

Sometimes it's useful to be able to exract the profile name when you only
have a reference to the profile.

* Add token_usage snowplow event tracking to the clojure agent

- refactor register-profile! to single-arg form with :name now stored in the profile map and returned by get-profile
- report-token-usage-xf now emits a :snowplow/token_usage event per LLM call alongside existing prometheus metrics
- pass tracking-opts from loop-step through call-llm into the token-usage transducer
- likewise for callers of call-llm-structured
- init-agent generates a per-run request-id UUID
- run-agent-loop accepts an optional conversation-id that turns into the snowplow token_usage session_id

* Add :model to tracking-opts and drop separate source arg

The prometheus source label should always be the same as the snowplow :tag (that's how it works in ai-service), so
drop the separate `source` arg from various functions and just take the source label from the tracking-opts :tag.

* Destructure token-opts in report-token-usage-xf

* Allow passing tracking-opts to run-agent-loop

And use this to override the :source in metabot-v3.api.document/native-generate-content

* Improve docstring for tracking-opts

* Provide model and source defaults in report-aisdk-errors-xf

* Fallback to analytics-uuid if premium-embedding-token is unset

* Fix typo: snowplot -> snowplow

* Compute hashed-token-or-uuid once rather than for each usage part in report-token-usage-xf
2026-03-16 09:58:13 +02:00
appleby
850195ed93 BOT-1051 Fix provider in generate-example-questions (#70121)
After #70061 call-llm-structured now expects the model to include a
provider prefix, so "anthropic/claude-haiku-4-5" was getting parsed as
:provider "anthropic" :model "claude-haiku-4-5".

Update metabase-enterprise.metabot-v3.example-question-generator/call-llm to
use the provider + model configured via llm/ee-ai-metabot-provider
2026-03-16 09:58:13 +02:00
appleby
3f0ca754bc BOT-866 Add prometheus metrics to metabot clojure agent part II (#70094)
* BOT-866 Add prometheus metrics to metabot clojure agent

* BOT-866 Also count inline aisdk errors

* BOT-866 push prometheus token counting down into call-llm

This is more in keeping with how current ai-service reports the metric, i.e. per llm call rather than a single batched
token usage per agent request.

This also has the advantage of keeping most prometheus metrics localized to the llm layer.

* Ensure that :metabase-metabot/llm-requests counts retries

This is more consistent with the existing ai-service llm_invocations_total metric

* Make :metabase-metabot/llm-duration-ms per llm invocation rather than cumulative

This more closely matches the existing ai-service llm_duration_ms metric

* Add :metabase-metabot/agent-duration-ms prometheus metric

Records the full duration of the outer agent loop, now that :metabase-metabot/llm-duration-ms records individual llm calls.

* Add comment on :metabase-metabot/llm-duration-ms buckets

And extend the low end of the range down to 100ms

* fixup! Add :metabase-metabot/agent-duration-ms prometheus metric

* Fix whitespace

* Add prometheus metrics tracking to call-llm-structured

* Allow llm callers to specificy the source label for prometheus metrics

And pass "example-question-generation" for question generation.

* Fix call-llm-prometheus-test

After rebasing on #70061 these tests need to include the "openrouter/" provider prefix along with the model.
2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
7e8e46d18c llm model select (#70061) 2026-03-16 09:58:13 +02:00
Maksym Yakubych
d6215f6baf feat: documents content generation for clojure metabot (#69807) 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
4483bdf7a0 prompt generation (#69970) 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
251d26464c fix error message in custom-elements e2e 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
b710a466d2 drop separate url for native agent, only have single url with a toggle 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
3934e2e774 Get tools back (#69901) 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
25869f04e7 fix api test formatting 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
68252b2493 fix usage in native streaming 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
51c86365d2 oh yeah very important type 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
56b54ed1d9 fix remove-unreadable-transforms 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
5c3934b58c fix field_stats test 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
e5eaf025d2 fix temporal checks 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
15f2e11391 test fixes 2026-03-16 09:58:13 +02:00
Oleksandr Solovyov
281f716904 get-dashboard-details 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
d3ac154add transforms in read_resource tool 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
a569b190e8 clj-kondo/cljfmt 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
6de32541e6 cumulative usage from agent loop to reduce need for arithmetics 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
e763fb6677 fix agent tests 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
3794cc5ecf better schema for incoming messages 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
a2dd3826ab fix test for closing connections 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
8c330cba24 review comments 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
e2d26e56f9 openrouter as main provider 2026-03-16 09:58:12 +02:00
Mihael Konjevic
3bc4fc0a95 Fix issues with the sql profile 2026-03-16 09:58:12 +02:00
Mihael Konjevic
ad3f4435a3 Replace raw/endraw tags with safe/endsafe 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
f9672bc248 make sure request cancellation works through with native agent streaming 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
4d788478f3 fix token undercounting 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
53aa8c1a5e no more stacktraces on agent errors 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
6938e94616 great namespace movement 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
284e03085d retry logic 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
03339e9774 simpler date filters handling 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
da1c0ef28b more clear naming 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
7725eb2d05 fancy field select - reduces amount of tool calls needed 2026-03-16 09:58:12 +02:00
Oleksandr Solovyov
bd0f7da953 slight prompt rewording to bring it closer to what benchmarks measure 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
a022acc732 instruct llms to use fq table names 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
6b607d0c87 python has no header for tool instructions 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
e7bb51cfc3 fix claude usage tracking 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
ee0545bc2d do not escape instructions 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
c6d9961443 debug for python ai-service 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
68a1522d8f accept both variations of db id 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
2bfaa0bd2b unify query formatting 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
6f168ad6c8 fixes to improve llm behavior 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
c2031934a5 ability to debug llm queries 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
88e3b5c922 handle inline llm errors 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
3527397cb1 proper capability check 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
ca96cfff68 no :system-instructions 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
d0746c6e0a proper rendering for query data 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
77332b5bfe put queries in the context 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
ad5b28e848 fix 'SQL Fixing' 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
794299eacd fix construct rendering in system prompt 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
d79a7f756c good filters validation/errors to llm 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
cf966a9b5b coerce tool args to json 2026-03-16 09:58:11 +02:00
Oleksandr Solovyov
e0a6ac6430 tool result should always have 'result' key 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
9ca1d62381 report tool args well 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
145833a7d7 proper context instructions for sql tools 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
e42aa09480 sql dialect 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
8c493b0554 little cleanups and field-metadata with instructions 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
6808dab261 improve search results tool response 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
342ba6c04f more prompt changes 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
5dd2b3735a fix fe formatting 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
4f282e8539 fix read_resource 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
6056e4578a make clj-kondo happy 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
0bdc1025df fix modules config 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
1fe9a996a2 datadog observability 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
c5d9bb71fc system prompts 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
ab9abb5d76 collecting usage and schema on tool 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
5a120186c1 format tools to string at their definition 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
c84c20ba4c use simple templates more 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
a385f56189 configurable api urls for llms 2026-03-16 09:58:10 +02:00
Oleksandr Solovyov
2ab01f43a2 cleanup agent.messages code/tests a bit 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
4557082c07 metabot error messages and tests 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
8a694c33a8 potentially: ability to reload repl state when js is compiled differently 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
347f97d393 better error messages from metabot backend 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
0802e6ae85 simpler tool executor function 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
b9e5f94031 make clj-kondo happier 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
e1a60f23d5 fix read resource output format 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
9971986146 test 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
91f9980779 cljfmt 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
86a0873890 integration test 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
5474dd48e1 cleanup user context templates 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
5f5f83957a cleaning up links code duplication 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
c13396f295 update modules config 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
b48af13049 output text fluidly to a user 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
e538744bc5 little things 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
97a783e83c update path for e2e tests 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
f68770ec33 fix api test 2026-03-16 09:58:09 +02:00
Oleksandr Solovyov
8eb5e4c5a8 cljfmt 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
4c79a075cd damn 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
7b135c1afe is this the last test 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
f00448d97b clj-kondo woes 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
b7e2008016 fix profiles.clj 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
cb4cbe3c62 llm-repr test fixes 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
5e68b1e357 tools tests 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
b821d37e66 api tests 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
2112ec3b43 tools tests 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
ac12564826 fix capabilities/nicer profiles 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
b18c52e05a ehhh sideeffect 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
b4c56f64cf clarify 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
b984e1e97d Refactor metabot v3 to use IReduceInit reducibles instead of core.async
- Replace channels with IReduceInit (reducible) pattern throughout
- Standard Clojure transducers work directly: (into [] xf (llm-call {...}))
- Remove core.async dependency from self namespace
- Rename chan? -> reducible?, sse-chan -> sse-reducible
- Rename tool-executor-rff -> tool-executor-xf
- Add malli schemas for agent state, messages, parts
- Simplify agent loop using reducible composition
- Update tests to use reducible patterns
2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
6897698b77 factor out core.async as much as possible 2026-03-16 09:58:08 +02:00
Mihael Konjevic
5efa2ac72f Simplify metabot tool wrappers 2026-03-16 09:58:08 +02:00
Mihael Konjevic
084deb0615 Route agent tools to core implementations 2026-03-16 09:58:08 +02:00
Mihael Konjevic
5e992f0741 Refactor metabot v3 tools namespace 2026-03-16 09:58:08 +02:00
Oleksandr Solovyov
40e8fc1d49 define caps by metadata 2026-03-16 09:58:08 +02:00
Mihael Konjevic
35ccec9896 Refactor tool result formatting 2026-03-16 09:58:07 +02:00
Mihael Konjevic
aeabe88d8b Render LLM representations with Selmer 2026-03-16 09:58:07 +02:00
Mihael Konjevic
9d4d6ec058 Refactor metabot tool registration 2026-03-16 09:58:07 +02:00
Mihael Konjevic
fe52e483a7 AI Service cleanup 2026-03-16 09:58:07 +02:00
Mihael Konjevic
e57e8a4037 Cleanup 2026-03-16 09:58:07 +02:00
Mihael Konjevic
1b52ad209f Refactor SQL tools to use in-memory state and data parts
Key changes:
- SQL tools (edit_sql_query, replace_sql_query) now operate on in-memory
  query state instead of persisting to database
- Convert tools from reactions to data-parts pattern (navigate_to)
- Add semantic_queries and keyword_queries to search tool schema
- Make edit_sql_query and replace_sql_query state-dependent tools
- Support string query IDs (in addition to int) for memory-based queries
- Extract SQL dialect only from native query context type
- Use streaming.navigate-to-part instead of direct reactions
- Add instructions pattern to SQL tools for better LLM guidance

This aligns the SQL tools with the AI Service's transient query model
where queries exist in conversation state until explicitly saved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
13af19eaaf Add streaming link buffer and improve AI service compatibility
Key changes:
- Add markdown_link_buffer.clj for handling links split across streaming
  text chunks (matches Python AI Service MarkdownLinkBuffer behavior)
- Seed agent state with queries from user_is_viewing context
- Support both snake_case and kebab-case keys in context for prompts
- Normalize entity types (keyword/string) in message formatting
- Decode tool arguments from JSON strings when needed
- Add table link resolution (metabase://table/{id})
- Preserve system-instructions when wrapping state-dependent tools
- Fix iteration boundary check (use inc iteration for comparison)
- Gracefully handle unresolvable links by falling back to link text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
c3a13be914 Implement AI Service parity for native Clojure agent
This commit adds feature parity between the Clojure native agent and
Python AI service, implementing all phases from the parity plan.

New Tools:
- todo_write / todo_read: Task management with todo_list data parts
- navigate_user: Page and entity navigation with redirect reactions
- ask_for_sql_clarification: SQL clarification with final-response flag
- write_transform_sql / write_transform_python: Transform code editing
- create_autogenerated_dashboard: X-ray dashboard generation
- list_snippets / get_snippet_details: SQL snippet access

Core Changes:
- State data part format: {type: data, data-type: "state", version: 1}
- Finish message format: {type: finish, finish-reason: "stop"|"error"}
- New data part types: todo_list, code_edit, transform_suggestion
- Data part streaming from tool results via :data-parts key
- Final response pattern: tools return :final-response? true to stop loop
- Capability-based tool filtering with tool-capabilities map

Profile Updates:
- internal: Added navigate_user, create_autogenerated_dashboard
- transforms-codegen: Added todo tools, transform write tools, snippets
- sql-only: Added ask_for_sql_clarification, :required-tool-call? flag
- nlq-only: Added navigate_user, create_chart, edit_chart

Tests:
- Comprehensive tests for all new tools (29 new tests, 200+ assertions)
- Updated existing tests for new formats and capabilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
c4978ba5fb Add auto-navigation and fix tool output handling for native agent
- Add redirect reactions to SQL query tools (create, edit, replace) for
  auto-navigation after query operations
- Add streaming module to convert reactions to AI SDK data parts
- Fix structured output extraction to handle both :structured-output and
  :structured_output key formats (Clojure vs JSON style)
- Add role normalization in message formatting to handle both keyword
  and string roles from frontend
- Include :query key in SQL tool returns for memory storage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
880b152bf5 Align Clojure LLM representations exactly with Python AI Service
Updates tool output formatting to match Python AI Service patterns exactly:

- field->xml: Add quotes around name, database_type attr, ## Description header
- metric->xml: Add collection, default_time_dimension_field, metabase:// URIs
- table->xml: Add database_engine, fully_qualified_name, related_tables section
- model->xml: Use <metabase-model> tag, slug-based fqn, related_tables
- query->xml: Include database_id, query-content, query_results with rows
- Add question->xml, dashboard->xml, collection->xml, user->xml formats
- Add visualization->xml for full chart representation
- Add field-metadata->xml, get-metadata-result->xml for metadata results
- Update search result tags: model→metabase-model, card→metabase_question
- Update instructions.clj with complete Python-matching guidance text
- Update messages.clj to use <metabase-models> tag
- Expand tests to verify Python format compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
2b2c0df5a2 Add InstructionResultSchema pattern for native agent tool outputs
Align Clojure native agent tool outputs with Python AI Service patterns
by wrapping results in <result>/<instructions> XML format for consistent
LLM guidance.

- Add llm_representations.clj with entity-to-XML formatting utilities
- Add instructions.clj with LLM guidance constants for each tool type
- Update messages.clj to use format-with-instructions wrapper
- Add type-specific formatters for search, entity, query, chart results
- Include comprehensive tests for LLM representation functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
2656abf3f2 Fix tool result formatting for LLM and link resolution
Key changes:
- Format tool results as XML for LLM consumption (messages.clj)
  - Query results now show query ID clearly: <query id="..." type="query">
  - Chart results include chart ID and reference link
  - This matches Python ai-service format so LLM can extract IDs properly

- Add fallback for chart link resolution (links.clj)
  - When LLM uses metabase://chart/ with a query ID, fall back to
    treating it as a query link
  - Handles common LLM mistake of using wrong link type

- Fix chart extraction logic (core.clj)
  - Charts identified by :chart-id + :query-id (without :query)
  - Distinguishes charts from query results correctly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
644c0a412d Add metabase:// link resolution for native agent streaming
The native agent now resolves metabase:// URIs in text parts before
streaming to clients. This mirrors the Python AI service's
MarkdownLinkBuffer behavior.

Link types supported:
- metabase://query/{uuid} - Resolves to /question# using stored query
- metabase://chart/{uuid} - Resolves to /question# via chart's query
- metabase://model/{id} - Resolves to /model/{id}
- metabase://metric/{id} - Resolves to /metric/{id}
- metabase://dashboard/{id} - Resolves to /dashboard/{id}
- metabase://question/{id} - Resolves to /question/{id}
- metabase://transform/{id} - Resolves to /admin/transforms/{id}

Changes:
- links.clj: New module for link resolution with markdown parsing
- core.clj: Integrate link processing in stream-parts-to-output!
- Tests for link resolution and streaming integration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
3402e02829 Add state management for query/chart IDs in native agent tools
State-dependent tools (show_results_to_user, create_chart, edit_chart) now
look up queries and charts from agent memory by ID instead of expecting raw
query objects. This mirrors the Python AI service pattern.

Key changes:
- memory.clj: Add query/chart storage functions (remember-query, find-query,
  store-chart, find-chart, load-queries-from-state, load-charts-from-state)
- core.clj: Extract queries/charts from tool outputs and store in memory
- tools.clj: Wrap state-dependent tools with memory atom access
- claude.clj: Update schema to accept wrapped tool maps with :fn/:doc/:schema
- self/core.clj: Handle wrapped tool maps in tool-executor-rff
- show_results_to_user.clj: Accept query-id and look up from queries-state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
5d7737a671 Fix native agent loop for multi-turn conversations with tools
Key fixes:
- Fix premature loop exit: Continue when tool calls present, regardless of text
- Fix Claude API integration: Extract system message separately, pass via :system param
- Fix tool name mismatch: Pass tools as [name, var] pairs so Claude sees registry keys
- Fix message format for tool_use: Use content array format for assistant messages
- Handle START_STEP/FINISH_STEP types in aisdk->messages
- Rename profiles to simpler keys (:embedding, :internal, etc.)
- Add /native-agent-streaming endpoint for testing (bypasses feature flag)
- Add debug logging for message building

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
98d1b1c8bb Add AI SDK v4 line protocol output format for native agent
The Python ai-service outputs a specific line protocol format that the
frontend expects. This adds the conversion layer to output the same format
from the native Clojure agent.

Format examples:
- 0:"text content"  - Text (JSON-encoded string)
- 2:{"type":...}    - Data parts
- 3:"error"         - Errors
- 9:{"toolCallId":..., "toolName":..., "args":...}  - Tool calls
- a:{"toolCallId":..., "result":...}  - Tool results
- d:{"finishReason":"stop", "usage":{...}}  - Finish message
- f:{"messageId":...}  - Start message

Changes:
- Add format-* functions for each part type in self/core.clj
- Add aisdk-line-xf transducer for batch conversion
- Export new functions via self.clj
- Update api.clj to stream parts in line protocol format
- Add comprehensive tests for all format functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
e50bc621a4 Implement Phase 2C chart tools, 2F profiles, and fix async blocking
Phase 2C - Chart Tools:
- Add create_chart tool to create visualizations from queries
- Add edit_chart tool to modify existing chart types
- Register chart tools in agent tools registry

Phase 2F - Profiles & Templates:
- Port sql-querying-only.selmer from Jinja2 (fixes broken :metabot-sql-only)
- Port metabot-next.selmer template
- Port natural-language-querying-only.selmer template
- Add :metabot-next, :metabot-nlq-only, :metabot-beta-customer profiles

Async Blocking Fix:
- Use a/thread instead of a/pipeline-blocking in run-llm-with-tools
- tool-executor-rff uses a/<!! in completion arity which cannot run in go-blocks
- Synchronous transducer application in thread context is now aligned with self code
- Fix stream-parts-to-output! and finalize-stream! to use a/>! in go blocks
- Fix api.clj syntax error with metadata on string literal
- Update tests to generate proper Claude raw SSE format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
702ca8bece Add read_resource tool for fetching Metabase resources via URIs
Implement the foundational read_resource tool that provides unified
interface for fetching detailed information about Metabase resources
using URI patterns. This tool implements the "Context Engineering with
Links" pattern where URIs serve as lightweight, token-efficient
references that can be fetched on-demand at the appropriate detail level.

Supported URI patterns:
- metabase://table/{id} - Basic table info
- metabase://table/{id}/fields - Table structure with fields
- metabase://table/{id}/fields/{field_id} - Specific field details
- metabase://model/{id}/fields - Model structure with fields
- metabase://metric/{id}/dimensions - Metric dimensions
- metabase://transform/{id} - Transform details (not yet implemented)
- metabase://dashboard/{id} - Dashboard details (not yet implemented)

Key features:
- Parses metabase:// URIs and routes to appropriate handlers
- Supports fetching multiple resources in parallel (max 5)
- Handles field IDs with slashes (e.g., c75/17)
- Returns formatted XML-like output for LLM consumption
- Provides detailed error messages for invalid URIs

This tool is critical for:
- Enabling agents to get more context about resources on demand
- Supporting workflows where search returns URIs that agents fetch later
- Reducing token usage by deferring detail fetching until needed

Changes:
- Created read_resource.clj with URI parsing and resource handlers
- Added read-resource-tool wrapper in tools.clj with Malli schema
- Updated all 4 profiles to include read_resource tool
- Added comprehensive tests for URI parsing and resource fetching

Tests: 24 assertions, 0 failures

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 09:58:07 +02:00
Mihael Konjevic
d56acdc1db Fix SQL tools to handle lib/query format
Fixed multiple issues with SQL tools not handling the transformed
lib/query format when Card models are read from the database:

## Issues Fixed

1. **extract-sql-content not finding SQL**: Card dataset_query transforms
   from {:native {:query "SQL"}} to {:stages [{:native "SQL"}]} when read.
   Updated extract-sql-content in all SQL tools to handle both formats.

2. **OffsetDateTime vs Date**: The rank-result function in sql_search
   called .getTime on timestamps, but Toucan2 returns OffsetDateTime.
   Added instanceof check to convert appropriately.

3. **Missing visualization_settings**: Test code using t2/insert! needed
   :visualization_settings {} field (database constraint).

4. **Test permissions**: Wrapped all test code with mt/with-current-user
   to provide proper permission context.

5. **Fixed typo in sql_search**: Changed accessible-cards variable reference
   from undefined to all-cards.

## Files Changed

- edit_sql_query.clj: Handle both lib/query and legacy formats
- replace_sql_query.clj: Handle both lib/query and legacy formats
- sql_search.clj: Handle both formats + OffsetDateTime conversion
- sql_tools_test.clj: Add visualization_settings, fix permissions,
  add clojure.string alias, fix indentation

## Test Results

All 19 assertions passing (4 test functions):
- edit-sql-query-test: 4 edit types tested
- replace-sql-query-test: Replace with metadata preservation
- sql-search-test: Search, filter by database, limit parameter
- integration-create-edit-search-test: Full workflow

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 09:58:06 +02:00
Mihael Konjevic
cccd28b2bb Implement Phase 2B: SQL Query Tools
Add four SQL query manipulation tools to enable the agent to create, edit,
and search SQL queries:

New Tools:
- create_sql_query: Create new SQL queries with validation
- edit_sql_query: Apply structured edits (replace, append, insert, etc.)
- replace_sql_query: Replace SQL content entirely while preserving metadata
- sql_search: Search for SQL queries by content with relevance ranking

Features:
- Direct Toucan2 Card model access (no HTTP round-trips)
- Database and permissions validation
- Structured edit operations (replace, replace-all, append, prepend, insert-after, insert-before)
- Search with ranking by name match, description match, SQL position, and recency
- Integration with existing agent tool registry
- Added to metabot-internal profile and new metabot-sql-only profile

Testing:
- Comprehensive test coverage for all SQL tools
- Integration tests for full workflow (create → edit → search)
- All tests passing (18 assertions, 0 failures)

Architecture:
- Follows existing tool pattern (mu/defn with Malli schemas)
- Returns structured output for agent state tracking
- Error handling with agent-error? flag for LLM-friendly messages

Phase 2B complete. Ready for Phase 2C: Visualization Tools.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 09:58:06 +02:00
Mihael Konjevic
b5b67c556c Fix async blocking issue in agent loop by using pipeline-blocking
The tool-executor-rff reducing function blocks in its completion arity
to wait for tool results. Using a/pipeline causes 'Invalid blocking call
in dispatch thread' errors because the regular pipeline uses a fixed
thread pool that doesn't allow blocking.

Solution: Use a/pipeline-blocking instead, which uses a separate thread
pool designed for blocking operations. This allows the tool executor's
completion arity to safely block while waiting for tool results.

Fixes the core-test integration test failures.
2026-03-16 09:58:06 +02:00
Mihael Konjevic
1352f69644 Add Selmer dependency for template rendering
Selmer v1.12.70 provides Jinja-like templating for Clojure,
used by the Phase 2A prompts system for rendering system messages
with dynamic context variables.
2026-03-16 09:58:06 +02:00
Mihael Konjevic
1402b5654f Integrate Phase 2A: Prompts & Context System into agent loop
This commit completes Phase 2A of the native Clojure agent implementation,
integrating the templated prompt system with context enrichment into the
main agent execution loop.

## New Features

### Prompt System (prompts.clj)
- Selmer template loading with caching
- SQL dialect instruction injection (14 dialects supported)
- Tool instruction extraction from docstrings
- System message template rendering with context variables

### Context Enrichment (user_context.clj)
- Timezone-aware time formatting
- SQL dialect extraction from context
- Viewing context formatting for all types:
  - Adhoc queries (notebook editor)
  - Native SQL queries with error context
  - Transforms with source information
  - Code editor with buffer/cursor/selection details
  - Entity details (tables, models, questions, metrics, dashboards)
- Recent views formatting with relevance hints
- Full context enrichment pipeline for template variable injection

### Resource Files
- 3 system prompt templates (embedding, internal, transform-codegen)
- 14 SQL dialect instruction files
- All templates support dynamic context injection

## Integration Changes

### Agent Core (core.clj)
- Updated build-messages-for-llm to accept context, profile, and tools
- Integrated system message building with enriched context
- Fixed pre-existing async blocking issue in run-llm-with-tools
  (changed from blocking a/<!! to returning channel for non-blocking a/<!)
- Agent now builds fresh system messages with context on each iteration

### Messages (messages.clj)
- Updated build-system-message to use templated prompts
- Accepts context, profile, and tools parameters
- Calls prompts/build-system-message-content for rendering

### Profiles (profiles.clj)
- Added :prompt-template mapping to all three profiles
- Maps profile IDs to template files in resources/metabot/prompts/system/

## Tests

All Phase 2A functionality is comprehensively tested:
- prompts-test: 6 tests, 46 assertions ✅
- user-context-test: 5 tests, 81 assertions ✅
- messages-test: 3 tests, 47 assertions ✅
- profiles-test: 1 test, 36 assertions ✅

Total: 15 tests, 210 assertions passing

Note: core-test integration tests have pre-existing failures due to blocking
calls in self/core.clj tool-executor-rff (line 116), which existed before
this change. The async pipeline architecture issue is tracked separately.

## Architecture

The agent now follows this flow on each iteration:
1. Enrich raw context (user_context/enrich-context-for-template)
2. Load and render system prompt template (prompts/build-system-message-content)
3. Build full message array with system message + history
4. Execute LLM call with enriched context
5. Process tool calls and responses
6. Repeat until completion

This matches the Python implementation's prompt and context system while
leveraging Clojure's functional architecture.
2026-03-16 09:58:06 +02:00
Mihael Konjevic
f4db7d9aa1 Implement native Clojure agent for MetaBot v3
This commit implements a production-ready native Clojure agent loop that
replaces the external Python AI Service with an in-process implementation,
leveraging the existing llm-inside streaming infrastructure.

Key Components:
- agent/core.clj: Main agent loop with iteration control and streaming
- agent/memory.clj: Conversation state and step tracking
- agent/messages.clj: Message formatting and LLM communication
- agent/profiles.clj: Profile configurations (embedding, internal, transforms)
- agent/tools.clj: Tool registry with 13 wrapped tools

Integration:
- api.clj: Feature-flagged routing via use-native-agent setting
- settings.clj: New use-native-agent boolean setting (default: false)

Benefits:
- Direct Toucan2 database access (no HTTP round-trips)
- Single JVM process deployment
- Lower latency for tool execution
- Unified logging and error handling
- Reuses 80%+ of existing streaming infrastructure

Test Coverage:
- 5 test files with comprehensive coverage
- All tests passing (133 total assertions)
- Unit tests for memory, messages, profiles, tools
- Integration tests for agent loop with mocked LLM

The implementation is production-ready and can be enabled via feature flag
for gradual rollout and validation against the Python service.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 09:58:06 +02:00
Oleksandr Solovyov
ecb7bef8d9 linter fixes 2026-03-16 09:58:06 +02:00
Oleksandr Solovyov
2b6ab1d577 support for claude to show how different adapters are 2026-03-16 09:58:06 +02:00
Oleksandr Solovyov
7591454681 switch to new openai /v1/responses api, check for structured output support 2026-03-16 09:58:06 +02:00
Oleksandr Solovyov
24ef512886 Libor comments 2026-03-16 09:58:06 +02:00
Oleksandr Solovyov
825a20d4ba Experiment with communicating with LLMs in Clojure 2026-03-16 09:58:06 +02:00
236 changed files with 32678 additions and 1337 deletions

View File

@@ -0,0 +1,173 @@
---
name: clojure-eval
description: Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session.
---
# Clojure REPL Evaluation
## When to Use This Skill
Use this skill when you need to:
- **Verify that edited Clojure files compile and load correctly**
- Test function behavior interactively
- Check the current state of the REPL
- Debug code by evaluating expressions
- Require or load namespaces for testing
- Validate that code changes work before committing
## How It Works
The `clj-nrepl-eval` command evaluates Clojure code against an nREPL server. **Session state persists between evaluations**, so you can require a namespace in one evaluation and use it in subsequent calls. Each host:port combination maintains its own session file.
## Instructions
### 0. Discover and select nREPL server
First, discover what nREPL servers are running in the current directory:
```bash
clj-nrepl-eval --discover-ports
```
This will show all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) running in the current project directory.
**Then use the AskUserQuestion tool:**
- **If ports are discovered:** Prompt user to select which nREPL port to use:
- **question:** "Which nREPL port would you like to use?"
- **header:** "nREPL Port"
- **options:** Present each discovered port as an option with:
- **label:** The port number
- **description:** The server type and status (e.g., "Clojure nREPL server in current directory")
- Include up to 4 discovered ports as options
- The user can select "Other" to enter a custom port number
- **If no ports are discovered:** Prompt user how to start an nREPL server:
- **question:** "No nREPL servers found. How would you like to start one?"
- **header:** "Start nREPL"
- **options:**
- **label:** "deps.edn alias", **description:** "Find and use an nREPL alias in deps.edn"
- **label:** "Leiningen", **description:** "Start nREPL using 'lein repl'"
- The user can select "Other" for alternative methods or if they already have a server running on a specific port
IMPORTANT: IF you start a REPL do not supply a port let the nREPL start and return the port that it was started on.
### 1. Evaluate Clojure Code
> Evaluation automatically connects to the given port
Use the `-p` flag to specify the port and pass your Clojure code.
**Recommended: Pass code as a command-line argument:**
```bash
clj-nrepl-eval -p <PORT> "(+ 1 2 3)"
```
**For multiple expressions (single line):**
```bash
clj-nrepl-eval -p <PORT> "(def x 10) (+ x 20)"
```
**Alternative: Using heredoc (may require permission approval for multiline commands):**
```bash
clj-nrepl-eval -p <PORT> <<'EOF'
(def x 10)
(+ x 20)
EOF
```
**Alternative: Via stdin pipe:**
```bash
echo "(+ 1 2 3)" | clj-nrepl-eval -p <PORT>
```
### 2. Display nREPL Sessions
**Discover all nREPL servers in current directory:**
```bash
clj-nrepl-eval --discover-ports
```
Shows all running nREPL servers in the current project directory, including their type (clj/bb/basilisp) and whether they match the current working directory.
**Check previously connected sessions:**
```bash
clj-nrepl-eval --connected-ports
```
Shows only connections you have made before (appears after first evaluation on a port).
### 3. Common Patterns
**Require a namespace (always use :reload to pick up changes):**
```bash
clj-nrepl-eval -p <PORT> "(require '[my.namespace :as ns] :reload)"
```
**Test a function after requiring:**
```bash
clj-nrepl-eval -p <PORT> "(ns/my-function arg1 arg2)"
```
**Check if a file compiles:**
```bash
clj-nrepl-eval -p <PORT> "(require 'my.namespace :reload)"
```
**Multiple expressions:**
```bash
clj-nrepl-eval -p <PORT> "(def x 10) (* x 2) (+ x 5)"
```
**Complex multiline code (using heredoc):**
```bash
clj-nrepl-eval -p <PORT> <<'EOF'
(def x 10)
(* x 2)
(+ x 5)
EOF
```
*Note: Heredoc syntax may require permission approval.*
**With custom timeout (in milliseconds):**
```bash
clj-nrepl-eval -p <PORT> --timeout 5000 "(long-running-fn)"
```
**Reset the session (clears all state):**
```bash
clj-nrepl-eval -p <PORT> --reset-session
clj-nrepl-eval -p <PORT> --reset-session "(def x 1)"
```
## Available Options
- `-p, --port PORT` - nREPL port (required)
- `-H, --host HOST` - nREPL host (default: 127.0.0.1)
- `-t, --timeout MILLISECONDS` - Timeout (default: 120000 = 2 minutes)
- `-r, --reset-session` - Reset the persistent nREPL session
- `-c, --connected-ports` - List previously connected nREPL sessions
- `-d, --discover-ports` - Discover nREPL servers in current directory
- `-h, --help` - Show help message
## Important Notes
- **Prefer command-line arguments:** Pass code as quoted strings: `clj-nrepl-eval -p <PORT> "(+ 1 2 3)"` - works with existing permissions
- **Heredoc for complex code:** Use heredoc (`<<'EOF' ... EOF`) for truly multiline code, but note it may require permission approval
- **Sessions persist:** State (vars, namespaces, loaded libraries) persists across invocations until the nREPL server restarts or `--reset-session` is used
- **Automatic delimiter repair:** The tool automatically repairs missing or mismatched parentheses
- **Always use :reload:** When requiring namespaces, use `:reload` to pick up recent changes
- **Default timeout:** 2 minutes (120000ms) - increase for long-running operations
- **Input precedence:** Command-line arguments take precedence over stdin
## Typical Workflow
1. Discover nREPL servers: `clj-nrepl-eval --discover-ports`
2. Use **AskUserQuestion** tool to prompt user to select a port
3. Require namespace:
```bash
clj-nrepl-eval -p <PORT> "(require '[my.ns :as ns] :reload)"
```
4. Test function:
```bash
clj-nrepl-eval -p <PORT> "(ns/my-fn ...)"
```
5. Iterate: Make changes, re-require with `:reload`, test again

View File

@@ -152,6 +152,7 @@
metabase.api.docs
metabase.api.init
metabase.api.macros
metabase.api.macros.defendpoint.tools-manifest
metabase.api.macros.scope
metabase.api.open-api
metabase.api.response
@@ -813,7 +814,6 @@
lib
lib-be
models
premium-features
request
settings
sql-tools
@@ -2305,15 +2305,26 @@
settings
util}}
enterprise/mcp
{:team "Metabot"
:api #{metabase-enterprise.mcp.api}
:uses #{api
config
request
server
util
enterprise/agent-api}}
enterprise/metabot-v3
{:team "Metabot"
:api #{metabase-enterprise.metabot-v3.api
metabase-enterprise.metabot-v3.client
metabase-enterprise.metabot-v3.core
metabase-enterprise.metabot-v3.init
metabase-enterprise.metabot-v3.tools.api}
:friends #{enterprise/agent-api}
:friends #{enterprise/agent-api
enterprise/mcp}
:uses #{activity-feed
analytics
api
app-db
audit-app
@@ -2342,10 +2353,10 @@
task
transforms
transforms-base
users
util
warehouses
enterprise/dependencies
enterprise/llm
enterprise/transforms-python}}
enterprise/permission-debug
@@ -2463,8 +2474,7 @@
task
tracing
util
enterprise/llm
enterprise/metabot-v3}}
enterprise/llm}}
enterprise/serialization
{:team "Querying"

View File

@@ -12,6 +12,7 @@ on:
branches:
- master
- internal-tools
- ai-service-port
paths-ignore:
# config files
- ".**"

View File

@@ -6,6 +6,7 @@ For detailed guidance on writing and reviewing code and documentation, see the s
## Clojure
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` shell commands.
- **[clojure-write](.claude/skills/clojure-write/SKILL.md)** - Clojure/ClojureScript development with REPL-driven workflow and coding conventions
- **[clojure-review](.claude/skills/clojure-review/SKILL.md)** - Clojure/ClojureScript code review guidelines and style enforcement

View File

@@ -105,6 +105,7 @@
methodical/methodical {:mvn/version "1.0.127"} ; drop-in replacements for Clojure multimethods and adds several advanced features
metosin/malli {:git/url "https://github.com/metabase/malli" ; Data-driven Schemas for Clojure/Script and babashka
:git/sha "b4a2d78647a049870b316753d7aae7fb4ade3ae5"} ; patched to resolve an issue causing OOM
net.carcdr/oidc-provider {:mvn/version "0.1.2"}
net.cgrand/macrovich {:mvn/version "0.2.2"} ; utils for writing macros for both Clojure & ClojureScript
net.clojars.wkok/openai-clojure {:mvn/version "0.23.0"
:exclusions [lambdaisland/uri]} ; OpenAI
@@ -151,6 +152,7 @@
org.clojure/core.match {:mvn/version "1.1.0"}
org.clojure/core.memoize {:mvn/version "1.1.266"} ; useful FIFO, LRU, etc. caching mechanisms
org.clojure/data.csv {:mvn/version "1.1.0"} ; CSV parsing / generation
org.clojure/data.xml {:mvn/version "0.2.0-alpha10"} ; XML parsing / generation
org.clojure/java.classpath {:mvn/version "1.1.0"} ; examine the Java classpath from Clojure programs
org.clojure/java.jdbc {:mvn/version "0.7.12"} ; basic JDBC access from Clojure
org.clojure/java.jmx {:mvn/version "1.1.0"} ; JMX bean library, for exporting diagnostic info
@@ -196,6 +198,7 @@
org.eclipse.jetty/jetty-unixdomain-server
org.eclipse.jetty.ee9/jetty-ee9-servlet
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server]}
selmer/selmer {:mvn/version "1.12.70"} ; Jinja-like templates for Clojure
slingshot/slingshot {:mvn/version "0.12.2"} ; enhanced throw/catch, used by other deps
software.amazon.awssdk/s3 {:mvn/version "2.40.13"} ; AWS SDK v2 for S3
software.amazon.awssdk/sts {:mvn/version "2.36.2"} ; Required for S3 client to use service account
@@ -770,7 +773,7 @@
io.github.clojure/tools.build {:mvn/version "0.10.11"}
io.github.seancorfield/build-uber-log4j2-handler
{:git/tag "v2.25.1" :git/sha "1cfab45"} ; collect binary files for log4j2
org.clojure/data.xml {:mvn/version "0.2.0-alpha9"}
org.clojure/data.xml {:mvn/version "0.2.0-alpha10"}
org.clojure/tools.deps.alpha {:mvn/version "0.15.1254"}
org.fedorahosted.tennera/jgettext {:mvn/version "0.15.1"}}

View File

@@ -51,6 +51,7 @@
"Put everything needed for REPL development within easy reach"
(:require
[clojure.core.async :as a]
[clojure.core.memoize :as memoize]
[clojure.main]
[clojure.string :as str]
[clojure.test]
@@ -467,3 +468,9 @@
(mt/initialize-if-needed! :test-users)
;; seed test db
(mt/id))
(defn reset-static!
"Reset static and template caches to pick up new js"
[]
((requiring-resolve 'stencil.loader/invalidate-cache))
(memoize/memo-clear! @(requiring-resolve 'metabase.server.routes.index/load-inline-js)))

View File

@@ -421,6 +421,8 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
});
it("should load the embedded Metabot component", () => {
cy.request("PUT", "/api/setting/use-native-agent", { value: true });
H.visitCustomHtmlPage(`
${H.getNewEmbedScriptTag()}
${H.getNewEmbedConfigurationScript()}
@@ -432,7 +434,7 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
cy.findByText("Ask questions to AI.").should("be.visible");
cy.findByPlaceholderText("Ask AI a question...").type("Foo{enter}");
cy.findByText(
"Metabot is currently offline. Please try again later.",
"Sorry, an error occurred: No OpenRouter API key is set. If this persists, please contact your administrator.",
).should("be.visible");
cy.log(

View File

@@ -206,11 +206,11 @@ d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
H.lastChatMessage().should("have.text", "You, but don't tell anyone.");
H.mockMetabotResponse({ statusCode: 500 });
H.mockMetabotResponse({ statusCode: 200, body: apiKeyInvalidResponse });
H.sendMetabotMessage("Who is your favorite?");
H.lastChatMessage().should(
"have.text",
"Metabot is currently offline. Please try again later.",
"Sorry, an error occurred: Anthropic API key expired or invalid. If this persists, please contact your administrator.",
);
});
@@ -274,3 +274,6 @@ describe("Metabot in full-app embedding", () => {
const whoIsYourFavoriteResponse = `0:"You, but don't tell anyone."
2:{"type":"state","version":1,"value":{"queries":{}}}
d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`;
const apiKeyInvalidResponse = `3:"Anthropic API key expired or invalid"
d:{"finishReason":"error","usage":{}}`;

View File

@@ -199,7 +199,9 @@
(api.macros/defendpoint :get "/v1/table/:id" :- ::table
"Get details for a table by ID."
{:scope "agent:table:read"}
{:scope "agent:table:read"
:tool {:name "get_table"
:description "Get details about a table including its fields, related tables, and metrics."}}
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
{:keys [with-fields with-field-values with-related-tables with-metrics with-measures with-segments]
:or {with-fields true, with-field-values false, with-related-tables true,
@@ -223,10 +225,13 @@
(api.macros/defendpoint :get "/v1/table/:id/field/:field-id/values" :- ::field-values
"Get statistics and sample values for a table field."
{:scope "agent:table:read"}
{:scope "agent:table:read"
:tool {:name "get_table_field_values"
:description "Get sample values and statistics for a field in a table."}}
[{:keys [id field-id]} :- [:map
[:id ms/PositiveInt]
[:field-id ms/NonBlankString]]]
[:field-id {:tool/description "Field identifier in the format '<prefix><entity-id>-<field-index>', e.g. 't123-0' for a table field."}
ms/NonBlankString]]]
(check-tool-result
(field-stats/field-values
{:entity-type "table"
@@ -236,7 +241,9 @@
(api.macros/defendpoint :get "/v1/metric/:id" :- ::metric
"Get details for a metric by ID."
{:scope "agent:metric:read"}
{:scope "agent:metric:read"
:tool {:name "get_metric"
:description "Get details about a metric including its queryable dimensions."}}
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
{:keys [with-default-temporal-breakout with-field-values with-queryable-dimensions with-segments]
:or {with-default-temporal-breakout true, with-field-values false,
@@ -256,10 +263,13 @@
(api.macros/defendpoint :get "/v1/metric/:id/field/:field-id/values" :- ::field-values
"Get statistics and sample values for a metric field."
{:scope "agent:metric:read"}
{:scope "agent:metric:read"
:tool {:name "get_metric_field_values"
:description "Get sample values and statistics for a field in a metric."}}
[{:keys [id field-id]} :- [:map
[:id ms/PositiveInt]
[:field-id ms/NonBlankString]]]
[:field-id {:tool/description "Field identifier in the format '<prefix><entity-id>-<field-index>', e.g. 'c456-2' for a metric field."}
ms/NonBlankString]]]
(check-tool-result
(field-stats/field-values
{:entity-type "metric"
@@ -272,7 +282,10 @@
Supports both term-based and semantic search queries. Results are ranked using
Reciprocal Rank Fusion when both query types are provided."
{:scope "agent:search"}
{:scope "agent:search"
:tool {:name "search"
:description "Search for tables and metrics in Metabase. Use term_queries for keyword search or semantic_queries for natural language search."
:annotations {:read-only? true}}}
[_route-params
_query-params
{term-queries :term_queries
@@ -363,7 +376,10 @@
For tables, supports: filters, fields, aggregations, group_by, order_by, limit.
For metrics, supports: filters, group_by (aggregation is defined by the metric)."
{:scope "agent:query:construct"}
{:scope "agent:query:construct"
:tool {:name "construct_query"
:description "Construct a query against a Metabase table or metric. Returns an opaque query string that can be executed with execute_query."
:annotations {:read-only? true :idempotent? true}}}
[_route-params
_query-params
body :- ::construct-query-request]
@@ -376,7 +392,8 @@
(mr/def ::execute-query-request
"Request schema for /v1/execute. Accepts a base64-encoded MBQL query."
[:map
[:query ms/NonBlankString]])
[:query {:tool/description "A base64-encoded query string returned by /v1/construct-query. Do not construct this value manually."}
ms/NonBlankString]])
(mr/def ::column-metadata
"Metadata for a single result column."
@@ -411,7 +428,9 @@
- On failure: {:status :failed :error \"message\" ...}
Standard userspace query limits are enforced (2000 rows for simple queries, 10000 for aggregated)."
{:scope "agent:query:execute"}
{:scope "agent:query:execute"
:tool {:name "execute_query"
:description "Execute a previously constructed query and return the results with column metadata, row count, and execution time."}}
[_route-params
_query-params
{encoded-query :query} :- ::execute-query-request]
@@ -507,7 +526,7 @@
(cond
;; Already authenticated via X-Metabase-Session (standard middleware handled it)
metabase-user-id
(handler (assoc request :token-scopes #{::scope/unrestricted}) respond raise)
(handler request respond raise)
;; Not authenticated via session - check for Bearer JWT
:else

View File

@@ -28,8 +28,10 @@
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.library.api]
[metabase-enterprise.llm.api]
[metabase-enterprise.mcp.api]
[metabase-enterprise.metabot-v3.api]
[metabase-enterprise.metabot-v3.tools.api]
[metabase-enterprise.oauth-server.admin-api]
[metabase-enterprise.permission-debug.api]
[metabase-enterprise.remote-sync.api]
[metabase-enterprise.replacement.api]
@@ -49,7 +51,8 @@
(comment metabase-enterprise.advanced-config.api.logs/keep-me
metabase-enterprise.llm.api/keep-me
metabase-enterprise.agent-api.api/keep-me)
metabase-enterprise.agent-api.api/keep-me
metabase-enterprise.mcp.api/keep-me)
(def ^:private required-feature->message
{:advanced-permissions (deferred-tru "Advanced Permissions")
@@ -87,13 +90,17 @@
(->> handler
(ee.api/+require-premium-feature required-feature (required-feature->message required-feature)))))
(def ^:private top-level-routes
"These are routes that intentionally do not follow the /ee/<feature>/ convention."
{"/agent" (premium-handler metabase-enterprise.agent-api.api/routes :agent-api)
"/mcp" (premium-handler metabase-enterprise.mcp.api/handler :agent-api)})
(def ^:private naughty-routes-map
"The following routes are NAUGHTY and do not follow the naming convention (i.e., they do not start with
`/ee/<feature>/`).
TODO -- Please fix them! See #22687"
{"/agent" (premium-handler metabase-enterprise.agent-api.api/routes :agent-api)
"/moderation-review" metabase-enterprise.content-verification.api.routes/routes
{"/moderation-review" metabase-enterprise.content-verification.api.routes/routes
"/mt" metabase-enterprise.sandbox.api.routes/sandbox-routes
"/table" metabase-enterprise.sandbox.api.routes/sandbox-table-routes})
@@ -131,6 +138,7 @@
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
"/oauth-server" (premium-handler metabase-enterprise.oauth-server.admin-api/routes :metabot-v3)
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
"/transforms-python" (premium-handler metabase-enterprise.transforms-python.api/routes :transforms-python)
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
@@ -146,6 +154,7 @@
(def ^:private routes-map
(merge
naughty-routes-map
top-level-routes
{"/ee" ee-routes-map}))
(def ^{:arglists '([request respond raise])} routes

View File

@@ -15,6 +15,7 @@
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]
[metabase-enterprise.metabot-v3.init]
[metabase-enterprise.oauth-server.init]
[metabase-enterprise.remote-sync.init]
[metabase-enterprise.scim.init]
[metabase-enterprise.semantic-search.init]

View File

@@ -1,7 +1,8 @@
(ns metabase-enterprise.llm.settings
(:require
[clojure.string :as str]
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru]]))
[metabase.util.i18n :refer [deferred-tru tru]]))
(defsetting ee-openai-model
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
@@ -21,20 +22,98 @@
(defsetting ee-openai-api-key
(deferred-tru "The OpenAI API Key used in Metabase Enterprise.")
:encryption :when-encryption-key-set
:sensitive? true
:visibility :settings-manager
:export? false
:doc false)
(defsetting ee-anthropic-api-base-url
(deferred-tru "The Anthropic API base URL used in Metabase Enterprise.")
:encryption :no
:visibility :settings-manager
:default "https://api.anthropic.com"
:export? false
:doc false)
(defsetting ee-anthropic-api-key
(deferred-tru "The Anthropic API Key used in Metabase Enterprise.")
:encryption :when-encryption-key-set
:sensitive? true
:visibility :settings-manager
:export? false
:doc false)
(defsetting ee-openrouter-api-base-url
(deferred-tru "The OpenRouter API base URL used for Chat Completions.")
:encryption :no
:visibility :settings-manager
:default "https://openrouter.ai/api"
:export? false
:doc false)
(defsetting ee-openrouter-api-key
(deferred-tru "The OpenRouter API Key used in Metabase Enterprise.")
:sensitive? true
:visibility :settings-manager
:export? false
:doc false)
(def ^:private supported-metabot-providers
"Set of supported LLM provider prefixes for the `ee-ai-metabot-provider` setting."
#{"anthropic" "openai" "openrouter"})
(defn- validate-metabot-provider!
"Validate that `value` has the format `provider/model` with a supported provider prefix.
Throws an exception with `:status-code 400` on invalid input."
[value]
(when-not (string? value)
(throw (ex-info (tru "Metabot provider must be a string, got: {0}" (pr-str value))
{:status-code 400})))
(let [[provider model] (str/split value #"/" 2)]
(when-not (contains? supported-metabot-providers provider)
(throw (ex-info (tru "Unknown provider {0}. Supported providers: {1}"
(pr-str provider) (str/join ", " (sort supported-metabot-providers)))
{:status-code 400
:provider provider
:supported supported-metabot-providers})))
(when (str/blank? model)
(throw (ex-info (tru "Model name is required. Expected format: provider/model, e.g. \"anthropic/claude-haiku-4-5\"")
{:status-code 400
:value value})))))
(defsetting ee-ai-metabot-provider
(deferred-tru "The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-4.1-mini`, `openrouter/anthropic/claude-haiku-4-5`.")
:type :string
:encryption :no
:default "openrouter/anthropic/claude-haiku-4-5"
:visibility :settings-manager
:export? false
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :ee-ai-metabot-provider new-value)))
(defsetting ee-ai-metabot-provider-lite
(deferred-tru "The AI provider and model for lightweight Metabot tasks (e.g. user intent classification).")
:type :string
:encryption :no
:default "openrouter/openai/gpt-oss-20b"
:visibility :settings-manager
:export? false
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :ee-ai-metabot-provider-lite new-value)))
(defsetting ee-ai-metabot-internal-tasks-enabled?
(deferred-tru "Controls whether Metabot performs internal tasks that might require background tasks or additional LLM calls (e.g. user intent classification).")
:type :boolean
:visibility :settings-manager
:default true
:export? false
:doc false)
(defsetting ee-ai-features-enabled
(deferred-tru "Enable AI features.")
:type :boolean

View File

@@ -0,0 +1,367 @@
(ns metabase-enterprise.mcp.api
"MCP (Model Context Protocol) Streamable HTTP transport handler.
Exposes Metabase's agent tools via JSON-RPC 2.0 over a single `/api/mcp` endpoint."
(:require
[clojure.core.async :as a]
[clojure.string :as str]
[compojure.response :as compojure.response]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.oauth-server.core :as oauth-server]
[metabase.api.common :as api]
[metabase.api.macros.scope :as scope]
[metabase.api.open-api :as open-api]
[metabase.request.core :as request]
[metabase.server.streaming-response :as streaming-response]
[metabase.system.core :as system]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[oidc-provider.protocol :as proto])
(:import
(java.io BufferedWriter OutputStreamWriter)
(java.net URI)
(java.nio.charset StandardCharsets)
(java.util UUID)))
(set! *warn-on-reflection* true)
;;; --------------------------------------------------- Sessions ----------------------------------------------------
(defonce ^:private sessions
(atom {}))
(def ^:private session-ttl-ms
"Sessions expire after 1 hour."
(* 60 60 1000))
(defn- sweep-expired-sessions!
"Remove all sessions whose TTL has elapsed. Called on session creation to
prevent abandoned sessions from accumulating in memory."
[]
(swap! sessions (fn [m]
(into {} (remove (fn [[_ {:keys [timer]}]]
(>= (u/since-ms timer) session-ttl-ms)))
m))))
(defn- create-session!
[user-id token-scopes]
(sweep-expired-sessions!)
(let [session-id (str (UUID/randomUUID))]
(swap! sessions assoc session-id {:timer (u/start-timer)
:initialized? false
:user-id user-id
:token-scopes token-scopes})
session-id))
(defn- delete-session! [session-id]
(swap! sessions dissoc session-id))
(defn- session-for-user
"Return session state when the session exists, is unexpired, and belongs to
`user-id`; otherwise return nil."
[session-id user-id]
(when-let [session (get @sessions session-id)]
(if (>= (u/since-ms (:timer session)) session-ttl-ms)
(do (delete-session! session-id) nil)
(when (= user-id (:user-id session))
session))))
(defn- session-initialized? [session-id]
(get-in @sessions [session-id :initialized?]))
(defn- mark-session-initialized! [session-id]
(swap! sessions assoc-in [session-id :initialized?] true))
(defn- session-token-scopes [session-id]
(get-in @sessions [session-id :token-scopes]))
(defn- session-user-id [session-id]
(get-in @sessions [session-id :user-id]))
;;; -------------------------------------------------- Auth --------------------------------------------------------
(defn- extract-bearer-token
"Extract the bearer token from the Authorization header."
[request]
(when-let [auth (get-in request [:headers "authorization"])]
(when (str/starts-with? (str/lower-case auth) "bearer ")
(str/trim (subs auth 7)))))
(defn- validate-bearer-token
"Look up and validate an OAuth bearer token. Returns `{:user-id <int> :scopes <set>}` on success, nil on failure."
[token-string]
(when-let [provider (oauth-server/get-provider)]
(when-let [token-data (proto/get-access-token (:token-store provider) token-string)]
(let [expiry (:expiry token-data)]
(when (or (nil? expiry)
(.isAfter (java.time.Instant/ofEpochSecond expiry) (java.time.Instant/now)))
(let [user-id (some-> (:user-id token-data) parse-long)
scopes (when-let [scope-vec (:scope token-data)]
(into #{} scope-vec))]
(when user-id
{:user-id user-id
:scopes (or scopes #{})})))))))
;;; ------------------------------------------------- JSON-RPC 2.0 --------------------------------------------------
(def ^:private server-info
{:name "metabase"
:version "0.1.0"})
(def ^:private protocol-version "2025-03-26")
(defn- jsonrpc-response [id result]
{:jsonrpc "2.0" :id id :result result})
(defn- jsonrpc-error [id code message]
{:jsonrpc "2.0" :id id :error {:code code :message message}})
(defn- handle-initialize [id params]
(when-let [client-info (:clientInfo params)]
(log/infof "MCP client connected: %s %s" (:name client-info) (:version client-info)))
(jsonrpc-response
id
{:protocolVersion protocol-version
:capabilities {:tools {}}
:serverInfo server-info}))
(defn- handle-tools-list [id _params token-scopes]
(jsonrpc-response id {:tools (mcp.tools/list-tools token-scopes)}))
(defn- handle-tools-call [id params token-scopes]
(let [tool-name (:name params)
arguments (or (:arguments params) {})]
(jsonrpc-response id (mcp.tools/call-tool token-scopes tool-name arguments))))
(defn- handle-ping [id _params]
(jsonrpc-response id {}))
(defmacro ^:private when-initialized
"Return a JSON-RPC error if the session is not yet initialized, otherwise evaluate `body`."
[id session-id & body]
`(if (session-initialized? ~session-id)
(do ~@body)
(jsonrpc-error ~id -32600 "Session not initialized: send notifications/initialized first")))
(defn- dispatch-request
"Dispatch a single JSON-RPC request. Returns a response map or nil for notifications."
[msg session-id token-scopes]
(let [id (:id msg)
method (:method msg)
params (:params msg)]
(try
(case method
"notifications/initialized" (do (mark-session-initialized! session-id) nil)
"tools/list" (when-initialized id session-id (handle-tools-list id params token-scopes))
"tools/call" (when-initialized id session-id (handle-tools-call id params token-scopes))
"ping" (when-initialized id session-id (handle-ping id params))
(if id
(jsonrpc-error id -32601 (str "Method not found: " method))
nil))
(catch Throwable e
(log/error e "Error dispatching JSON-RPC method" method)
(jsonrpc-error id -32603 (or (ex-message e) "Internal error"))))))
;;; ----------------------------------------------------- SSE ------------------------------------------------------
(defn- accepts-sse?
"Return true if the request's Accept header includes text/event-stream."
[request]
(some-> (get-in request [:headers "accept"])
(str/includes? "text/event-stream")))
(defn- sse-body
"Format a sequence of JSON-RPC messages as SSE event text."
[messages]
(str/join (map #(str "event: message\ndata: " (json/encode %) "\n\n") messages)))
;;; -------------------------------------------------- Responses ---------------------------------------------------
(defn- json-response
([status body]
(json-response status body nil))
([status body extra-headers]
{:status status
:headers (merge {"Content-Type" "application/json"} extra-headers)
:body (json/encode body)}))
(defn- sse-response
"Return a plain Ring response with SSE-formatted body for POST requests."
([messages]
(sse-response messages nil))
([messages extra-headers]
{:status 200
:headers (merge {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache"}
extra-headers)
:body (sse-body messages)}))
;;; ------------------------------------------------- Validation --------------------------------------------------
(defn- validate-origin
"Validate the Origin header to prevent DNS rebinding attacks (MCP spec requirement).
Returns a 403 response if Origin is present and doesn't match the request's Host header.
Non-browser clients that omit the Origin header are allowed through."
[request]
(when-let [origin (get-in request [:headers "origin"])]
(let [host (get-in request [:headers "host"])]
(when-not (try
(let [origin-host (.getHost (URI. origin))
request-host (first (str/split (str host) #":"))]
(= origin-host request-host))
(catch Exception _ false))
(json-response 403 (jsonrpc-error nil -32600 "Origin not allowed"))))))
(defn- session-error-response
"Return an HTTP error response when the session is invalid, or nil if valid.
Returns 400 for a missing session header, 404 for an invalid/expired session (MCP spec)."
[session-id user-id]
(cond
(str/blank? session-id)
(json-response 400 (jsonrpc-error nil -32600 "Missing Mcp-Session-Id header"))
(nil? (session-for-user session-id user-id))
(json-response 404 (jsonrpc-error nil -32600 "Session not found or expired"))))
;;; -------------------------------------------------- Handlers ---------------------------------------------------
(defn- handle-post
"Handle a POST request containing one or more JSON-RPC messages."
[user-id request]
(let [body (:body request)
session-id (get-in request [:headers "mcp-session-id"])
batch? (sequential? body)
session-err (delay (session-error-response session-id user-id))]
(cond
(nil? body)
(json-response 400 (jsonrpc-error nil -32700 "Parse error: empty body"))
(and (not (map? body)) (not batch?))
(json-response 400 (jsonrpc-error nil -32600 "Invalid request: expected object or array"))
;; JSON-RPC 2.0: empty batch is invalid
(and batch? (empty? body))
(json-response 400 (jsonrpc-error nil -32600 "Invalid request: empty batch"))
;; MCP spec: "The initialize request MUST NOT be part of a JSON-RPC batch"
(and batch? (some #(= "initialize" (:method %)) body))
(json-response 400 (jsonrpc-error nil -32600 "initialize must not be batched"))
;; Initialize: create session and return response with session header
(and (not batch?) (= "initialize" (:method body)))
(let [session-id (create-session! user-id (:token-scopes request))
init-response (handle-initialize (:id body) (:params body))]
(if (accepts-sse? request)
(sse-response [init-response] {"Mcp-Session-Id" session-id})
(json-response 200 init-response {"Mcp-Session-Id" session-id})))
;; All other requests require a valid session (400 for missing header, 404 for invalid)
(some? @session-err) @session-err
:else
(let [messages (if batch? body [body])
results (mapv #(dispatch-request % session-id (:token-scopes request)) messages)
responses (filterv some? results)]
(cond
(empty? responses)
{:status 202 :headers {} :body ""}
(accepts-sse? request)
(sse-response responses)
(and (not batch?) (= 1 (count responses)))
(json-response 200 (first responses))
:else
(json-response 200 responses))))))
(defn- handle-get
"Handle a GET request for SSE stream (keepalive for server-initiated notifications)."
[user-id request respond raise]
(let [session-id (get-in request [:headers "mcp-session-id"])
session-err (session-error-response session-id user-id)]
(cond
(some? session-err)
(respond session-err)
(not (session-initialized? session-id))
(respond (json-response 400 (jsonrpc-error nil -32600 "Session not initialized")))
:else
(let [resp (streaming-response/streaming-response
{:content-type "text/event-stream"
:headers {"Cache-Control" "no-cache"}
:status 200}
[os canceled-chan]
(let [writer (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8))]
(loop []
(when-not (a/poll! canceled-chan)
(.write writer ": keepalive\n\n")
(.flush writer)
(Thread/sleep 30000)
(recur)))))]
(compojure.response/send* resp request respond raise)))))
(defn- handle-delete
"Handle a DELETE request to tear down a session."
[user-id request]
(let [session-id (get-in request [:headers "mcp-session-id"])
session-err (session-error-response session-id user-id)]
(or session-err
(do (delete-session! session-id)
{:status 200 :headers {"Content-Type" "application/json"} :body ""}))))
;;; ---------------------------------------------------- Handler ---------------------------------------------------
(defn- www-authenticate-discovery []
(str "Bearer realm=\"mcp\" resource_metadata=\"" (system/site-url) "/.well-known/oauth-protected-resource/api/mcp\""))
(def ^{:arglists '([request respond raise])} handler
"Ring async handler for the MCP endpoint.
Uses JSON-RPC 2.0 over HTTP rather than REST, so the OpenAPI spec is empty."
(open-api/handler-with-open-api-spec
(fn [request respond raise]
(let [origin-error (validate-origin request)
bearer-token (extract-bearer-token request)
session-auth api/*current-user-id*
mcp-session-id (get-in request [:headers "mcp-session-id"])
;; For non-initialize requests with a valid MCP session, inherit stored auth
stored-user-id (when (and (not bearer-token) (not session-auth) mcp-session-id)
(session-user-id mcp-session-id))
stored-scopes (when stored-user-id
(session-token-scopes mcp-session-id))]
(letfn [(dispatch [user-id token-scopes]
(request/with-current-user user-id
(try
(let [request (assoc request :token-scopes token-scopes)]
(case (:request-method request)
:post (respond (handle-post user-id request))
:get (handle-get user-id request respond raise)
:delete (respond (handle-delete user-id request))
(respond (json-response 405 (jsonrpc-error nil -32600 "Method not allowed")))))
(catch Throwable e
(raise e)))))]
(cond
(some? origin-error)
(respond origin-error)
;; Session auth (browser/cookie) — unrestricted scopes
session-auth
(dispatch session-auth #{::scope/unrestricted})
;; Bearer token auth — validate and extract scopes
bearer-token
(if-let [{:keys [user-id scopes]} (validate-bearer-token bearer-token)]
(dispatch user-id scopes)
(respond (json-response 401 (jsonrpc-error nil -32603 "Invalid bearer token")
{"WWW-Authenticate" "Bearer error=\"invalid_token\""})))
;; Stored session auth (bearer token was sent on initialize)
stored-user-id
(dispatch stored-user-id (or stored-scopes #{}))
;; No auth at all — return 401 with discovery
:else
(respond (json-response 401 (jsonrpc-error nil -32603 "Authentication required")
{"WWW-Authenticate" (www-authenticate-discovery)}))))))
(constantly nil)))

View File

@@ -0,0 +1,182 @@
(ns metabase-enterprise.mcp.tools
"MCP tool dispatch. Generates tool definitions from defendpoint metadata and delegates
tool calls to existing agent API endpoints."
(:require
[clojure.core.async :as a]
[clojure.string :as str]
[metabase-enterprise.agent-api.api :as agent-api]
[metabase.api.common :as api]
[metabase.api.macros.defendpoint.tools-manifest :as tools-manifest]
[metabase.api.macros.scope :as scope]
[metabase.config.core :as config]
[metabase.util :as u]
[metabase.util.json :as json])
(:import
(java.io ByteArrayOutputStream)
(java.net URLEncoder)
(metabase.server.streaming_response StreamingResponse)))
(set! *warn-on-reflection* true)
(defn- generate-manifest []
(tools-manifest/generate-tools-manifest
{'metabase-enterprise.agent-api.api "/api/agent"}))
(def ^:private manifest-delay
(delay (generate-manifest)))
(defn- manifest
"Return the tools manifest. Cached in prod, recomputed each call in dev."
[]
(if config/is-dev?
(generate-manifest)
@manifest-delay))
(defn- tool-visible?
"Check if a tool should be visible given the current token scopes."
[token-scopes tool]
(or (nil? token-scopes)
(contains? token-scopes ::scope/unrestricted)
(nil? (:scope tool))
(scope/scope-satisfied? token-scopes (:scope tool))))
(defn list-tools
"Return the tool definitions suitable for MCP `tools/list` responses.
Filters tools based on `token-scopes` when provided."
[token-scopes]
(into []
(comp (filter (partial tool-visible? token-scopes))
(map (fn [tool]
{:name (:name tool)
:description (:description tool)
:inputSchema (:inputSchema tool)})))
(:tools (manifest))))
(defn- build-tool-index []
(into {} (map (juxt :name identity)) (:tools (manifest))))
(def ^:private tool-index-delay
(delay (build-tool-index)))
(defn- tool-index
"Lookup from tool name to its full manifest definition. Cached in prod, recomputed each call in dev."
[]
(if config/is-dev?
(build-tool-index)
@tool-index-delay))
;;; ------------------------------------------------- Tool Dispatch -------------------------------------------------
(defn- text-content
"Wrap a value as MCP text content."
[v]
{:content [{:type "text" :text (if (string? v) v (json/encode v))}]})
(defn- error-content
"Wrap an error message as MCP error content."
[message]
{:content [{:type "text" :text message}] :isError true})
(defn- capture-streaming-response
"Execute a StreamingResponse in-process by writing to a ByteArrayOutputStream,
then parse the JSON output and return it as MCP text content.
NOTE: This intentionally bypasses the normal StreamingResponse lifecycle (thread pool,
gzip, donechan delivery) because the agent-api streaming functions only write JSON to
the output stream and don't depend on `streaming-response/*response*` or other
infrastructure bindings. BOT-1120 tracks streaming MCP responses without buffering
in memory, which will replace this approach."
[^StreamingResponse response]
(let [baos (ByteArrayOutputStream.)
canceled-chan (a/promise-chan)
f (.f response)]
(f baos canceled-chan)
(text-content (json/decode+kw (.toString baos "UTF-8")))))
(defn- invoke-agent-api
"Invoke an Agent API endpoint with a synthetic Ring request.
Returns MCP content (text-content on success, error-content on failure).
Propagates `token-scopes` from the original MCP request so that scope restrictions
are preserved through the synthetic request."
[method path token-scopes & [body]]
(let [result (promise)]
(agent-api/routes
(cond-> {:request-method method
:uri path
:metabase-user-id api/*current-user-id*
:token-scopes token-scopes}
body (assoc :body body))
(fn [response] (deliver result response))
(fn [error] (deliver result {:status 500 :body {:message (ex-message error)}})))
(let [response (deref result 30000 {:status 504 :body {:message "Timeout"}})]
(cond
(instance? StreamingResponse (:body response))
(capture-streaming-response (:body response))
(= 200 (:status response))
(text-content (:body response))
:else
(error-content (or (some-> response :body :message)
(some-> response :body :error)
(str "Agent API error: " (:status response))))))))
(defn- interpolate-path
"Replace `{param}` placeholders in `path` with values from `arguments`.
Returns `[resolved-path remaining-args]` where remaining-args has path params removed."
[path arguments]
(let [params (re-seq #"\{([^}]+)\}" path)
resolved (reduce (fn [p [placeholder k]]
(let [v (get arguments (keyword k))]
(when (nil? v)
(throw (ex-info (str "Missing required path parameter: " k)
{:parameter k :path path})))
(str/replace p placeholder (URLEncoder/encode (str v) "UTF-8"))))
path
params)
path-keys (set (map (comp keyword second) params))]
[resolved (apply dissoc arguments path-keys)]))
(defn- strip-api-prefix
"Strip the `/api/agent` prefix from manifest paths, since `invoke-agent-api`
calls `agent-api/routes` directly which expects `/v1/...` paths."
[path]
(str/replace-first path #"^/api/agent" ""))
(defn- invoke-agent-api-with-params
"Invoke an Agent API endpoint with query parameters for GET/DELETE requests.
Appends `params` as a query string to `path`."
[method path token-scopes params]
(if (and (seq params) (not= :post method))
(let [query-string (->> params
(map (fn [[k v]] (str (name k) "=" (URLEncoder/encode (str v) "UTF-8"))))
(str/join "&"))]
(invoke-agent-api method (str path "?" query-string) token-scopes))
(invoke-agent-api method path token-scopes (when (= :post method) params))))
(defn- dispatch-via-agent-api
"Generic dispatch for tools whose responseFormat is \"json\".
Looks up method/path from the tool definition, interpolates path params,
and calls `invoke-agent-api`. For POST requests, remaining args are sent as the
request body. For GET/DELETE requests, remaining args are sent as query params."
[tool-def arguments token-scopes]
(let [{:keys [method path]} (:endpoint tool-def)
method (keyword (u/lower-case-en method))
[resolved-path
remaining-args] (interpolate-path path arguments)
api-path (strip-api-prefix resolved-path)]
(invoke-agent-api-with-params method api-path token-scopes remaining-args)))
(defn call-tool
"Dispatch an MCP `tools/call` request to the appropriate handler.
`token-scopes` from the original MCP session are propagated to the synthetic
agent-api request so that scope restrictions are preserved.
Returns MCP content on success, or error content on failure."
[token-scopes tool-name arguments]
(if-let [tool-def (get (tool-index) tool-name)]
(try
(dispatch-via-agent-api tool-def arguments token-scopes)
(catch Exception e
(error-content (or (ex-message e) "Internal error"))))
(error-content (str "Unknown tool: " tool-name))))

View File

@@ -0,0 +1,130 @@
(ns metabase-enterprise.metabot-v3.agent.analytics
"Snowplow and Prometheus analytics and intent-classification helpers for the metabot agent."
(:require
[clojure.string :as str]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.analytics.core :as analytics]
[metabase.api.common :as api]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; User intent classification
(def ^:private user-intents
{"create_or_modify_sql_query"
{:description "User wants to write new SQL from scratch or edit existing SQL"
:examples ["Write a SQL query to find all orders from last month."
"Edit the query to include customer email addresses (while looking at a SQL query)"]}
"fix_broken_sql_query"
{:description "User has broken SQL that needs fixing"
:examples ["My SQL query is giving an error, can you fix it?"
"Fix this query."]}
"query_data"
{:description "User wants to query data to get insights."
:examples ["Show me sales by region for Q1."
"How many users signed up last week?"]}
"data_discovery"
{:description "User looking for relevant tables/data or existing Metabase dashboards/questions"
:examples ["Which table has customer information?"
"What data can I use to analyze user behavior?"]}
"editing_visualizations"
{:description "User wants to build or edit visualizations"
:examples ["Create a bar chart for revenue by product."
"Turn this into a bar chart."]}
"interpret_results"
{:description "User needs help understanding data/charts"
:examples ["What does this chart mean?"
"Explain the results of this query."]}
"create_dashboard"
{:description "User wants to build dashboard"
:examples ["Build a dashboard for sales KPIs."
"Create a dashboard with multiple visualizations."]}
"navigation"
{:description "User wants to navigate to a specific Metabase UI location or feature"
:examples ["Take me to the SQL editor."
"Show me the admin settings."]}
"metabase_help"
{:description "User needs help using Metabase"
:examples ["How do I share a dashboard?"
"Help me set up a new database connection."
"How do I use custom expressions?"]}
"other"
{:description "User request doesn't fit standard categories"
:examples ["Any other request not covered above."]}})
(defn- render-user-intents-table
"Render user-intents as a plain-text table."
[]
(let [header "| Intent Category | Description | Examples |"
sep "|-----------------|-------------|----------|"
rows (map (fn [[intent {:keys [description examples]}]]
(str "| " intent " | " description " | " (str/join "; " examples) " |"))
user-intents)]
(str/join "\n" (concat [header sep] rows))))
(defn- build-intent-prompt [conversation-context]
(str "You are a user intent classification engine for requests to an AI agent operating on top of Metabase.\n"
"Given a conversation, classify the user's intent into one of the following categories:\n\n"
(render-user-intents-table)
"\n\nThis is the conversation context:\n"
"<conversation_context>\n"
conversation-context
"\n</conversation_context>\n\n"
"## Output format\n"
"Return the category wrapped in a <category> tag so that we can easily extract it from the string.\n"
"Only respond with the <category> tag including the intent category name - nothing else.\n\n"
"**Example:**\n"
"User: \"I want to see sales by region for Q1.\"\n"
"You: \"<category>query_data</category>\"\n\n"
"Classify the user's intent into one of the Intent Categories above.\n"))
(defn- classify-user-intent!
"Classify the user's message into one of the [[user-intent]] categories.
Returns the intent string.
Raises an exception if the classified intent is not in the known intents.
Uses plain-text extraction (no tool calls) so it works with models that don't support function calling."
[messages]
(let [context (json/encode (take-last 3 messages))
text (transduce (keep (fn [{:keys [type text]}] (when (= type :text) text)))
str
(self/call-llm (llm/ee-ai-metabot-provider-lite)
nil
[{:role "user" :content (build-intent-prompt context)}]
[]
;; Omit :request-id from tracking-opts to skip snowplow token_usage event
;; since this is for internal usage.
{:tag "user-intent-classification"}))
intent (second (re-find #"<category>([^<]+)</category>" text))]
(when-not (contains? user-intents intent)
(throw (ex-info (str "Classified intent '" intent "' not in known intents")
{:intent intent
:valid-intents (keys user-intents)})))
intent))
(defn- track-user-intent!
"Fire user_intent :snowplow/ai_service_event synchronously."
[intent tracking-opts]
(analytics/track-event!
:snowplow/ai_service_event
{:hashed-metabase-license-token (analytics/hashed-metabase-token-or-uuid)
:request-id (analytics/uuid->ai-service-hex-uuid (:request-id tracking-opts))
:source (:source tracking-opts)
:event "user_intent"
:user-id api/*current-user-id*
:session-id (:session-id tracking-opts)
:profile (some-> (:profile-id tracking-opts) name)
:event-details {"intent" intent}}))
(defn classify-and-track-user-intent-async!
"Classify and [[track-user-intent!]] in a background future."
[messages tracking-opts]
(future
(try
(-> messages
classify-user-intent!
(track-user-intent! tracking-opts))
(catch Exception e
(log/warn e "Failed to track user intent")))))

View File

@@ -0,0 +1,476 @@
(ns metabase-enterprise.metabot-v3.agent.core
"Main agent loop implementation using reducible streaming infrastructure."
(:require
[clojure.string :as str]
[metabase-enterprise.llm.settings :as llm.settings]
[metabase-enterprise.metabot-v3.agent.analytics :as agent-analytics]
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.agent.messages :as messages]
[metabase-enterprise.metabot-v3.agent.profiles :as profiles]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.analytics.prometheus :as prometheus]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[metabase.util.o11y :refer [with-span]]))
(set! *warn-on-reflection* true)
;;; Debug logging
;;
;; When `*debug-log*` is bound to an atom, the agent loop records full LLM
;; request/response data for every iteration. This is invaluable for debugging
;; benchmark failures — you can see exactly what the system prompt, message
;; history, and raw response were for each turn.
;;
;; Usage:
;; (binding [core/*debug-log* (atom [])]
;; (into [] (run-agent-loop opts))
;; @core/*debug-log*) ;; => [{:iteration 0 :request {...} :response [...]} ...]
;;
;; Or via the API with `"debug": true` in the request body, which emits the
;; debug log as a "debug_log" data part in the SSE stream.
(def ^:dynamic *debug-log*
"When bound to an atom, collects full LLM request/response data per iteration.
Each entry is a map with :iteration, :request, and :response keys.
The :request contains :model, :system, :messages (full history), and :tools.
The :response contains the collected parts from that iteration."
nil)
(defn- collect-text-from-parts
"Collect all text content from parts into a single string."
[parts]
(->> parts
(filter #(= :text (:type %)))
(map :text)
(str/join "")))
(defn- summarize-tool-ios
"Summarize tool inputs and outputs from parts."
[parts]
(let [inputs (filter #(= :tool-input (:type %)) parts)
outputs (filter #(= :tool-output (:type %)) parts)]
{:tool-calls (mapv (fn [p] {:id (:id p)
:function (:function p)
:arguments (:arguments p)})
inputs)
:tool-results (mapv (fn [p] {:id (:id p)
:output (let [r (:result p)]
(or (:output r) r))
:error (:error p)})
outputs)}))
(defn- debug-log!
"Append a debug entry if *debug-log* is bound."
[entry]
(when *debug-log*
(swap! *debug-log* conj entry)))
;;; Schemas
;;; Message schemas
;;
;; Input messages to the agent loop arrive in Chat Completions format (from the
;; API / frontend history). `messages.clj` converts them into AISDK parts.
;;
;; `:content` can be either a plain string or a sequential of content blocks
;; (backward-compat with Claude-format messages stored by the old Python AI
;; service).
(mr/def ::content-block
"A content block in a multi-part message (Claude format, backward-compat)."
[:map
[:type :string]]) ;; "text", "tool_use", "tool_result", etc.
(mr/def ::content
"Message content — string or sequential of content blocks."
[:or :string [:sequential ::content-block]])
(mr/def ::tool-call
"A tool call in an assistant message."
[:map
[:id :string]
[:name :string]
[:arguments [:or :string :map]]])
(mr/def ::user-message
"A user message: plain text or a sequence of tool_result content blocks."
[:map
[:role [:= :user]]
[:content ::content]])
(mr/def ::assistant-message
"An assistant message with optional text and/or tool calls."
[:map
[:role [:= :assistant]]
[:content {:optional true} [:maybe ::content]]
[:tool_calls {:optional true} [:maybe [:sequential ::tool-call]]]])
(mr/def ::system-message
"A system message."
[:map
[:role [:= :system]]
[:content :string]])
(mr/def ::tool-message
"A tool result message, referencing a previous tool call by ID."
[:map
[:role [:= :tool]]
[:tool_call_id :string]
[:content [:or :string :map]]])
(mr/def ::message
"A single message in the conversation history.
Discriminated by :role — each role has its own required/optional fields."
[:multi {:dispatch :role}
[:user ::user-message]
[:assistant ::assistant-message]
[:system ::system-message]
[:tool ::tool-message]])
(mr/def ::messages
"Sequence of messages in the conversation."
[:sequential ::message])
(mr/def ::state
"Agent state containing queries, charts, todos, and transforms."
[:map
[:queries {:optional true} [:map-of [:or :string :keyword] :map]]
[:charts {:optional true} [:map-of [:or :string :keyword] :map]]
[:todos {:optional true} [:sequential :map]]
[:transforms {:optional true} [:map-of [:or :string :keyword] :map]]])
(mr/def ::context
"Context information for the agent."
[:map-of :keyword :any])
(mr/def ::profile-id
"Profile identifier keyword."
[:enum :embedding_next :internal :transforms_codegen :sql :nlq :document-generate-content])
(mr/def ::tracking-opts
"Options for snowplow and prometheus analytics tracking."
[:map
[:session-id {:optional true} [:maybe ms/UUIDString]]
[:source {:optional true} [:maybe :string]]
[:tag {:optional true} [:maybe :string]]
[:track-user-intent? {:optional true} [:maybe :boolean]]])
;;; Iteration control
(defn- has-tool-calls?
"Check if any parts contain tool calls."
[parts]
(some #(= (:type %) :tool-input) parts))
(defn- has-final-response?
"Check if any tool result signals a final response."
[parts]
(some #(and (= (:type %) :tool-output)
(get-in % [:result :final-response?]))
parts))
(defn- should-continue?
"Determine if agent should continue iterating."
[iteration max-iterations parts]
(and (< iteration max-iterations)
(has-tool-calls? parts)
(not (has-final-response? parts))))
(defn- finish-reason
"Determine why the agent loop stopped."
[iteration max-iterations parts]
(cond
(>= iteration max-iterations) :max-iterations
(has-final-response? parts) :final-response
:else :stop))
;;; Call LLM
(defn- call-llm
"Call the LLM and stream processed parts.
Builds AISDK parts from memory and passes them to the adapter which converts
them to its native wire format."
[memory context profile tools iteration tracking-opts]
(let [model (:model profile)
system-msg (messages/build-system-message context profile tools)
input-parts (messages/build-message-history memory)
llm-opts (cond-> {}
(:required-tool-call? profile) (assoc :tool-choice "required"))]
(when *debug-log*
(debug-log! {:iteration iteration
:phase :request
:model model
:system (:content system-msg)
:parts input-parts
:tools (mapv (fn [[name _]] name) tools)}))
(eduction (streaming/post-process-xf (get-in memory [:state :queries] {})
(get-in memory [:state :charts] {}))
(self/call-llm model (:content system-msg) input-parts tools tracking-opts llm-opts))))
;;; Memory management
(defn- get-structured-output [result]
(or (:structured-output result) (:structured_output result)))
(defn- extract-queries
"Extract and store queries from tool outputs."
[memory parts]
(transduce
(comp (filter #(= :tool-output (:type %)))
(map #(get-structured-output (:result %)))
(filter #(and (:query-id %) (:query %))))
(completing
(fn [mem {:keys [query-id query]}]
(memory/remember-query mem query-id query)))
memory
parts))
(defn- extract-charts
"Extract and store charts from tool outputs."
[memory parts]
(transduce
(comp (filter #(= :tool-output (:type %)))
(map #(get-structured-output (:result %)))
(filter #(and (:chart-id %) (:query-id %))))
(completing
(fn [mem {:keys [chart-id] :as chart}]
(memory/store-chart mem chart-id chart)))
memory
parts))
(defn- update-memory
"Update memory with parts from an iteration."
[memory parts]
(-> memory
(memory/add-step parts)
(extract-queries parts)
(extract-charts parts)))
;;; Context seeding
(defn- normalize-type [t]
(cond (keyword? t) (name t)
(string? t) t
:else (some-> t str)))
(defn- ensure-context-item-id
"Ensure a viewing context item has an :id, generating a UUID if missing.
The Python ai-service auto-generates UUIDs for context items; we match that behavior."
[item]
(cond-> item
(not (:id item)) (assoc :id (str (random-uuid)))))
(defn- assign-context-ids
"Assign IDs to all viewing context items that lack one.
Must be called before both seed-state and enrich-context-for-template
so they share consistent IDs."
[context]
(update context :user_is_viewing #(mapv ensure-context-item-id %)))
(defn- extract-query-from-context-item
"Extract [query-id query] from viewing context item."
[{:keys [id type query source] :as _item}]
(let [t (normalize-type type)]
(cond
(and (#{"adhoc" "native"} t) (map? query) id)
[(str id) query]
(and (= "transform" t)
(= "query" (normalize-type (:type source)))
(map? (:query source))
id)
[(str id) (:query source)])))
(defn- seed-state
"Seed state with queries from viewing context."
[state context]
(reduce (fn [s item]
(if-let [[qid q] (extract-query-from-context-item item)]
(assoc-in s [:queries qid] q)
s))
state
(:user_is_viewing context)))
;;; Main loop
(defn- init-agent
"Initialize agent state."
[{:keys [messages state profile-id context tracking-opts]}]
(let [context (assign-context-ids context)
profile (or (profiles/get-profile profile-id)
(throw (ex-info "Unknown profile" {:profile-id profile-id})))
capabilities (get context :capabilities #{})
base-tools (profiles/get-tools-for-profile profile-id capabilities)
seeded (seed-state (or state {}) context)
memory (-> (memory/initialize messages seeded context)
(memory/load-queries-from-state seeded)
(memory/load-charts-from-state seeded)
(memory/load-transforms-from-state seeded)
(memory/load-todos-from-state seeded))
memory-atom (atom memory)
tools (agent-tools/wrap-tools-with-state base-tools memory-atom)]
(log/info "Starting agent" {:profile profile-id
:tools (count tools)
:max-iter (:max-iterations profile)
:msgs (count messages)})
{:profile profile
:tools tools
:context context
:memory-atom memory-atom
:tracking-opts (merge {:profile-id profile-id
:request-id (str (random-uuid))
:source "metabot_agent"
:tag "agent"}
tracking-opts)}))
(defn- initial-loop-state
"Create initial loop state from agent config and reduction context."
[agent rf init usage-atom]
{:agent agent
:rf rf
:result init
:iteration 1
:status :continue
:usage-atom usage-atom})
(defn- final-state-part [memory]
{:type :data, :data-type "state", :version 1, :data (memory/get-state memory)})
(defn- error-part [^Exception e]
{:type :error, :error {:message (.getMessage e), :type (str (type e)), :data (ex-data e)}})
(defn- accumulate-usage-xf
"Transducer that merges each `:usage` part into the cumulative usage atom
(keyed by model) and replaces the part's `:usage` with the running total.
Non-usage parts pass through unchanged."
[usage-atom]
(map (fn [{:keys [usage model] :as part}]
(if (= (:type part) :usage)
(let [model (or model "unknown")]
(assoc part :usage
(-> (swap! usage-atom update model (partial merge-with +) usage)
(get model))))
part))))
(defn- loop-step
"Execute one iteration of the agent loop. Returns next loop state.
Streams parts to the consumer as they arrive while simultaneously accumulating
them for memory updates and control flow decisions."
[{:keys [agent rf result iteration usage-atom] :as loop-state}]
(with-span :debug {:name :metabot-v3.agent/loop-step
:iteration iteration}
(let [{:keys [profile tools context memory-atom tracking-opts]} agent
max-iter (:max-iterations profile 10)
tracking-opts (assoc tracking-opts :iteration iteration)
parts-atom (atom [])
llm-call (call-llm @memory-atom context profile tools iteration tracking-opts)
xf (comp (accumulate-usage-xf usage-atom)
(u/tee-xf parts-atom))
;; We use `reduce` instead of `transduce` because rf is the outer reducing
;; function (e.g. aisdk-line-xf wrapping streaming-writer-rf) whose completion
;; arity emits a finish message — that must only fire once, at the end of the
;; entire agent loop, not after every iteration.
result' (reduce (xf rf) result llm-call)
parts @parts-atom]
;; Capture response for debug log
(when *debug-log*
(debug-log! {:iteration iteration
:phase :response
:text (collect-text-from-parts parts)
:tools (summarize-tool-ios parts)
:all-parts parts}))
(log/debug "Iteration" {:n iteration :parts-count (count parts)})
(if (empty? parts)
(assoc loop-state :status :done :result (rf result (final-state-part @memory-atom)))
(do
(log/debug "Got parts" {:count (count parts) :types (mapv :type parts)})
(swap! memory-atom update-memory parts)
(cond
(reduced? result')
(assoc loop-state :status :reduced :result @result')
(should-continue? iteration max-iter parts)
(assoc loop-state :result result' :iteration (inc iteration))
:else
(do (log/info "Agent loop complete"
{:iterations iteration
;; TODO: decide if we want this reason to float up to frontend
:reason (finish-reason iteration max-iter parts)})
(assoc loop-state
:status :done
:result (rf result' (final-state-part @memory-atom))))))))))
;;; Public API
(defn- debug-log-part
"Create a data part containing the complete debug log.
Emitted at the end of the stream when debug mode is active."
[debug-log]
{:type :data
:data-type "debug_log"
:version 1
:data debug-log})
(mu/defn run-agent-loop
"Run agent loop, returning a reducible of parts.
When `:debug?` is true, binds `*debug-log*` and emits a `debug_log` data part
at the end of the stream with the complete LLM request/response data for every
iteration. This is useful for debugging benchmark failures.
Usage:
(into [] (run-agent-loop opts))
(transduce xf rf (run-agent-loop opts))
(into [] (run-agent-loop (assoc opts :debug? true))) ;; with debug log"
[opts :- [:map
[:messages ::messages]
[:profile-id ::profile-id]
[:state {:optional true} [:maybe ::state]]
[:context {:optional true} [:maybe ::context]]
[:tracking-opts {:optional true} [:maybe ::tracking-opts]]
[:debug? {:optional true} [:maybe :boolean]]]]
(let [profile-id (:profile-id opts)
debug? (:debug? opts)
labels {:profile-id (name profile-id)}
track-user-intent? (and (llm.settings/ee-ai-metabot-internal-tasks-enabled?)
(some-> opts :tracking-opts :track-user-intent?))]
(reify clojure.lang.IReduceInit
(reduce [_ rf init]
(with-span :info {:name :metabot-v3.agent/run-agent-loop
:profile-id profile-id
:msg-count (count (:messages opts))}
(prometheus/inc! :metabase-metabot/agent-requests labels)
(let [start-ms (u/start-timer)]
(binding [*debug-log* (when debug? (atom []))]
(try
(let [agent (init-agent opts)
_ (when track-user-intent?
(agent-analytics/classify-and-track-user-intent-async!
(:messages opts)
(:tracking-opts agent)))
{result :result
iteration :iteration} (->> (initial-loop-state agent rf init (atom {}))
(iterate loop-step)
(drop-while #(= :continue (:status %)))
first)]
(prometheus/observe! :metabase-metabot/agent-iterations labels iteration)
;; Emit debug log as a data part if debug mode was active
(if (and debug? (seq @*debug-log*))
(rf result (debug-log-part @*debug-log*))
result))
(catch Exception e
(prometheus/inc! :metabase-metabot/agent-errors labels)
(when-not (:api-error (ex-data e))
(log/error e "Agent loop error"))
(rf init (error-part e)))
(finally
(prometheus/observe! :metabase-metabot/agent-duration-ms labels (u/since-ms start-ms)))))))))))

View File

@@ -0,0 +1,187 @@
(ns metabase-enterprise.metabot-v3.agent.links
"Link resolution for metabase:// URIs in streaming text.
Converts internal metabase:// links to proper Metabase URLs using agent memory state."
(:require
[buddy.core.codecs :as codecs]
[clojure.string :as str]
[metabase.lib.core :as lib]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; Link Types
(def ^:private link-type-paths
"Map of metabase:// link types to their URL paths."
{"model" "/model"
"metric" "/metric"
"dashboard" "/dashboard"
"question" "/question"
"transform" "/admin/transforms"})
;;; Query/Chart URL Generation
(defn- query->url-hash
"Convert an MBQL query to a base64-encoded URL hash for /question# URLs."
[query]
;; Frontend /question# URLs require legacy MBQL format
#_{:clj-kondo/ignore [:discouraged-var]}
(let [dataset-query (if (and (map? query) (:lib/type query))
(lib/->legacy-MBQL query)
query)]
(-> {:dataset_query dataset-query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str)))
(defn- resolve-query-link
"Resolve a metabase://query/{id} link to a /question# URL."
[query-id queries-state]
(if-let [query (get queries-state query-id)]
(str "/question#" (query->url-hash query))
(do
(log/warn "Query not found for link resolution" {:query-id query-id
:available (keys queries-state)})
nil)))
(defn- resolve-chart-link
"Resolve a metabase://chart/{id} link to a proper URL.
Charts contain visualization settings and reference queries.
Falls back to treating the ID as a query ID if no chart is found
(handles LLM mistakes where it uses chart/ instead of query/)."
[chart-id charts-state queries-state]
(if-let [chart (get charts-state chart-id)]
;; Chart has a query-id that points to the actual query
(let [query-id (:query-id chart)]
(if-let [query (get queries-state query-id)]
(str "/question#" (query->url-hash query))
(do
(log/warn "Query not found for chart" {:chart-id chart-id
:query-id query-id})
nil)))
;; Chart not found - fall back to checking if it's actually a query ID
;; (LLM sometimes uses metabase://chart/ when it should use metabase://query/)
(if-let [query (get queries-state chart-id)]
(do
(log/debug "Treating chart link as query link" {:id chart-id})
(str "/question#" (query->url-hash query)))
(do
(log/warn "Chart not found for link resolution" {:chart-id chart-id
:available-charts (keys charts-state)
:available-queries (keys queries-state)})
nil))))
(defn- resolve-entity-link
"Resolve a metabase://{type}/{id} link to a proper URL."
[entity-type entity-id]
(if-let [path (get link-type-paths entity-type)]
(str path "/" entity-id)
(do
(log/warn "Unknown entity type for link" {:type entity-type :id entity-id})
nil)))
(defn- resolve-table-link
"Resolve a metabase://table/{id} link to a table page URL.
Note: This differs from ai-service (which links to /question#... by fetching database_id).
We avoid a DB lookup during streaming by linking directly to the table."
[table-id]
(let [parsed-id (cond
(int? table-id) table-id
(string? table-id) (when (re-matches #"\d+" table-id)
(parse-long table-id))
:else nil)]
(if-not parsed-id
(do
(log/warn "Invalid table id for link resolution" {:table-id table-id})
nil)
(str "/table/" parsed-id))))
;;; Main Link Resolution
(defn resolve-metabase-uri
"Resolve a metabase:// URI to a proper Metabase URL.
Supported URI formats:
- metabase://query/{uuid} - Links to query results
- metabase://chart/{uuid} - Links to chart visualizations
- metabase://question/{uuid} - Links to saved questions
- metabase://model/{id} - Links to models
- metabase://metric/{id} - Links to metrics
- metabase://dashboard/{id} - Links to dashboards
- metabase://table/{id} - Links to tables (as questions)
- metabase://transform/{id} - Links to transforms
Returns the resolved URL or nil if resolution fails."
[uri queries-state charts-state]
(when (and uri (str/starts-with? uri "metabase://"))
(let [path (subs uri 11) ; Remove "metabase://"
[entity-type entity-id] (str/split path #"/" 2)]
(when-not (or (str/blank? entity-type) (str/blank? entity-id))
(case entity-type
"query" (resolve-query-link entity-id queries-state)
"chart" (resolve-chart-link entity-id charts-state queries-state)
"question" (resolve-entity-link "question" entity-id)
"table" (resolve-table-link entity-id)
;; For other types, use simple path mapping
(resolve-entity-link entity-type entity-id))))))
;;; Markdown Link Processing
(def link-pattern
"Regex matching a complete markdown link [text](url)."
#"\[([^\[\]]*)\]\(([^()]*)\)")
(defn resolve-links
"Resolve all metabase:// links in text, replacing them with proper URLs.
Takes text containing markdown links like [Chart](metabase://chart/uuid)
and replaces metabase:// URLs with proper Metabase URLs.
Non-metabase:// links are preserved unchanged.
Returns the text with all resolvable links replaced."
[text queries-state charts-state]
(when (string? text)
(str/replace text link-pattern
(fn [[_ link-text url]]
(if (str/starts-with? url "metabase://")
(if-let [resolved (resolve-metabase-uri url queries-state charts-state)]
(str "[" link-text "](" resolved ")")
(do
(log/warn "Failed to resolve link URL" {:url url})
link-text))
(str "[" link-text "](" url ")"))))))
(defn resolve-links-xf
"Transducer that resolves metabase:// links in text parts.
Takes queries-state and charts-state for link resolution.
For :text parts, resolves metabase:// links to proper URLs.
For other part types, passes through unchanged."
[queries-state charts-state]
(map (fn [part]
(if (and (= (:type part) :text) (:text part))
(update part :text resolve-links queries-state charts-state)
part))))
;;; Part Processing (deprecated, use resolve-links-xf)
(defn process-part-links
"Process metabase:// links in a part's text content.
For :text parts, processes the text to resolve metabase:// links.
For other part types, returns unchanged.
Deprecated: Use resolve-links-xf transducer instead."
[part queries-state charts-state]
(if (and (= (:type part) :text) (:text part))
(update part :text resolve-links queries-state charts-state)
part))
(defn process-parts-links
"Process metabase:// links in all parts.
Returns parts with all text links resolved.
Deprecated: Use resolve-links-xf transducer instead."
[parts queries-state charts-state]
(into [] (resolve-links-xf queries-state charts-state) parts))

View File

@@ -0,0 +1,121 @@
(ns metabase-enterprise.metabot-v3.agent.markdown-link-buffer
"Streaming markdown link buffer for resolving metabase:// links.
Buffers markdown links during streaming to prevent incomplete markdown rendering
and resolves metabase:// protocol links to proper Metabase URLs.
Provides both a pure functional state machine and a transducer for stream processing."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.links :as links]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; State
(def initial-state
"Initial parser state."
{:buffer ""
:queries {}
:charts {}})
(defn with-context
"Update the queries and charts context in the state."
[state queries charts]
(assoc state :queries queries :charts charts))
;;; Buffering Logic
(defn- find-potential-link-start
"Find the index of a potential incomplete link start (unmatched '[').
Returns nil if no potential incomplete link exists."
[text]
(let [last-open (str/last-index-of text "[")]
(when last-open
;; Check if there's a complete link starting at or after this position
(let [suffix (subs text last-open)]
(when-not (re-find links/link-pattern suffix)
;; No complete link, but could be start of one if we have '[' without matching ']('...')'
;; or '[...](' without closing ')'
(when (or (re-find #"\[[^\]]*$" suffix) ; unclosed [
(re-find #"\[[^\]]*\]\([^)]*$" suffix)) ; unclosed (
last-open))))))
(defn step
"Process a chunk of text through the state machine.
Returns [new-state output-string]."
[{:keys [buffer] :as state} chunk]
(let [text (str buffer chunk)
;; First resolve any complete links
resolved (links/resolve-links text (:queries state) (:charts state))
;; Then check for incomplete link at the end
split-point (find-potential-link-start resolved)]
(if split-point
[(assoc state :buffer (subs resolved split-point))
(subs resolved 0 split-point)]
[(assoc state :buffer "")
resolved])))
(defn flush-state
"Flush remaining buffered content from state.
Returns the buffered content string."
[state]
(:buffer state ""))
;;; Transducer
(defn- get-structured-output
"Extract structured output from a tool result, handling key variations."
[result]
(or (:structured-output result)
(:structured_output result)))
(defn resolve-xf
"Stateful transducer that resolves metabase:// links in text parts.
This transducer:
1. Accumulates queries/charts from tool-output parts (via :structured-output)
2. Buffers markdown links to handle links split across text chunks
3. Resolves metabase:// URLs to proper Metabase paths
4. Flushes any remaining buffered content at stream end
Parameters:
- initial-queries: Initial map of query-id to query data
- initial-charts: Initial map of chart-id to chart data"
[initial-queries initial-charts]
(fn [rf]
(let [state (volatile! (with-context initial-state
(or initial-queries {})
(or initial-charts {})))
queries (volatile! (or initial-queries {}))
charts (volatile! (or initial-charts {}))]
(fn
([] (rf))
([result]
;; Flush any remaining buffered content
(let [flushed (flush-state @state)]
(if (seq flushed)
(rf (rf result {:type :text :text flushed}))
(rf result))))
([result part]
;; Accumulate state from tool outputs
(when (= (:type part) :tool-output)
(when-let [{:keys [query-id query chart-id]} (get-structured-output (:result part))]
(when (and query-id query)
(log/debug "Accumulating query in resolve-xf" {:query-id query-id})
(vswap! queries assoc query-id query))
(when (and chart-id query-id)
(log/debug "Accumulating chart in resolve-xf" {:chart-id chart-id})
(vswap! charts assoc chart-id (get-structured-output (:result part)))))
;; Update state with new context
(vswap! state with-context @queries @charts))
;; Process text parts through link buffer
(if (= (:type part) :text)
(let [[new-state processed-text] (step @state (:text part))]
(vreset! state new-state)
(if (seq processed-text)
(rf result (assoc part :text processed-text))
result))
(rf result part)))))))

View File

@@ -0,0 +1,172 @@
(ns metabase-enterprise.metabot-v3.agent.memory
"Memory and state management for the agent loop.
Tracks conversation history, tool execution steps, and conversation state."
(:require
[clojure.string :as str]))
(defn initialize
"Initialize memory from input messages and initial state.
State structure:
{:queries {query-id query-map}
:charts {chart-id chart-map}
:todos [...]
:transforms {...}}"
([messages state]
(initialize messages state nil))
([messages state context]
{:input-messages messages
:steps-taken []
:context context
:state (or state {:queries {} :charts {} :todos [] :transforms {}})}))
(defn add-step
"Add a completed agent step to memory.
A step includes the LLM response parts (text, tool calls, tool results)."
[memory parts]
(update memory :steps-taken conj {:parts parts}))
(defn get-state
"Extract conversation state from memory.
State includes queries, charts, todos, transforms, etc."
[memory]
(:state memory))
(defn update-state
"Update conversation state with new entities."
[memory state-updates]
(update memory :state merge state-updates))
(defn get-steps
"Get all steps taken by the agent so far."
[memory]
(:steps-taken memory))
(defn get-input-messages
"Get the original input messages."
[memory]
(:input-messages memory))
(defn iteration-count
"Get the number of iterations completed."
[memory]
(count (:steps-taken memory)))
;;; Query Management
(defn remember-query
"Store a query in memory state by its query-id.
Query should be an MBQL/MLv2 query map."
[memory query-id query]
(update-in memory [:state :queries] assoc query-id query))
(defn find-query
"Retrieve a query from memory state by its query-id.
Throws if query not found."
[memory query-id]
(let [queries (get-in memory [:state :queries] {})]
(if-let [query (get queries query-id)]
query
(throw (ex-info (str "Query with ID " query-id " not found in memory. "
"Available queries: [" (str/join ", " (keys queries)) "]. "
"Are you sure you were referring to a query ID?")
{:agent-error? true
:query-id query-id
:available-queries (keys queries)})))))
(defn get-queries
"Get all stored queries from memory state."
[memory]
(get-in memory [:state :queries] {}))
;;; Chart Management
(defn store-chart
"Store a chart configuration in memory state by its chart-id."
[memory chart-id chart]
(update-in memory [:state :charts] assoc chart-id chart))
(defn find-chart
"Retrieve a chart from memory state by its chart-id.
Throws if chart not found."
[memory chart-id]
(let [charts (get-in memory [:state :charts] {})]
(if-let [chart (get charts chart-id)]
chart
(throw (ex-info (str "Chart with ID " chart-id " not found in memory. "
"Available charts: [" (str/join ", " (keys charts)) "]")
{:agent-error? true
:chart-id chart-id
:available-charts (keys charts)})))))
(defn get-charts
"Get all stored charts from memory state."
[memory]
(get-in memory [:state :charts] {}))
;;; Transform Management
(defn remember-transform
"Store a transform in memory state by its ID."
[memory transform-id transform]
(update-in memory [:state :transforms] assoc (str transform-id) transform))
(defn find-transform
"Retrieve a transform from memory by its ID.
Throws if transform not found."
[memory transform-id]
(let [transforms (get-in memory [:state :transforms] {})]
(if-let [transform (get transforms (str transform-id))]
transform
(throw (ex-info (str "Transform with ID " transform-id " not found in memory. "
"Available transforms: [" (str/join ", " (keys transforms)) "]")
{:agent-error? true
:transform-id transform-id
:available-transforms (keys transforms)})))))
(defn get-transforms
"Get all stored transforms from memory state."
[memory]
(get-in memory [:state :transforms] {}))
;;; Todo Management
(defn set-todos
"Set the todo list in memory state.
Todos should be a vector of todo item maps."
[memory todos]
(assoc-in memory [:state :todos] (vec todos)))
(defn get-todos
"Get the current todo list from memory state."
[memory]
(get-in memory [:state :todos] []))
;;; State Loading
(defn load-queries-from-state
"Load queries from incoming state into memory."
[memory state]
(if-let [queries (:queries state)]
(reduce-kv remember-query memory queries)
memory))
(defn load-charts-from-state
"Load charts from incoming state into memory."
[memory state]
(if-let [charts (:charts state)]
(reduce-kv store-chart memory charts)
memory))
(defn load-transforms-from-state
"Load transforms from incoming state into memory."
[memory state]
(if-let [transforms (:transforms state)]
(reduce-kv remember-transform memory transforms)
memory))
(defn load-todos-from-state
"Load todos from incoming state into memory."
[memory state]
(if-let [todos (:todos state)]
(set-todos memory todos)
memory))

View File

@@ -0,0 +1,177 @@
(ns metabase-enterprise.metabot-v3.agent.messages
"Message formatting and history construction for the agent loop.
The agent stores conversation history as AISDK parts — the format-neutral
internal representation used throughout the agent loop. Each LLM adapter
is responsible for converting these parts into its own wire format (Claude
messages, Chat Completions messages, OpenAI Responses input items, etc.)."
(:require
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.agent.prompts :as prompts]
[metabase-enterprise.metabot-v3.agent.user-context :as user-context]
[metabase.util.json :as json]
[metabase.util.log :as log]))
;;; ──────────────────────────────────────────────────────────────────
;;; Input message → AISDK part normalisation
;;; ──────────────────────────────────────────────────────────────────
(defn- normalize-role
"Normalize role to keyword for comparison.
Handles both keyword roles (:user) and string roles (\"user\")."
[role]
(if (keyword? role)
role
(keyword role)))
(defn- decode-tool-arguments
"Decode tool call arguments from JSON when provided as a string.
Falls back to the raw string if decoding fails."
[arguments]
(if (string? arguments)
(try
(json/decode+kw arguments)
(catch Exception e
(log/warn e "Failed to decode tool call arguments" {:arguments arguments})
arguments))
arguments))
(defn- tool-result-content
"Normalise tool result content to a string suitable for :tool-output."
[content]
(cond
(string? content) content
(map? content) (or (:output content) (pr-str content))
:else (str content)))
;; TODO (Sanya 2026-02-17): when we will use stored messages for context for AI agents we should be able to
;; simplify (or even drop) this significantly if we're storing in the same AISDK parts format.
(defn input-message->parts
"Convert an input message (from the frontend / API) into a sequence of AISDK parts.
Supported input shapes:
{:role :user, :content \"...\"}
{:role :assistant, :content \"...\"}
{:role :assistant, :content \"...\", :tool_calls [...]}
{:role :assistant, :content [{:type \"tool_use\" ...}]}
{:role :tool, :tool_call_id \"...\", :content \"...\"}
Returns a sequence of AISDK parts (may be >1 for messages with tool calls)."
[{:keys [content tool_calls] :as msg}]
(let [role (normalize-role (:role msg))]
(case role
:user
;; User messages that contain tool_result content blocks (pre-formatted
;; from conversation history) are expanded into :tool-output parts.
(if (and (sequential? content)
(every? #(= "tool_result" (:type %)) content))
(mapv (fn [{:keys [tool_use_id content]}]
{:type :tool-output
:id tool_use_id
:result {:output (tool-result-content content)}})
content)
[{:role :user :content (if (sequential? content)
;; Mixed content blocks: join text parts
(->> content
(filter #(= "text" (:type %)))
(map :text)
(apply str))
(or content ""))}])
:assistant
;; Assistant messages may have text, tool_use content blocks, or
;; OpenAI-style :tool_calls. Normalise all into AISDK parts.
(let [ ;; Extract text — either plain string or from content blocks
text (cond
(string? content) content
(sequential? content) (let [texts (->> content
(filter #(= "text" (:type %)))
(map :text))]
(when (seq texts) (apply str texts))))
;; Extract tool calls — from content blocks or :tool_calls key
tool-inputs (cond
(sequential? content)
(->> content
(filter #(= "tool_use" (:type %)))
(mapv (fn [{:keys [id name input]}]
{:type :tool-input
:id id
:function name
:arguments (decode-tool-arguments input)})))
(seq tool_calls)
(mapv (fn [{:keys [id name arguments]}]
{:type :tool-input
:id id
:function name
:arguments (decode-tool-arguments arguments)})
tool_calls))]
(cond-> []
text (conj {:type :text :text text})
(seq tool-inputs) (into tool-inputs)))
:tool
[{:type :tool-output
:id (:tool_call_id msg)
:result {:output (tool-result-content content)}}]
;; system — pass through as-is (not really an AISDK part, but adapters
;; handle it separately via the :system option anyway)
:system
[{:role :system :content content}]
;; Fallback
(do
(log/warn "Unknown message role, passing through" {:role role})
[{:role role :content content}]))))
;;; ──────────────────────────────────────────────────────────────────
;;; History construction
;;; ──────────────────────────────────────────────────────────────────
(defn- step->parts
"Extract AISDK parts from a step, filtering out non-message types."
[step]
(->> (:parts step)
(filter #(#{:text :tool-input :tool-output} (:type %)))))
(defn build-message-history
"Build the conversation history as a flat sequence of AISDK parts.
Returns a vector of items that are either:
- `{:role :user, :content \"...\"}` for user messages
- `{:type :text, :text \"...\"}` for assistant text
- `{:type :tool-input, :id ..., :function ..., :arguments ...}` for tool calls
- `{:type :tool-output, :id ..., :result ...}` for tool results
Each LLM adapter converts this to its own wire format."
[memory]
(let [input-messages (memory/get-input-messages memory)
steps (memory/get-steps memory)
input-parts (mapcat input-message->parts input-messages)
step-parts (mapcat step->parts steps)
result (vec (concat input-parts step-parts))]
(log/info "Building message history"
{:input-message-count (count input-messages)
:step-count (count steps)
:total-parts (count result)})
result))
;;; ──────────────────────────────────────────────────────────────────
;;; System message
;;; ──────────────────────────────────────────────────────────────────
(defn build-system-message
"Build system message with templated prompt and enriched context.
Parameters:
- context: Context map from API (with user_is_viewing, user_recently_viewed, etc.)
- profile: Profile map with :prompt-template key
- tools: Tool registry map (name -> var)
Returns message map with {:role \"system\" :content \"...\"}."
[context profile tools]
(let [enriched-context (user-context/enrich-context-for-template context)
content (prompts/build-system-message-content profile enriched-context tools)]
{:role "system"
:content content}))

View File

@@ -0,0 +1,196 @@
(ns metabase-enterprise.metabot-v3.agent.profiles
"Profile configurations for different metabot use cases.
Each profile defines model settings, iteration limits, and available tools."
(:require
[clojure.string :as str]
[malli.error :as me]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(def ^:private tool-definition-schema
[:map
[:tool-name [:and :string [:fn #(not (str/blank? %))]]]
[:schema [:fn some?]]
[:doc {:optional true} [:maybe :string]]
[:prompt {:optional true} [:maybe :string]]])
(def ^:private *profiles
"Map of profile-id to profile configuration"
(atom {}))
(defn- validate-tool-definition!
[tool-var]
(let [definition (select-keys (meta tool-var) [:tool-name :schema :doc :prompt])]
(when-not (mr/validate tool-definition-schema definition)
(throw (ex-info "Invalid tool definition metadata"
{:tool tool-var
:definition definition
:errors (me/humanize (mu/explain tool-definition-schema definition))})))))
(mu/defn ^:private register-profile!
"Register new profile configuration.
Each profile includes:
- :name - Keyword identifier for the profile (e.g. :internal)
- :prompt-template - Selmer template name from resources/metabot/prompts/system/
- :max-iterations - Maximum agent loop iterations
- :temperature - LLM temperature setting
- :tools - Vector of tool vars available to this profile
Note: `:model` is resolved at runtime from the `ee-ai-metabot-provider` setting
(see [[get-profile]]), not stored in the profile."
[profile :- [:map
[:name :keyword]
[:prompt-template :string]
[:max-iterations :int]
[:temperature :float]
[:tools [:vector :any]]]]
(when-not (apply distinct? (map #(:tool-name (meta %)) (:tools profile)))
(let [dups (->> (frequencies (map #(:tool-name (meta %)) (:tools profile)))
(filter (fn [[_ cnt]] (< 1 cnt))))]
(throw (ex-info "Duplicate tool names in profile" {:tool-names (map first dups)}))))
(doseq [tool (:tools profile)]
(validate-tool-definition! tool))
(swap! *profiles assoc (:name profile) profile))
(register-profile!
{:name :embedding_next
:prompt-template "embedding-next.selmer"
:max-iterations 10
:temperature 0.3
:tools [#'agent-tools/construct-notebook-query-tool
#'agent-tools/read-resource-tool
#'agent-tools/list-available-data-sources-tool]})
(register-profile!
{:name :internal
:prompt-template "internal.selmer"
:max-iterations 10
:temperature 0.3
:tools [#'agent-tools/search-tool
#'agent-tools/construct-notebook-query-tool
#'agent-tools/read-resource-tool
#'agent-tools/create-sql-query-tool
#'agent-tools/edit-sql-query-tool
#'agent-tools/replace-sql-query-tool
#'agent-tools/edit-chart-tool
#'agent-tools/navigate-user-tool
#'agent-tools/create-chart-tool
#'agent-tools/create-autogenerated-dashboard-tool
#'agent-tools/create-dashboard-subscription-tool]})
(register-profile!
{:name :transforms_codegen
:prompt-template "transform-codegen.selmer"
:max-iterations 30
:temperature 0.3
:tools [#'agent-tools/transform-search-tool
#'agent-tools/get-transform-details-tool
#'agent-tools/get-transform-python-library-details-tool
#'agent-tools/write-transform-sql-tool
#'agent-tools/write-transform-python-tool
#'agent-tools/list-snippets-tool
#'agent-tools/get-snippet-details-tool
#'agent-tools/list-available-fields-tool
#'agent-tools/get-field-values-tool
#'agent-tools/todo-write-tool
#'agent-tools/todo-read-tool]})
;; SQL responses are rendered from tool results in the native port, so this
;; profile must always end with a tool call rather than free-form assistant text.
(register-profile!
{:name :sql
:prompt-template "sql-querying-only.selmer"
:max-iterations 10
:temperature 0.3
:required-tool-call? true
:tools [#'agent-tools/sql-search-tool
#'agent-tools/read-resource-tool
#'agent-tools/create-sql-query-code-edit-tool
#'agent-tools/edit-sql-query-code-edit-tool
#'agent-tools/replace-sql-query-code-edit-tool
#'agent-tools/ask-for-sql-clarification-tool]})
(register-profile!
{:name :nlq
:prompt-template "natural-language-querying-only.selmer"
:max-iterations 10
:temperature 0.3
:tools [#'agent-tools/nlq-search-tool
#'agent-tools/read-resource-tool
#'agent-tools/construct-notebook-query-tool
#'agent-tools/navigate-user-tool
#'agent-tools/create-chart-tool
#'agent-tools/edit-chart-tool]})
(register-profile!
{:name :document-generate-content
:prompt-template "document-generate-content.selmer"
:max-iterations 10
:temperature 0.3
:required-tool-call? true
:tools [#'agent-tools/list-available-data-sources-tool
#'agent-tools/list-available-fields-tool
#'agent-tools/get-field-values-tool
#'agent-tools/document-schema-collect-tool
#'agent-tools/document-construct-model-chart-tool
#'agent-tools/document-construct-sql-chart-tool]})
(def ^:private api-string->capability-keyword
"Map from API capability strings (as sent by the frontend) to the keywords
used in tool :capabilities metadata. Keywords pass through unchanged."
{"frontend:navigate_user_v1" :frontend-navigate-user-v1
"permission:save_questions" :permission-save-questions
"permission:write_sql_queries" :permission-write-sql-queries
"permission:write_transforms" :permission-write-transforms
"feature:transforms" :feature-transforms
"feature:transforms-python" :feature-transforms-python
"feature:snippets" :feature-snippets
"automagic-dashboards" :automagic-dashboards})
(defn- capability->keyword
"Normalize a capability value to a keyword. Strings are looked up in the
api-string->capability-keyword map; keywords are returned as-is."
[cap]
(if (keyword? cap)
cap
(or (api-string->capability-keyword cap)
;; Fallback: keywordize unknown strings so new capabilities degrade
;; gracefully without requiring a map update.
(keyword (-> (str cap) (str/replace ":" "-") (str/replace "_" "-"))))))
(defn- filter-by-capabilities
"Filter tool vars by user capabilities.
Removes tools that require capabilities the user doesn't have.
Capabilities from the API arrive as strings (e.g. \"frontend:navigate_user_v1\")
while tool metadata uses keywords (e.g. :frontend-navigate-user-v1), so we
normalize to keywords before comparing."
[tool-vars capabilities]
(let [capabilities-set (into #{} (map capability->keyword) capabilities)]
(filter (fn [tool-var]
(every? capabilities-set (:capabilities (meta tool-var))))
tool-vars)))
(defn- tool-map
[tools]
(into {} (map (juxt #(:tool-name (meta %)) identity) tools)))
;;; API
(defn get-profile
"Get profile configuration by profile-id keyword.
The `:model` in the returned profile is resolved from the `ee-ai-metabot-provider`
setting at call time, so it always reflects the current admin configuration."
[profile-id]
(when-let [profile (get @*profiles profile-id)]
(assoc profile :model (llm/ee-ai-metabot-provider))))
(defn get-tools-for-profile
"Get tool registry filtered by profile configuration and user capabilities."
[profile-id capabilities]
(some-> (get-profile profile-id)
:tools
(filter-by-capabilities capabilities)
tool-map))

View File

@@ -0,0 +1,246 @@
(ns metabase-enterprise.metabot-v3.agent.prompts
"System for loading and rendering prompt templates using Selmer.
Handles:
- System prompt templates from resources/metabot/prompts/system/
- SQL dialect instructions from resources/metabot/prompts/dialects/
- Tool-specific prompts from resources/metabot/prompts/tools/
- Template rendering with context variables
- Template caching for performance"
(:require
[clojure.java.io :as io]
[metabase.util.log :as log]
[selmer.parser :as selmer]))
(set! *warn-on-reflection* true)
;;; Template Loading
(defn- load-resource
"Load a resource file as a string."
[resource-path]
(when-let [resource (io/resource resource-path)]
(slurp resource)))
(defn load-system-prompt-template
"Load a system prompt template from resources/metabot/prompts/system/.
Example: (load-system-prompt-template \"internal.selmer\")"
[template-name]
(let [path (str "metabot/prompts/system/" template-name)]
(or (load-resource path)
(do
(log/warn "System prompt template not found:" path)
nil))))
(defn load-dialect-instructions
"Load SQL dialect instructions from resources/metabot/prompts/dialects/.
Example: (load-dialect-instructions \"postgresql\")
Returns nil if dialect not found."
[dialect-name]
(when dialect-name
(let [path (str "metabot/prompts/dialects/" dialect-name ".md")]
(or (load-resource path)
(do
(log/debug "Dialect instructions not found:" path)
nil)))))
(defn load-tool-prompt-template
"Load a tool-specific prompt template from resources/metabot/prompts/tools/.
Example: (load-tool-prompt-template \"query-datasource/system.selmer\")"
[template-path]
(let [path (str "metabot/prompts/tools/" template-path)]
(or (load-resource path)
(do
(log/debug "Tool prompt template not found:" path)
nil))))
(defn load-llm-representations-template
"Load the LLM representations template from resources/metabot/prompts/."
[template-name]
(let [path (str "metabot/prompts/" template-name)]
(or (load-resource path)
(do
(log/warn "LLM representations template not found:" path)
nil))))
;;; Template Rendering
(defn render-system-prompt
"Render a system prompt template with context variables using Selmer.
Parameters:
- template: Template string (from load-system-prompt-template)
- context: Map of template variables
Common context variables:
- :current-user-info - Formatted current user info and glossary
- :current-time - User's current time string
- :first-day-of-week - Calendar week start (default \"Sunday\")
- :sql-dialect - SQL dialect name
- :sql-dialect-instructions - Dialect-specific guidance (markdown)
- :tool-instructions - Vector of tool instruction maps
- :viewing-context - Formatted user viewing context
- :recent-views - Formatted recent views"
[template context]
(try
(selmer/render template context)
(catch Exception e
(log/error e "Error rendering system prompt template")
;; Return template as-is on error
template)))
(defn render-tool-prompt
"Render a tool-specific prompt template with context variables.
Similar to render-system-prompt but for tool-level prompts."
[template context]
(try
(selmer/render template context)
(catch Exception e
(log/error e "Error rendering tool prompt template")
template)))
;;; Template Caching
(def ^:private template-cache
"In-memory cache for loaded templates.
Map of resource-path -> {:template string :loaded-at instant}"
(atom {}))
(defn- cache-key
"Generate cache key for a template."
[resource-type resource-name]
(str resource-type ":" resource-name))
(defn cached-load-template
"Load template with caching. Cache is simple in-memory map.
In development, you can clear the cache to reload templates:
(reset! metabot-v3.agent.prompts/template-cache {})"
[resource-type resource-name loader-fn]
(let [key (cache-key resource-type resource-name)]
(or (get @template-cache key)
(when-let [template (loader-fn resource-name)]
(let [entry {:template template
:loaded-at (java.time.Instant/now)}]
(swap! template-cache assoc key entry)
entry)))))
(defn get-cached-system-prompt
"Get system prompt template from cache or load it.
Returns template string or nil."
[template-name]
(:template (cached-load-template "system" template-name load-system-prompt-template)))
(defn get-cached-dialect-instructions
"Get dialect instructions from cache or load them.
Returns instructions string or nil."
[dialect-name]
(when dialect-name
(:template (cached-load-template "dialect" dialect-name load-dialect-instructions))))
(defn get-cached-tool-prompt
"Get tool prompt template from cache or load it.
Returns template string or nil."
[template-path]
(:template (cached-load-template "tool" template-path load-tool-prompt-template)))
(defn get-cached-llm-representations-template
"Get the LLM representations template from cache or load it."
[]
(:template (cached-load-template "llm-representations"
"llm_representations.selmer"
load-llm-representations-template)))
(defn clear-cache!
"Clear the template cache. Useful for development/testing."
[]
(reset! template-cache {}))
;;; Tool Instructions Extraction
(defn extract-tool-instructions
"Extract system instructions from tool definitions by loading prompt files.
For each tool, loads a markdown prompt file from `resources/metabot/prompts/tools/`.
The filename is determined by:
1. `:prompt` key in the tool's metadata (if present), or
2. `\"<tool-name>.md\"` as default.
Only tools with a corresponding prompt resource file are included.
Returns vector of maps: [{:tool_name \"search\" :instructions \"...\"}]"
[tools]
(vec
(for [[tool-name tool] tools
:let [fname (or (-> tool meta :prompt)
(str tool-name ".md"))
prompt (some-> (io/resource (str "metabot/prompts/tools/" fname))
slurp)]
:when prompt]
{:tool_name tool-name
:instructions prompt})))
;;; High-Level API
(defn build-system-message-content
"Build complete system message content from profile and context.
Parameters:
- profile: Profile map with :prompt-template key
- context: Agent context map with user info, viewing context, etc.
- tools: Tool registry map
Returns rendered system message string."
[{:keys [prompt-template]} context tools]
(let [template-name (or prompt-template "internal.selmer")
template (get-cached-system-prompt template-name)]
(if template
(let [sql-dialect (or (get context :sql_dialect)
(get context :sql-dialect))
dialect-instructions (when sql-dialect
(get-cached-dialect-instructions sql-dialect))
tool-instructions (extract-tool-instructions tools)
current-user-info (or (get context :current_user_info)
(get context :current-user-info))
current-time (or (get context :current_time)
(get context :current-time))
first-day-of-week (or (get context :first_day_of_week)
(get context :first-day-of-week "Sunday"))
viewing-context (or (get context :viewing_context)
(get context :viewing-context))
recent-views (or (get context :recent_views)
(get context :recent-views))
template-context {:current_time current-time
:current_user_info current-user-info
:first_day_of_week first-day-of-week
:sql_dialect sql-dialect
:sql_dialect_instructions dialect-instructions
:tool_instructions tool-instructions
:viewing_context viewing-context
:recent_views recent-views}]
(render-system-prompt template template-context))
;; Fallback if template not found
(do
(log/error "System prompt template not found:" template-name)
"You are Metabot, a data analysis assistant for Metabase."))))
(comment
;; Development examples
;; Load and render a template
(def template (load-system-prompt-template "internal.selmer"))
(render-system-prompt template {:current-time "2024-01-15 14:30:00"
:sql-dialect "postgresql"})
;; Load dialect instructions
(load-dialect-instructions "postgresql")
;; Clear cache during development
(clear-cache!)
;; Extract tool instructions
(extract-tool-instructions {"search" #'some-tool-var}))

View File

@@ -0,0 +1,147 @@
(ns metabase-enterprise.metabot-v3.agent.streaming
"Streaming helpers for the agent loop.
Provides utilities for creating AI-SDK data parts, including navigation,
and transducers for stream processing."
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.agent.markdown-link-buffer :as markdown-link-buffer]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
;;; AI-SDK Data Types
;;
;; These match the Python AI Service's AISDKDataTypes enum
(def navigate-to-type "AI-SDK data type for navigation links." "navigate_to")
(def state-type "AI-SDK data type for state updates." "state")
(def todo-list-type "AI-SDK data type for todo lists." "todo_list")
(def code-edit-type "AI-SDK data type for code edits." "code_edit")
(def transform-suggestion-type "AI-SDK data type for transform suggestions." "transform_suggestion")
;;; Query URL Encoding
(defn query->url-hash
"Convert an MLv2/MBQL query to a base64-encoded URL hash.
Used for /question# URLs."
[query]
(-> {:dataset_query query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str))
(defn query->question-url
"Convert a query to a /question# URL."
[query]
(str "/question#" (query->url-hash query)))
;;; Data Part Constructors
(defn navigate-to-part
"Create a NAVIGATE_TO data part for streaming.
The URL should be a path like '/question#...' or '/model/123'.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.NAVIGATE_TO, version=1, value=path)"
[url]
{:type :data
:data-type navigate-to-type
:data url})
(defn state-part
"Create a STATE data part for streaming."
[state-map]
{:type :data
:data-type state-type
:data state-map})
(defn todo-list-part
"Create a TODO_LIST data part for streaming.
Todos should be a vector of todo item maps with :id, :content, :status, :priority keys.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.TODO_LIST, version=1, value=todos)"
[todos]
{:type :data
:data-type todo-list-type
:version 1
:data todos})
(defn code-edit-part
"Create a CODE_EDIT data part for streaming.
Edit-data should be a map describing the code edit operation.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.CODE_EDIT, version=1, value=edit_data)"
[edit-data]
{:type :data
:data-type code-edit-type
:version 1
:data edit-data})
(defn transform-suggestion-part
"Create a TRANSFORM_SUGGESTION data part for streaming.
Suggestion should be a map containing the suggested transform definition.
This matches Python AI Service's:
ai_sdk.create_data_part(data_type=AISDKDataTypes.TRANSFORM_SUGGESTION, version=1, value=suggestion)"
[suggestion]
{:type :data
:data-type transform-suggestion-type
:version 1
:data suggestion})
;;; Reaction Conversion
(defn reactions->data-parts
"Convert tool reactions to AI-SDK data parts for streaming.
Reactions are metadata returned by tools that trigger side effects:
- :metabot.reaction/redirect -> navigate_to data part
Returns a vector of data parts (may be empty if no relevant reactions)."
[reactions]
(into []
(keep (fn [{:keys [type url]}]
(case type
:metabot.reaction/redirect (navigate-to-part url)
nil)))
reactions))
;;; Stream Processing Transducers
(def expand-reactions-xf
"Stateless transducer that expands :reactions from tool-output parts into data parts.
Passes through all parts unchanged, then appends any reaction data parts after tool-output parts."
(mapcat (fn [part]
(if (= (:type part) :tool-output)
(cons part (reactions->data-parts (get-in part [:result :reactions])))
[part]))))
(def expand-data-parts-xf
"Stateless transducer that expands :data-parts from tool-output results.
Passes through all parts unchanged, then appends any data parts after tool-output parts."
(mapcat (fn [part]
(if (= (:type part) :tool-output)
(cons part (get-in part [:result :data-parts]))
[part]))))
(defn post-process-xf
"Composed transducer for post-processing agent output.
Applies in order:
1. expand-data-parts-xf - Extract data-parts from tool outputs
2. expand-reactions-xf - Extract reactions from tool outputs as data parts
3. resolve-links-xf - Resolve metabase:// links in text parts
Note: expand-data-parts-xf comes first so its output appears before reactions,
matching the original stream-parts-to-output! behavior.
Parameters:
- initial-queries: Initial map of query-id to query data
- initial-charts: Initial map of chart-id to chart data"
[initial-queries initial-charts]
(comp
expand-data-parts-xf
expand-reactions-xf
(markdown-link-buffer/resolve-xf initial-queries initial-charts)))

View File

@@ -0,0 +1,106 @@
(ns metabase-enterprise.metabot-v3.agent.tools
"Tool registry for the agent loop.
Provides a unified namespace for tool vars and state-aware wrapping."
(:require
[metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard :as tools.autogen-dashboard]
[metabase-enterprise.metabot-v3.agent.tools.charts :as tools.charts]
[metabase-enterprise.metabot-v3.agent.tools.clarification :as tools.clarification]
[metabase-enterprise.metabot-v3.agent.tools.construct :as tools.construct]
[metabase-enterprise.metabot-v3.agent.tools.document :as tools.document]
[metabase-enterprise.metabot-v3.agent.tools.metadata :as tools.metadata]
[metabase-enterprise.metabot-v3.agent.tools.navigation :as tools.navigation]
[metabase-enterprise.metabot-v3.agent.tools.resources :as tools.resources]
[metabase-enterprise.metabot-v3.agent.tools.search :as tools.search]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.agent.tools.snippets :as tools.snippets]
[metabase-enterprise.metabot-v3.agent.tools.sql :as tools.sql]
[metabase-enterprise.metabot-v3.agent.tools.subscriptions :as tools.subscriptions]
[metabase-enterprise.metabot-v3.agent.tools.todo :as tools.todo]
[metabase-enterprise.metabot-v3.agent.tools.transforms :as tools.transforms]
[potemkin :as p]))
(set! *warn-on-reflection* true)
(p/import-vars
[tools.search search-tool
sql-search-tool
nlq-search-tool
transform-search-tool]
[tools.construct
construct-notebook-query-tool]
[tools.document
document-schema-collect-tool
document-construct-sql-chart-tool
document-construct-model-chart-tool]
[tools.metadata
list-available-data-sources-tool
list-available-fields-tool
get-field-values-tool]
[tools.transforms
get-transform-details-tool
get-transform-python-library-details-tool
write-transform-sql-tool
write-transform-python-tool]
[tools.sql
create-sql-query-tool
create-sql-query-code-edit-tool
edit-sql-query-tool
edit-sql-query-code-edit-tool
replace-sql-query-tool
replace-sql-query-code-edit-tool]
[tools.charts
create-chart-tool
edit-chart-tool]
[tools.resources
read-resource-tool]
[tools.todo
todo-write-tool
todo-read-tool]
[tools.navigation
navigate-user-tool]
[tools.snippets
list-snippets-tool
get-snippet-details-tool]
[tools.clarification
ask-for-sql-clarification-tool]
[tools.autogen-dashboard
create-autogenerated-dashboard-tool]
[tools.subscriptions
create-dashboard-subscription-tool])
(def ^:dynamic ^:private *memory-atom* nil)
(def ^:private state-dependent-tools
"Set of tool names that require access to agent state."
#{"create_chart" "edit_chart" "todo_write" "todo_read" "navigate_user"
"write_transform_sql" "write_transform_python" "create_autogenerated_dashboard"
"create_sql_query" "edit_sql_query" "replace_sql_query"
"document_schema_collect" "document_construct_sql_chart" "document_construct_model_chart"})
(defn wrap-tools-with-state
"Wrap state-dependent tools with access to the memory atom.
Tools in `state-dependent-tools` will have access to the memory atom
via the dynamic `*memory-atom*` binding.
Returns a new tools map where state-dependent tools are wrapped.
Wrapped tools are returned as maps with :doc, :schema, :prompt, and :fn keys
to satisfy the Claude API schema validation. Tool-specific instructions are
loaded from `resources/metabot/prompts/tools/` by `extract-tool-instructions`
in `prompts.clj`, keyed by `:prompt` metadata or `<tool-name>.md` by default."
[tools memory-atom]
(reduce-kv
(fn [acc tool-name tool-var]
(if (contains? state-dependent-tools tool-name)
(let [{:keys [doc schema prompt decode]} (meta tool-var)
wrapped-fn (fn [args]
(binding [shared/*memory-atom* memory-atom
*memory-atom* memory-atom]
(tool-var args)))]
(assoc acc tool-name (cond-> {:doc doc
:schema schema
:prompt prompt
:fn wrapped-fn}
decode (assoc :decode decode))))
(assoc acc tool-name tool-var)))
{}
tools))

View File

@@ -0,0 +1,49 @@
(ns metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard
"Autogenerated dashboard tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-autogenerated-dashboard :as autogen-dashboard-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "create_autogenerated_dashboard"
:capabilities #{:automagic-dashboards}}
create-autogenerated-dashboard-tool
"Create an automatically generated dashboard using Metabase's X-ray feature.
X-ray builds complete dashboards with multiple visualizations that showcase
different aspects of your data - distributions, breakdowns by categories,
trends over time, and relationships between fields.
Specify one source type:
- table_id: Create dashboard for a table
- model_id: Create dashboard for a model
- metric_id: Create dashboard for a metric
- query_id: Create dashboard for query results from this conversation
- report_id: Create dashboard for an existing report/question"
[{:keys [source]}
:- [:map {:closed true}
[:source [:map
[:table_id {:optional true} [:maybe :int]]
[:model_id {:optional true} [:maybe :int]]
[:metric_id {:optional true} [:maybe :int]]
[:query_id {:optional true} [:maybe :string]]
[:report_id {:optional true} [:maybe :int]]]]]]
(try
(let [result (autogen-dashboard-tools/create-autogenerated-dashboard
{:source source
:memory-atom shared/*memory-atom*})
structured (:structured-output result)
reactions (:reactions result)]
(cond-> {:output (:message structured)
:structured-output structured
:final-response? (:final-response? result)
:instructions (:instructions result)}
(seq reactions) (assoc :reactions reactions)))
(catch Exception e
(log/error e "Error creating autogenerated dashboard")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create autogenerated dashboard: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,72 @@
(ns metabase-enterprise.metabot-v3.agent.tools.charts
"Chart tool wrappers."
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
[metabase-enterprise.metabot-v3.tools.edit-chart :as edit-chart-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-chart-output
[{:keys [chart-id] :as structured}]
(let [chart-xml (llm-rep/chart->xml structured)]
(str "<result>\n" chart-xml "\n</result>\n"
"<instructions>\n" (instructions/chart-created-instructions chart-id) "\n</instructions>")))
(mu/defn ^{:tool-name "create_chart"} create-chart-tool
"Create a chart from a query.
Provide a query_id in data_source and a chart_type in viz_settings."
[{:keys [data_source viz_settings]}
:- [:map {:closed true}
[:data_source [:map {:closed true}
[:query_id :string]]]
[:viz_settings [:map {:closed true}
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
(try
(let [result (create-chart-tools/create-chart
{:query-id (get data_source :query_id)
:chart-type (keyword (get viz_settings :chart_type))
:queries-state (shared/current-queries-state)})
reactions (:reactions result)
structured (assoc (dissoc result :reactions) :result-type :chart)]
(-> {:output (format-chart-output structured)
:structured-output structured}
(m/assoc-some :reactions (not-empty reactions))))
(catch Exception e
(log/error e "Error creating chart")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create chart: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "edit_chart"} edit-chart-tool
"Edit an existing chart's visualization type.
Provide a new chart_type in new_viz_settings."
[{:keys [chart_id new_viz_settings]}
:- [:map {:closed true}
[:chart_id :string]
[:new_viz_settings [:map {:closed true}
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
(try
(let [result (edit-chart-tools/edit-chart
{:chart-id chart_id
:new-chart-type (keyword (get new_viz_settings :chart_type))
:charts-state (shared/current-charts-state)})
structured (assoc result :result-type :chart)]
{:output (format-chart-output structured)
:structured-output structured})
(catch Exception e
(log/error e "Error editing chart")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to edit chart: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,30 @@
(ns metabase-enterprise.metabot-v3.agent.tools.clarification
"Clarification tool wrappers."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.ask-clarification :as ask-clarification-tools]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-clarification-output
[{:keys [question options]}]
(str question
(when (seq options)
(str "\n\nOptions:\n" (str/join "\n" (map #(str "- " %) options))))))
(mu/defn ^{:tool-name "ask_for_sql_clarification"} ask-for-sql-clarification-tool
"Ask the user for clarification about their SQL query request.
Use this tool when you need more information from the user to properly
complete their SQL request. The agent will stop and wait for the user's response."
[{:keys [question options]}
:- [:map {:closed true}
[:question :string]
[:options {:optional true} [:maybe [:sequential :string]]]]]
(try
(let [result (ask-clarification-tools/ask-for-sql-clarification {:question question
:options options})]
(assoc result :output (format-clarification-output (:structured-output result))))
(catch Exception e
{:output (str "Failed to ask clarification: " (or (ex-message e) "Unknown error"))})))

View File

@@ -0,0 +1,363 @@
(ns metabase-enterprise.metabot-v3.agent.tools.construct
"Notebook query construction tool wrappers."
(:require
[clojure.walk :as walk]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
[metabase-enterprise.metabot-v3.tools.filters :as filter-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.util :as metabot-u]
[metabase.lib.core :as lib]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- normalize-ai-args
"Normalize nested tool arguments to kebab-case keys and keyword enums."
[value]
(let [normalized (when value
(metabot-u/recursive-update-keys value metabot-u/safe->kebab-case-en))
enum-keys #{:operation :bucket :function :sort-order :direction :field-granularity}
normalize-enum (fn [v]
(cond
(keyword? v) v
(string? v) (case v
"ascending" :asc
"descending" :desc
(keyword v))
:else v))]
(walk/postwalk
(fn [x]
(if (map? x)
(reduce-kv (fn [m k v]
(assoc m k (if (enum-keys k) (normalize-enum v) v)))
{}
x)
x))
normalized)))
;; Query construction is handled by construct_notebook_query for parity with ai-service.
(def ^:private construct-field-schema
[:map {:closed true}
[:field_id :string]
[:field_granularity {:optional true} [:maybe :string]]
[:bucket {:optional true} [:maybe :string]]])
(def ^:private construct-field-aggregation-schema
[:map {:closed true}
[:field_id {:optional true} :string]
[:function [:enum "count" "count-distinct" "distinct" "sum" "min" "max" "avg"]]
[:sort_order {:optional true} [:maybe [:enum "ascending" "descending"]]]
[:bucket {:optional true} [:maybe :string]]])
(def ^:private construct-measure-aggregation-schema
[:map {:closed true}
[:measure_id :int]
[:sort_order {:optional true} [:maybe :string]]])
(def ^:private construct-metric-schema
[:or construct-field-aggregation-schema construct-measure-aggregation-schema])
(def ^:private construct-order-by-schema
[:map {:closed true}
[:field construct-field-schema]
[:direction :string]])
;; Canonical operation values per filter_type.
;; These appear as enum constraints in the JSON Schema sent to the LLM.
(def ^:private multi-value-operations
["equals" "not-equals"
"string-starts-with" "string-ends-with"
"string-contains" "string-not-contains"
;; aliases the LLM may send from system-instruction shorthand
"contains" "not-contains"
"starts-with" "ends-with"])
(def ^:private single-value-operations
["greater-than" "greater-than-or-equal"
"less-than" "less-than-or-equal"
;; LLM may use equals/not-equals with a single value instead of multi_value
"equals" "not-equals"])
(def ^:private no-value-operations
["is-null" "is-not-null"
"string-is-empty" "string-is-not-empty"
"is-true" "is-false"
;; aliases
"is-empty" "is-not-empty"])
(def ^:private construct-multi-value-filter-schema
[:map {:closed true
:decode/tool filter-tools/decode-temporal-filter}
[:filter_type [:enum "multi_value"]]
[:field_id :string]
[:operation (into [:enum] multi-value-operations)]
[:bucket {:optional true} [:maybe :string]]
[:value {:optional true} [:maybe :any]]
[:values {:optional true} [:maybe [:sequential :any]]]])
(def ^:private construct-single-value-filter-schema
[:map {:closed true
:decode/tool filter-tools/decode-temporal-filter}
[:filter_type [:enum "single_value"]]
[:field_id :string]
[:operation (into [:enum] single-value-operations)]
[:bucket {:optional true} [:maybe :string]]
[:value {:optional true} [:maybe :any]]
[:values {:optional true} [:maybe [:sequential :any]]]])
(def ^:private construct-no-value-filter-schema
[:map {:closed true}
[:filter_type [:enum "no_value"]]
[:field_id :string]
[:operation (into [:enum] no-value-operations)]])
(def ^:private construct-segment-filter-schema
[:map {:closed true}
[:filter_type [:enum "segment"]]
[:segment_id :int]])
(def ^:private construct-filter-schema
[:or
construct-multi-value-filter-schema
construct-single-value-filter-schema
construct-no-value-filter-schema
construct-segment-filter-schema])
(def ^:private construct-source-metric-schema
[:map {:closed true}
[:metric_id :int]])
(def ^:private construct-source-model-schema
[:map {:closed true}
[:model_id :int]])
(def ^:private construct-source-table-schema
[:map {:closed true}
[:table_id :int]])
(def ^:private construct-visualization-schema
[:map {:closed true}
[:chart_type :string]])
;; The LLM sometimes nests visualization inside query, so we accept it as optional
;; in each query variant and pull it out during normalization.
(def ^:private construct-query-metric-schema
[:map {:closed true}
[:query_type [:enum "metric"]]
[:source construct-source-metric-schema]
[:filters [:sequential construct-filter-schema]]
[:group_by [:sequential construct-field-schema]]
[:visualization {:optional true} construct-visualization-schema]])
(def ^:private construct-query-aggregate-schema
[:map {:closed true}
[:query_type [:enum "aggregate"]]
[:source [:or construct-source-model-schema construct-source-table-schema]]
[:aggregations [:sequential construct-metric-schema]]
[:filters [:sequential construct-filter-schema]]
[:group_by [:sequential construct-field-schema]]
[:limit [:maybe :int]]
[:visualization {:optional true} construct-visualization-schema]])
(def ^:private construct-query-raw-schema
[:map {:closed true}
[:query_type [:enum "raw"]]
[:source [:or construct-source-model-schema construct-source-table-schema]]
[:filters [:sequential construct-filter-schema]]
[:fields [:sequential construct-field-schema]]
[:order_by [:sequential construct-order-by-schema]]
[:limit [:maybe :int]]
[:visualization {:optional true} construct-visualization-schema]])
(def ^:private construct-query-schema
[:or construct-query-metric-schema construct-query-aggregate-schema construct-query-raw-schema])
(def ^:private construct-operation-aliases
{"contains" :string-contains
"not-contains" :string-not-contains
"starts-with" :string-starts-with
"ends-with" :string-ends-with
"is-empty" :string-is-empty
"is-not-empty" :string-is-not-empty})
(defn- normalize-construct-operation
[operation]
(let [op (cond
(keyword? operation) operation
(string? operation) (keyword operation)
:else operation)]
(get construct-operation-aliases (name op) op)))
(defn- normalize-construct-filter
[filter]
(let [normalized (normalize-ai-args filter)
filter-type (:filter-type normalized)
normalized (assoc normalized :operation (normalize-construct-operation (:operation normalized)))]
(case filter-type
:multi-value (-> normalized
(dissoc :filter-type :value)
(update :values (fn [vals]
(cond
(sequential? vals) (vec vals)
(some? (:value normalized)) [(:value normalized)]
:else []))))
:single-value (-> normalized
(dissoc :filter-type :values)
(assoc :value (or (:value normalized)
(first (:values normalized)))))
:no-value (-> normalized
(dissoc :filter-type :value :values))
:segment (-> normalized
(dissoc :filter-type :field-id :operation :bucket :value :values))
(dissoc normalized :filter-type))))
(defn- normalize-construct-filters
[filters]
(mapv normalize-construct-filter filters))
(defn- chart-type->keyword
[chart-type]
(cond
(keyword? chart-type) chart-type
(string? chart-type) (keyword chart-type)
:else chart-type))
(defn- query-type->keyword
[query-type]
(cond
(keyword? query-type) query-type
(string? query-type) (keyword query-type)
:else query-type))
(def ^:private construct-notebook-query-args-schema
"Schema for the `construct_notebook_query` tool arguments.
Filter sub-schemas carry `:decode/tool` transforms for temporal value coercion."
[:map {:closed true}
[:reasoning {:optional true} :string]
[:query construct-query-schema]
[:visualization {:optional true} construct-visualization-schema]])
(defn- structured->query-data
"Convert tool structured output to a map suitable for [[llm-rep/query->xml]].
Converts the pMBQL query to legacy MBQL, JSON-encodes it, and wraps result columns."
[{:keys [query-id query result-columns]}]
(let [legacy-query (when (and (map? query) (:lib/type query))
#_{:clj-kondo/ignore [:discouraged-var]}
(lib/->legacy-MBQL query))]
{:query-type "notebook"
:query-id query-id
:database_id (:database legacy-query)
:query-content (when legacy-query (json/encode legacy-query))
:result (when (seq result-columns)
{:result_columns result-columns})}))
(defn- structured->chart-xml
"Render the full chart XML (matching Python Visualization.llm_representation)
for the construct_notebook_query tool result."
[structured chart-id chart-type]
(llm-rep/visualization->xml
{:chart-id chart-id
:queries [(structured->query-data structured)]
:visualization_settings {:chart_type (if chart-type (name chart-type) "table")}}))
(def ^:private tool-arg-transformer
"Malli transformer that applies `:decode/tool` transforms declared on sub-schemas.
Used by [[decode-tool-args]] to coerce LLM arguments (e.g., date strings → integers
for extraction buckets) before the tool function runs."
(mtx/transformer {:name :tool}))
(defn- decode-tool-args
"Decode tool arguments through the Malli schema, applying `:decode/tool` transforms.
This is attached as `:decode` metadata on the tool var so that [[run-tool]] calls it
before invoking the tool function."
[args]
(mc/decode construct-notebook-query-args-schema args tool-arg-transformer))
(mu/defn ^{:tool-name "construct_notebook_query"
:decode decode-tool-args}
construct-notebook-query-tool
"Construct and visualize a notebook query from a metric, model, or table."
[{:keys [_reasoning query visualization]} :- construct-notebook-query-args-schema]
(try
(let [;; LLM sometimes nests visualization inside query — pull it out
effective-viz (or visualization (:visualization query))
normalized-query (normalize-ai-args (dissoc query :visualization))
normalized-visualization (normalize-ai-args effective-viz)
query-type (query-type->keyword (:query-type normalized-query))
chart-type (or (chart-type->keyword (:chart-type normalized-visualization))
:table)
_ (log/debug "construct_notebook_query request"
{:query-type query-type
:chart-type chart-type})
query-result (case query-type
:metric
(filter-tools/query-metric
{:metric-id (get-in normalized-query [:source :metric-id])
:filters (normalize-construct-filters (:filters normalized-query))
:group-by (normalize-ai-args (:group-by normalized-query))})
:aggregate
(filter-tools/query-datasource
{:model-id (get-in normalized-query [:source :model-id])
:table-id (get-in normalized-query [:source :table-id])
:aggregations (normalize-ai-args (:aggregations normalized-query))
:filters (normalize-construct-filters (:filters normalized-query))
:group-by (normalize-ai-args (:group-by normalized-query))
:limit (:limit normalized-query)})
:raw
(filter-tools/query-datasource
{:model-id (get-in normalized-query [:source :model-id])
:table-id (get-in normalized-query [:source :table-id])
:fields (normalize-ai-args (:fields normalized-query))
:filters (normalize-construct-filters (:filters normalized-query))
:order-by (normalize-ai-args (:order-by normalized-query))
:limit (:limit normalized-query)})
{:output (str "Unsupported query_type: " query-type)})
structured (or (:structured-output query-result) (:structured_output query-result))]
(if (and structured (:query-id structured) (:query structured))
(let [chart-result (create-chart-tools/create-chart
{:query-id (:query-id structured)
:chart-type chart-type
:queries-state {(:query-id structured) (:query structured)}})
navigate-url (get-in chart-result [:reactions 0 :url])
full-structured (assoc structured
:result-type :query
:chart-id (:chart-id chart-result)
:chart-type (:chart-type chart-result)
:chart-link (:chart-link chart-result)
:chart-content (:chart-content chart-result))
instruction-text
(let [link (te/link "Chart" "metabase://chart/" (:chart-id chart-result))]
(te/lines
"Your query and chart have been created successfully."
""
"Next steps to present the chart to the user:"
(str "- Always provide a direct link using: `" link "` where Chart is a meaningful link text")
"- If creating multiple charts, present all chart links"))
chart-xml (structured->chart-xml structured (:chart-id chart-result) chart-type)]
{:output (str "<result>\n" chart-xml "\n</result>\n"
"<instructions>\n" instruction-text "\n</instructions>")
:data-parts (when navigate-url
[(streaming/navigate-to-part navigate-url)])
:structured-output full-structured
:instructions instruction-text})
;; query-result may already have :output (error) or only :structured-output
(if-let [s (or (:structured-output query-result) (:structured_output query-result))]
(let [query-xml (llm-rep/query->xml (structured->query-data s))
instruction-text (instructions/query-created-instructions-for (:query-id s))]
(assoc query-result
:output (str "<result>\n" query-xml "\n</result>\n"
"<instructions>\n" instruction-text "\n</instructions>")))
query-result)))
(catch Exception e
(log/error e "Failed to construct notebook query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to construct notebook query: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,166 @@
(ns metabase-enterprise.metabot-v3.agent.tools.document
"Document-generation specific tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.construct :as construct-tools]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.warehouses.core :as warehouses]))
(set! *warn-on-reflection* true)
(def ^:private chart-type-enum
[:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"])
(defn- parse-reference
[[k v]]
(let [reference (name k)]
(when-let [[_ reference-type reference-id] (re-matches #"([^:]+):(.+)" reference)]
{:reference reference
:type reference-type
:id reference-id
:name v})))
(defn- parse-int
[value]
(cond
(int? value) value
(and (string? value) (re-matches #"\d+" value)) (Integer/parseInt value)
:else nil))
(defn- context-references
[]
(or (get (shared/current-context) :references) {}))
(defn- referenced-database-ids
[references]
(into []
(comp (map parse-reference)
(filter #(= "database" (:type %)))
(map (comp parse-int :id))
(remove nil?))
references))
(mu/defn ^{:tool-name "document_schema_collect"}
document-schema-collect-tool
"Collects the schema of a database in order to construct a SQL query.
**Usage:**
- Use this tool when the user is explicitly requesting to use SQL
- Use this tool when an existing model or metric does not answer the user's question.
- Do NOT use this tool if there is a model or metric that can answer the user's question
unless the user is requesting SQL."
[_args :- [:maybe [:map {:closed true}]]]
(try
(let [refs (context-references)
database-ids (referenced-database-ids refs)]
(cond
(empty? database-ids)
{:output "You must `@` mention a database to use when not querying an existing model"}
(< 1 (count database-ids))
{:output "You can only `@` mention one database when generating SQL"}
:else
(let [database-id (first database-ids)
db (warehouses/get-database database-id)
schema-ddl (table-utils/schema-full database-id)
output (str "<result>\n"
"The database the user selected has this schema:\n"
"<database_schema>\n"
schema-ddl
"\n</database_schema>\n"
"</result>\n"
"<instructions>\n"
"NEXT: construct a SQL query based on the user's instructions and this schema.\n"
"THEN: call `document_construct_sql_chart` with SQL and chart settings.\n"
"SQL engine: " (:engine db) ".\n"
"</instructions>")]
{:output output
:structured-output {:database_id database-id
:sql_engine (:engine db)}})))
(catch Exception e
(log/error e "Error collecting document schema")
{:output (str "Failed to collect schema: " (or (ex-message e) "Unknown error"))})))
(mu/defn ^{:tool-name "document_construct_sql_chart"}
document-construct-sql-chart-tool
"Construct SQL-backed chart draft payload for document insertion."
[{:keys [database_id name description analysis approach sql viz_settings]}
:- [:map {:closed true}
[:database_id :int]
[:name :string]
[:description :string]
[:analysis :string]
[:approach :string]
[:sql :string]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]]]
(try
(let [result (create-sql-query-tools/create-sql-query {:database-id database_id
:sql sql})
chart-type (get viz_settings :chart_type)
query-id (:query-id result)
query (:query result)
structured {:tool "document_construct_sql_chart"
:name name
:description description
:analysis analysis
:approach approach
:dataset_query query
:display chart-type
:chart_type chart-type
:query_id query-id
:query query
:result-type :chart-draft}]
{:output "Draft chart payload generated from SQL query."
:structured-output structured
:final-response? true})
(catch Exception e
(log/error e "Error constructing SQL chart draft")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to construct SQL chart draft: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "document_construct_model_chart"}
document-construct-model-chart-tool
"Construct notebook/model-backed chart draft payload for document insertion."
[{:keys [name description query viz_settings]}
:- [:map {:closed true}
[:name :string]
[:description :string]
[:query :map]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]]]
(try
(let [chart-type (get viz_settings :chart_type)
result (construct-tools/construct-notebook-query-tool
{:query query
:visualization {:chart_type chart-type}})
structured (or (:structured-output result) (:structured_output result))
query-id (:query-id structured)
dataset-query (:query structured)]
(if (map? dataset-query)
{:output "Draft chart payload generated from model/notebook query."
:structured-output {:tool "document_construct_model_chart"
:name name
:description description
:dataset_query dataset-query
:display chart-type
:chart_type chart-type
:query_id query-id
:query dataset-query
:result-type :chart-draft}
:final-response? true}
;; Preserve tool error messaging from construct_notebook_query path.
(or result
{:output "Failed to construct model chart draft."})))
(catch Exception e
(log/error e "Error constructing model chart draft")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to construct model chart draft: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,80 @@
(ns metabase-enterprise.metabot-v3.agent.tools.metadata
"Metadata tool wrappers."
(:require
[clojure.string :as str]
[medley.core :as m]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details-tools]
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats-tools]
[metabase-enterprise.metabot-v3.tools.get-metadata :as metadata-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-with-instructions
[data instruction-text]
(str "<result>\n" data "\n</result>\n"
"<instructions>\n" instruction-text "\n</instructions>"))
(defn- format-answer-sources-output
[{:keys [metrics models]}]
(let [content (str (when (seq metrics)
(str "<metrics>\n"
(str/join "\n" (map llm-rep/metric->xml metrics))
"\n</metrics>\n"))
(when (seq models)
(str "<metabase-models>\n"
(str/join "\n" (map llm-rep/model->xml models))
"\n</metabase-models>")))]
(format-with-instructions content instructions/answer-sources-instructions)))
(defn- format-metadata-output
[structured]
(llm-rep/get-metadata-result->xml structured))
(defn- format-field-metadata-output
;; NOTE: keep in sync with read_resource.clj/format-content :field-metadata branch
[{:keys [field_id value_metadata]}]
(format-with-instructions
(llm-rep/field-metadata->xml {:field_id field_id :value_metadata value_metadata})
instructions/field-metadata-instructions))
(defn- add-output
"Add :output to a tool result that has :structured-output, using the given format-fn."
[result format-fn]
(m/assoc-some result :output (some-> result :structured-output format-fn)))
(mu/defn ^{:tool-name "list_available_data_sources"} list-available-data-sources-tool
"List all data sources (metrics and models) available to the metabot instance."
[_args :- [:maybe [:map {:closed true}]]]
(add-output
(entity-details-tools/answer-sources {:metabot-id metabot-v3.config/embedded-metabot-id
:with-field-values? false})
format-answer-sources-output))
(mu/defn ^{:tool-name "list_available_fields"} list-available-fields-tool
"Retrieve metadata for tables, models, and metrics."
[{:keys [table_ids model_ids metric_ids]} :- [:map {:closed true}
[:table_ids [:sequential :int]]
[:model_ids [:sequential :int]]
[:metric_ids [:sequential :int]]]]
(add-output
(metadata-tools/get-metadata {:table-ids table_ids
:model-ids model_ids
:metric-ids metric_ids})
format-metadata-output))
(mu/defn ^{:tool-name "get_field_values"} get-field-values-tool
"Return metadata for a given field of a given data source."
[{:keys [data_source source_id field_id]} :- [:map {:closed true}
[:data_source [:enum "table" "model" "metric"]]
[:source_id :int]
[:field_id :string]]]
(add-output
(field-stats-tools/field-values {:entity-type data_source
:entity-id source_id
:field-id field_id
:limit nil})
format-field-metadata-output))

View File

@@ -0,0 +1,47 @@
(ns metabase-enterprise.metabot-v3.agent.tools.navigation
"Navigation tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.navigate :as navigate-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "navigate_user"
:capabilities #{:frontend-navigate-user-v1}}
navigate-user-tool
"Navigate the user to a specific page or entity in Metabase.
Use this tool to direct users to:
- Pages: notebook_editor, metrics_browser, model_browser, database_browser, sql_editor
- Entities: table, model, question, metric, dashboard
- Query results or charts from the current conversation"
[{:keys [destination]}
:- [:map {:closed true}
[:destination [:or
[:map {:closed true}
[:page [:enum "notebook_editor" "metrics_browser" "model_browser" "database_browser"]]]
[:map {:closed true}
[:page [:enum "sql_editor"]]
[:database_id :int]]
[:map {:closed true}
[:entity_type [:enum "table" "model" "question" "metric" "dashboard"]]
[:entity_id :int]]
[:map {:closed true}
[:query_id :string]]
[:map {:closed true}
[:chart_id :string]]]]]]
(try
(let [result (navigate-tools/navigate {:destination destination
:memory-atom shared/*memory-atom*})
structured (:structured-output result)
reactions (:reactions result)]
(cond-> {:output (:message structured)
:structured-output structured}
(seq reactions) (assoc :reactions reactions)))
(catch Exception e
(log/error e "Error navigating")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to navigate: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,26 @@
(ns metabase-enterprise.metabot-v3.agent.tools.resources
"Resource reading tool wrappers."
(:require
[metabase-enterprise.metabot-v3.tools.read-resource :as read-resource-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "read_resource"} read-resource-tool
"Read detailed information about Metabase resources via URI patterns.
Supports fetching multiple resources in parallel using metabase:// URIs:
- metabase://table/{id}/fields - Get table structure with fields
- metabase://model/{id}/fields/{field_id} - Get specific field details
- metabase://metric/{id}/dimensions - Get metric dimensions
- metabase://transform/{id} - Get transform details
- metabase://dashboard/{id} - Get dashboard details"
[{:keys [uris]}
:- [:map {:closed true}
[:uris [:sequential [:string {:description "Metabase resource URIs to fetch"}]]]]]
(try
(read-resource-tools/read-resource {:uris uris})
(catch Exception e
(log/error e "Error in read_resource tool")
{:output (str "Failed to read resources: " (or (ex-message e) "Unknown error"))})))

View File

@@ -0,0 +1,126 @@
(ns metabase-enterprise.metabot-v3.agent.tools.search
"Search tool wrappers for Metabot v3."
(:require
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.tools.search :as search-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-search-output
"Format search results as an LLM-ready string."
[results]
(let [results-xml (llm-rep/search-results->xml results)]
(te/lines
"<result>"
results-xml
""
(str "Total results: " (count results))
"</result>"
"<instructions>"
instructions/search-result-instructions "</instructions>")))
(defn- invalid-entity-types
[entity-types allowed]
(when (seq entity-types)
(seq (remove allowed entity-types))))
(mu/defn ^{:tool-name "search"} search-tool
"Search for tables, models, metrics, dashboards, and saved questions."
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "metric" "dashboard" "question"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "metric" "dashboard" "question"})]
{:output (str "Invalid entity_types for search: " (pr-str (vec invalid))
". Allowed types: table, model, metric, dashboard, question.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "sql_search.md"} sql-search-tool
"Search for SQL-queryable data sources (tables and models) within a database."
[{:keys [semantic_queries keyword_queries entity_types database_id]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:database_id :int]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model"})]
{:output (str "Invalid entity_types for SQL search: " (pr-str (vec invalid))
". Allowed types: table, model.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:database-id database_id
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in SQL search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "nql_search.md"} nlq-search-tool
"Search for NLQ-queryable data sources (models, metrics, tables)."
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "model" "metric" "table"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"model" "metric" "table"})]
{:output (str "Invalid entity_types for NLQ search: " (pr-str (vec invalid))
". Allowed types: model, metric, table.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:profile-id "nlq"
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in NLQ search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "transform_search"} transform-search-tool
"Search for transforms, tables, and models."
[{:keys [semantic_queries keyword_queries entity_types search_native_query]} :- [:map {:closed true}
[:semantic_queries [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:search_native_query {:optional true} [:maybe :boolean]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "transform"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "transform"})]
{:output (str "Invalid entity_types for transform search: " (pr-str (vec invalid))
". Allowed types: table, model, transform.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:search-native-query search_native_query
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in transform search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,40 @@
(ns metabase-enterprise.metabot-v3.agent.tools.shared
"Shared tool helpers and state accessors."
(:require
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:dynamic *memory-atom*
"Dynamic memory atom bound for tools that need access to agent state."
nil)
(defn current-memory
"Returns the current agent memory map, or nil if not in an agent context."
[]
(when *memory-atom*
@*memory-atom*))
(defn current-queries-state
"Returns the current queries state map from agent memory."
[]
(get-in (current-memory) [:state :queries] {}))
(defn current-charts-state
"Returns the current charts state map from agent memory."
[]
(get-in (current-memory) [:state :charts] {}))
(defn current-context
"Returns the current agent context from memory."
[]
(get (current-memory) :context))
(defn with-memory
"Helper for debugging memory-bound tools when needed."
[f]
(if *memory-atom*
(f)
(do
(log/warn "Tool called without bound memory atom")
(f))))

View File

@@ -0,0 +1,55 @@
(ns metabase-enterprise.metabot-v3.agent.tools.snippets
"Snippet tool wrappers."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.snippets :as snippet-tools]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-snippet-list-output
[snippets]
(if (seq snippets)
(str "<snippets>\n"
(str/join "\n" (map (fn [{:keys [id name description]}]
(str "<snippet id=\"" id "\" name=\"" name "\">"
(when description description)
"</snippet>"))
snippets))
"\n</snippets>")
"No snippets available."))
(defn- format-snippet-details-output
[{:keys [id name description content]}]
(str "<snippet id=\"" id "\" name=\"" name "\">\n"
(when description (str "<description>" description "</description>\n"))
(when content (str "<content>\n" content "\n</content>\n"))
"</snippet>"))
(defn- add-output
"Add :output to a tool result. Handles both :structured_output and :structured-output."
[result format-fn]
(if-let [structured (or (:structured_output result) (:structured-output result))]
(assoc result :output (format-fn structured))
result))
(mu/defn ^{:tool-name "list_snippets"
:capabilities #{:feature-snippets}} list-snippets-tool
"List all SQL snippets available in the Metabase instance.
Use this tool before editing or creating SQL transforms to understand what
snippets are available. If any snippets may be relevant to the user's request,
fetch their content using the get_snippet_details tool."
[_args :- [:maybe [:map {:closed true}]]]
(add-output (snippet-tools/get-snippets {}) format-snippet-list-output))
(mu/defn ^{:tool-name "get_snippet_details"
:capabilities #{:feature-snippets}} get-snippet-details-tool
"Get the full details of a SQL snippet including its content.
Use this tool to retrieve the actual SQL content of a snippet after identifying
it with list_snippets."
[{:keys [snippet_id]}
:- [:map {:closed true}
[:snippet_id :int]]]
(add-output (snippet-tools/get-snippet-details {:snippet-id snippet_id}) format-snippet-details-output))

View File

@@ -0,0 +1,274 @@
(ns metabase-enterprise.metabot-v3.agent.tools.sql
"SQL tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
[metabase-enterprise.metabot-v3.tools.edit-sql-query :as edit-sql-query-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.tools.replace-sql-query :as replace-sql-query-tools]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- normalize-context-type
[type-val]
(cond
(keyword? type-val) (name type-val)
(string? type-val) (u/lower-case-en type-val)
:else ""))
(defn- first-code-editor-buffer-id
[]
(let [memory (shared/current-memory)]
(when memory
(let [viewing (get-in memory [:context :user_is_viewing] [])]
(some->> viewing
(filter #(= "code_editor" (normalize-context-type (:type %))))
first
:buffers
first
:id)))))
(defn- format-query-output
"Format a query result for LLM consumption.
`preamble` is optional text placed before the query XML inside <result>
(e.g. \"SQL query successfully constructed.\")."
[structured instruction-text & {:keys [preamble?]}]
(let [query-xml (llm-rep/query->xml structured)]
(te/lines
"<result>"
(when preamble?
(te/lines
"SQL query successfully constructed."
(str "New query ID: " (:query-id structured))
"The complete query is shown below:"))
query-xml
"</result>"
"<instructions>"
instruction-text
"</instructions>")))
(defn- format-validation-error-output
[instruction-text]
(te/lines
"<result>"
"SQL query construction failed."
"</result>"
"<instructions>"
instruction-text
"</instructions>"))
(defn- code-edit-part
[buffer-id sql]
(streaming/code-edit-part {:buffer_id buffer-id
:mode "rewrite"
:value sql}))
(mu/defn ^{:tool-name "create_sql_query"
:capabilities #{:permission-write-sql-queries}}
create-sql-query-tool
"Create a new SQL query."
[{:keys [database_id sql_query]}
:- [:map {:closed true}
[:database_id :int]
[:sql_query :string]]]
(try
(let [{:keys [validation-result action-result]}
(create-sql-query-tools/create-sql-query
{:database-id database_id
:sql sql_query})
{:keys [valid? dialect error-message]} validation-result
{:keys [query-id query]} action-result]
(if valid?
(let [structured (assoc action-result :result-type :query)
instr (instructions/query-created-instructions-for query-id)
results-url (streaming/query->question-url query)]
{:output (format-query-output structured instr {:preamble? true})
:structured-output structured
:instructions instr
:data-parts [(streaming/navigate-to-part results-url)]})
(let [instr (instructions/sql-validation-error-instructions dialect error-message)]
{:output (format-validation-error-output instr)
:instructions instr})))
(catch Exception e
(log/error e "Error creating SQL query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create SQL query: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "create_sql_query"
:capabilities #{:permission-write-sql-queries}}
create-sql-query-code-edit-tool
"Create a new SQL query and update the code editor buffer."
[{:keys [database_id sql_query]}
:- [:map {:closed true}
[:database_id :int]
[:sql_query :string]]]
(let [buffer-id (first-code-editor-buffer-id)]
(if (nil? buffer-id)
{:output "No active code editor buffer found for SQL editing."}
(let [{:keys [validation-result action-result]}
(create-sql-query-tools/create-sql-query
{:database-id database_id
:sql sql_query})
{:keys [valid? dialect error-message]} validation-result
{:keys [query-id query-content]} action-result]
(if valid?
(let [structured (assoc action-result :result-type :query)
instr (instructions/query-created-instructions-for query-id)]
{:output (format-query-output structured instr {:preamble? true})
:structured-output structured
:instructions instr
:data-parts [(code-edit-part buffer-id query-content)]})
(let [instr (instructions/sql-validation-error-instructions dialect error-message)]
{:output (format-validation-error-output instr)
:instructions instr}))))))
(mu/defn ^{:tool-name "edit_sql_query"
:capabilities #{:permission-write-sql-queries}}
edit-sql-query-tool
"Edit an existing SQL query using structured edits."
[{:keys [query_id edits checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:edits [:sequential [:map {:closed true}
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]]
(try
(let [{:keys [validation-result action-result]}
(edit-sql-query-tools/edit-sql-query
{:query-id query_id
:edits edits
:checklist checklist
:queries-state (shared/current-queries-state)})
{:keys [valid? error-message dialect]} validation-result
{:keys [query-id query]} action-result]
(if valid?
(let [structured (assoc action-result :result-type :query)
instr (instructions/edit-sql-query-instructions-for query-id)
results-url (streaming/query->question-url query)]
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(streaming/navigate-to-part results-url)]})
(let [instr (instructions/sql-validation-error-instructions dialect error-message)]
{:output (format-validation-error-output instr)
:instructions instr})))
(catch Exception e
(log/error e "Error editing SQL query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to edit SQL query: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "edit_sql_query"
:capabilities #{:permission-write-sql-queries}}
edit-sql-query-code-edit-tool
"Edit an existing SQL query and update the code editor buffer."
[{:keys [query_id edits checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:edits [:sequential [:map {:closed true}
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]]
(let [buffer-id (first-code-editor-buffer-id)]
(if (nil? buffer-id)
{:output "No active code editor buffer found for SQL editing."}
(let [{:keys [validation-result action-result]}
(edit-sql-query-tools/edit-sql-query
{:query-id query_id
:edits edits
:checklist checklist
:queries-state (shared/current-queries-state)})
{:keys [valid? dialect error-message]} validation-result
{:keys [query-id query-content]} action-result]
(if valid?
(let [structured (assoc action-result :result-type :query)
instr (instructions/edit-sql-query-instructions-for query-id)]
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(code-edit-part buffer-id query-content)]})
(let [instr (instructions/sql-validation-error-instructions dialect error-message)]
{:output (format-validation-error-output instr)
:instructions instr}))))))
(mu/defn ^{:tool-name "replace_sql_query"
:capabilities #{:permission-write-sql-queries}}
replace-sql-query-tool
"Replace the SQL content of an existing query entirely."
[{:keys [query_id new_query checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:new_query :string]]]
(try
(let [{:keys [validation-result action-result]}
(replace-sql-query-tools/replace-sql-query
{:query-id query_id
:sql new_query
:checklist checklist
:queries-state (shared/current-queries-state)})
{:keys [valid? dialect error-message]} validation-result
{:keys [query-id query]} action-result]
(if valid?
(let [structured (assoc action-result :result-type :query)
instr (instructions/replace-sql-query-instructions-for query-id)
results-url (streaming/query->question-url query)]
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(streaming/navigate-to-part results-url)]})
(let [instr (instructions/sql-validation-error-instructions dialect error-message)]
{:output (format-validation-error-output instr)
:instructions instr})))
(catch Exception e
(log/error e "Error replacing SQL query")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to replace SQL query: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "replace_sql_query"
:capabilities #{:permission-write-sql-queries}}
replace-sql-query-code-edit-tool
"Replace an SQL query and update the code editor buffer."
[{:keys [query_id new_query checklist]}
:- [:map {:closed true}
[:query_id [:or :string :int]]
[:checklist :string]
[:new_query :string]]]
(let [buffer-id (first-code-editor-buffer-id)]
(if (nil? buffer-id)
{:output "No active code editor buffer found for SQL editing."}
(let [{:keys [validation-result action-result]}
(replace-sql-query-tools/replace-sql-query
{:query-id query_id
:sql new_query
:checklist checklist
:queries-state (shared/current-queries-state)})
{:keys [valid? dialect error-message]} validation-result
{:keys [query-id query-content]} action-result]
(if valid?
(let [structured (assoc action-result :result-type :query)
instr (instructions/replace-sql-query-instructions-for query-id)]
{:output (format-query-output structured instr)
:structured-output structured
:instructions instr
:data-parts [(code-edit-part buffer-id query-content)]})
(let [instr (instructions/sql-validation-error-instructions dialect error-message)]
{:output (format-validation-error-output instr)
:instructions instr}))))))

View File

@@ -0,0 +1,44 @@
(ns metabase-enterprise.metabot-v3.agent.tools.subscriptions
"Dashboard subscription tool wrapper."
(:require
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as subscription-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "create_dashboard_subscription"}
create-dashboard-subscription-tool
"Create a dashboard subscription to send regular email updates.
Use when a user wants to receive or send regular updates on a dashboard's contents.
Requires a valid dashboard ID, email address, and schedule.
Do NOT infer email addresses from usernames or other information.
If the email address is incomplete or missing a part like the TLD,
ask the user for clarification before proceeding."
[{:keys [dashboard_id email schedule]}
:- [:map {:closed true}
[:dashboard_id :int]
[:email :string]
[:schedule [:map
[:frequency [:enum "hourly" "daily" "weekly" "monthly"]]
[:hour {:optional true} [:maybe :int]]
[:day_of_week {:optional true} [:maybe :string]]
[:day_of_month {:optional true} [:maybe :string]]]]]]
(try
(subscription-tools/create-dashboard-subscription
{:dashboard-id dashboard_id
:email email
:schedule (-> schedule
(update :frequency keyword)
(cond->
(:day_of_week schedule) (-> (assoc :day-of-week (keyword (:day_of_week schedule)))
(dissoc :day_of_week))
(:day_of_month schedule) (-> (assoc :day-of-month (keyword (:day_of_month schedule)))
(dissoc :day_of_month))))})
(catch Exception e
(log/error e "Error creating dashboard subscription")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to create dashboard subscription: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,65 @@
(ns metabase-enterprise.metabot-v3.agent.tools.todo
"Todo tool wrappers."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.todo :as todo-tools]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-todo-write-output
[{:keys [todo_count]}]
(str "Todo list updated successfully. " todo_count " item(s)."))
(defn- format-todo-read-output
[{:keys [todos todo_count]}]
(if (seq todos)
(str "Current todo list (" todo_count " items):\n"
(str/join "\n" (map (fn [{:keys [id content status priority]}]
(str "- [" status "] " content " (id=" id ", priority=" priority ")"))
todos)))
"No todos found."))
(defn- add-output
[result format-fn]
(if-let [structured (:structured-output result)]
(assoc result :output (format-fn structured))
result))
(mu/defn ^{:tool-name "todo_write"} todo-write-tool
"Create and manage a structured task list.
Write or update the todo list with tasks for tracking progress.
Each todo item must have:
- id: Unique identifier for the task
- content: Description of what needs to be done
- status: One of 'pending', 'in_progress', 'completed', 'cancelled'
- priority: One of 'high', 'medium', 'low'"
[{:keys [todos]}
:- [:map {:closed true}
[:todos [:sequential [:map {:closed true}
[:id :string]
[:content :string]
[:status [:enum "pending" "in_progress" "completed" "cancelled"]]
[:priority [:enum "high" "medium" "low"]]]]]]]
(try
(add-output
(todo-tools/todo-write {:todos todos
:memory-atom shared/*memory-atom*})
format-todo-write-output)
(catch Exception e
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to update todo list: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "todo_read"} todo-read-tool
"Read the current todo list from memory.
Returns the list of todos that have been created during this conversation."
[_args :- [:maybe [:map {:closed true}]]]
(try
(add-output
(todo-tools/todo-read {:memory-atom shared/*memory-atom*})
format-todo-read-output)
(catch Exception e
{:output (str "Failed to read todo list: " (or (ex-message e) "Unknown error"))})))

View File

@@ -0,0 +1,185 @@
(ns metabase-enterprise.metabot-v3.agent.tools.transforms
"Transform tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.dependencies :as deps]
[metabase-enterprise.metabot-v3.tools.transforms :as transform-tools]
[metabase-enterprise.metabot-v3.tools.transforms-write :as transforms-write-tools]
[metabase-enterprise.metabot-v3.util :as metabot.u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-transform-details-output
[{:keys [id name description source target] :as _transform}]
(metabot.u/xml
[:transform {:id id :name name}
(when description [:description description])
(when source
[:source {:type (:type source)}
(when (:query source) [:query (:query source)])
(when (:source-database source) [:database (:source-database source)])])
(when target [:target (pr-str target)])]))
(defn- format-python-library-output
[{:keys [path] :as lib}]
(metabot.u/xml
[:python-library {:path path}
(when-let [content (:content lib)] [:content content])]))
(defn- format-transform-write-output
[{:keys [message]}]
(or message "Transform updated successfully."))
(defn- add-output
"Add :output to a tool result. Handles both :structured_output and :structured-output."
[result format-fn]
(if-let [structured (or (:structured_output result) (:structured-output result))]
(assoc result :output (format-fn structured))
result))
(defn- check-dependencies
"Check for downstream breakages after a SQL transform edit.
Returns nil if no issues found or the check fails, otherwise a map with
:bad_transforms and :bad_questions."
[transform-id source]
(when transform-id
(try
(let [{:keys [structured_output]} (deps/check-transform-dependencies
{:id transform-id :source source})]
(when (and structured_output
(or (seq (:bad_transforms structured_output))
(seq (:bad_questions structured_output))))
structured_output))
(catch Exception e
(log/error e "Dependency check failed for transform" transform-id)
nil))))
(defn- format-dependency-warnings
"Format dependency check results into instructions for the agent."
[{:keys [bad_transforms bad_questions]}]
(str "\n\n**Dependency issues detected.**\n"
"Your proposed changes would break downstream dependencies.\n\n"
(when (seq bad_transforms)
(str "Broken transforms:\n"
(apply str (for [{:keys [transform]} bad_transforms]
(str "- [" (:name transform) "](metabase://transform/" (:id transform) ")\n")))))
(when (seq bad_questions)
(str "Broken questions:\n"
(apply str (for [{:keys [question]} bad_questions]
(str "- [" (:name question) "](metabase://question/" (:id question) ")\n")))))
"\nYou must summarize these dependency issues for the user. "
"Do not propose or apply fixes yourself."))
(mu/defn ^{:tool-name "get_transform_details"} get-transform-details-tool
"Get information about a transform."
[{:keys [transform_id]} :- [:map {:closed true}
[:transform_id :int]]]
(add-output (transform-tools/get-transform-details {:transform-id transform_id})
format-transform-details-output))
(mu/defn ^{:tool-name "get_transform_python_library_details"} get-transform-python-library-details-tool
"Get information about a Python library by path."
[{:keys [path]} :- [:map {:closed true}
[:path :string]]]
(add-output (transform-tools/get-transform-python-library-details {:path path})
format-python-library-output))
(mu/defn ^{:tool-name "write_transform_sql"
:capabilities #{:feature-transforms :permission-write-transforms}}
write-transform-sql-tool
"Write new SQL queries or edit existing queries for transforms.
Supports two modes:
- edit: Targeted string replacements with partial edits
- replace: Replace entire query atomically
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
For replace mode, provide new_content with the complete SQL."
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables]}
:- [:map {:closed true}
[:transform_id {:optional true} [:maybe :int]]
[:edit_action [:map
[:mode [:enum "edit" "replace"]]
[:edits {:optional true} [:maybe [:sequential [:map
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]
[:new_content {:optional true} [:maybe :string]]]]
[:thinking {:optional true} [:maybe :string]]
[:transform_name {:optional true} [:maybe :string]]
[:transform_description {:optional true} [:maybe :string]]
[:source_database {:optional true} [:maybe :int]]
[:source_tables {:optional true} [:maybe :map]]]]
(try
(let [result (add-output
(transforms-write-tools/write-transform-sql
{:transform_id transform_id
:edit_action edit_action
:thinking thinking
:transform_name transform_name
:transform_description transform_description
:source_database source_database
:source_tables source_tables
:memory-atom shared/*memory-atom*
:context (shared/current-context)})
format-transform-write-output)
transform (get-in result [:structured-output :transform])
dep-issues (check-dependencies transform_id (:source transform))]
(cond-> result
dep-issues (update :instructions str (format-dependency-warnings dep-issues))))
(catch Exception e
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to write SQL transform: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "write_transform_python"
:capabilities #{:feature-transforms :feature-transforms-python :permission-write-transforms}}
write-transform-python-tool
"Write new Python code or edit existing code for transforms.
Supports two modes:
- edit: Targeted string replacements with partial edits
- replace: Replace entire code atomically
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
For replace mode, provide new_content with the complete Python code.
Use `get_transform_python_library_details` before writing any Python code to inspect the shared library.
Use the shared library in your code by adding `import common` at the top of the file.
Keep `import common` at the top of the file even if it is currently unused."
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables]}
:- [:map {:closed true}
[:transform_id {:optional true} [:maybe :int]]
[:edit_action [:map
[:mode [:enum "edit" "replace"]]
[:edits {:optional true} [:maybe [:sequential [:map
[:old_string :string]
[:new_string :string]
[:replace_all {:optional true} [:maybe :boolean]]]]]]
[:new_content {:optional true} [:maybe :string]]]]
[:thinking {:optional true} [:maybe :string]]
[:transform_name {:optional true} [:maybe :string]]
[:transform_description {:optional true} [:maybe :string]]
[:source_database {:optional true} [:maybe :int]]
[:source_tables {:optional true} [:maybe :map]]]]
(try
(add-output
(transforms-write-tools/write-transform-python
{:transform_id transform_id
:edit_action edit_action
:thinking thinking
:transform_name transform_name
:transform_description transform_description
:source_database source_database
:source_tables source_tables
:memory-atom shared/*memory-atom*
:context (shared/current-context)})
format-transform-write-output)
(catch Exception e
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to write Python transform: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,387 @@
(ns metabase-enterprise.metabot-v3.agent.user-context
"User context enrichment and formatting for agent system messages.
Handles formatting of viewing context (what the user is currently looking at),
recent views, user time formatting, and SQL dialect extraction from context."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.util :as u]
[metabase.util.log :as log])
(:import
(java.time OffsetDateTime)
(java.time.format DateTimeFormatter)))
(set! *warn-on-reflection* true)
;;; Time Formatting
(defn format-current-time
"Format user's current time from context.
Expects :current_user_time (preferred) or :current_time_with_timezone in context as ISO-8601 string."
[context]
(try
(cond
(string? (:current_user_time context))
(:current_user_time context)
(string? (:current_time_with_timezone context))
(let [offset-time (OffsetDateTime/parse (:current_time_with_timezone context))
formatter DateTimeFormatter/ISO_LOCAL_DATE_TIME]
(.format formatter offset-time))
(string? (:current_time context))
(:current_time context)
:else
(.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (OffsetDateTime/now)))
(catch Exception e
(log/error e "Error formatting current time")
(.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (OffsetDateTime/now)))))
;;; SQL Dialect Extraction
(declare normalize-context-type native-query-item? effective-context-type)
(defn extract-sql-dialect
"Extract SQL dialect name from context.
Looks for dialect in native SQL editor context (:sql_engine field).
Handles both explicit `type: \"native\"` items and `type: \"adhoc\"` items
whose inner dataset-query type is `\"native\"`.
Returns lowercase dialect name suitable for loading dialect instructions."
[context]
(when-let [viewing (:user_is_viewing context)]
(some (fn [item]
(when (= "native" (effective-context-type item))
(some-> (:sql_engine item) u/lower-case-en)))
viewing)))
;;; Context Normalization
(defn- normalize-context-type
"Normalize context :type to lowercase string."
[type-val]
(cond
(keyword? type-val) (name type-val)
(string? type-val) type-val
(nil? type-val) nil
:else (str type-val)))
(defn- native-query-item?
"True when the viewing-context item represents a native SQL query.
The frontend sends `type: \"adhoc\"` for *both* notebook (MBQL) and native SQL
queries. We distinguish them by inspecting the query: a dataset-query with
`{:type \"native\"}` (or `:native`) is a native SQL query, as is an MLv2/pMBQL
query with a single native stage."
[item]
(let [query (:query item)]
(or (= "native" (normalize-context-type (:type query)))
;; MLv2/pMBQL: normalize and use lib to detect native queries
(when (and (map? query) (:database query))
(try
(lib/native-only-query? (lib-be/normalize-query query))
(catch Exception _ false))))))
(defn- effective-context-type
"Return the effective context type for a viewing-context item.
Handles the fact that the frontend sends `type: \"adhoc\"` for both notebook
and native SQL queries by inspecting the inner dataset-query type."
[item]
(let [t (normalize-context-type (:type item))]
(if (and (= "adhoc" t) (native-query-item? item))
"native"
t)))
;;; Entity Formatting
(defn- fully-qualified-name
"Build fully qualified table name (schema.table) when schema is available."
[{:keys [name database_schema]}]
(if (not-empty database_schema)
(str database_schema "." name)
name))
(defn- format-simple-entity
[entity]
(te/lines
(te/field (:type entity) (str (fully-qualified-name entity) " (ID: " (or (:id entity) "-") ")"))
(te/field "Description" (:description entity))
(te/field "Fields" (some->> (:fields entity) (map :name) (str/join ", ")))))
(defn- dispatch-format-entity [entity] (effective-context-type entity))
(defmulti format-entity "Format an entity for LLM representation." {:arglists '([entity])} dispatch-format-entity)
(defmethod format-entity :default [entity]
(log/warn "Unknown viewing context type:" (:type entity))
"")
;; For saved entities (table, model, question, metric, dashboard), the frontend only sends
;; type + id. We fetch full details from the DB using entity-details and render them via
;; llm-representations, mirroring what the Python AI service did via HTTP callbacks.
(defn- fetch-and-format
"Fetch entity details and format with llm-rep. Falls back to format-simple-entity on failure."
[entity preamble details-fn format-fn]
(try
(let [{:keys [structured-output]} (details-fn)]
(if structured-output
(te/lines preamble (format-fn structured-output))
(format-simple-entity entity)))
(catch Exception e
(log/error e "Error fetching entity details for viewing context" {:type (:type entity) :id (:id entity)})
(format-simple-entity entity))))
(defmethod format-entity "table"
[entity]
(fetch-and-format entity
"The user is currently looking at the rows of a table:"
#(entity-details/get-table-details {:table-id (:id entity)
:with-field-values? false
:with-metrics? false})
llm-rep/table->xml))
(defmethod format-entity "model"
[entity]
(fetch-and-format entity
"The user is currently looking at the rows of a model:"
#(entity-details/get-table-details {:model-id (:id entity)
:with-field-values? false
:with-metrics? false})
llm-rep/model->xml))
(defmethod format-entity "question"
[entity]
(if (native-query-item? entity)
(format-entity "native" entity)
(fetch-and-format entity
"The user is currently looking at the results of a report:"
#(entity-details/get-report-details {:report-id (:id entity)
:with-field-values? false})
llm-rep/question->xml)))
(defmethod format-entity "metric"
[entity]
(fetch-and-format entity
"The user is currently looking at the details of a metric:"
#(entity-details/get-metric-details {:metric-id (:id entity)
:with-field-values? false})
llm-rep/metric->xml))
(defmethod format-entity "dashboard"
[entity]
(fetch-and-format entity
"The user is currently looking at the details of a dashboard:"
#(entity-details/get-dashboard-details {:dashboard-id (:id entity)})
llm-rep/dashboard->xml))
;;; Viewing Context Formatting
;; Format adhoc query (notebook editor) viewing context.
(defmethod format-entity "adhoc"
[item]
(te/lines "The user is currently in the notebook editor viewing a query."
(te/field "Query ID" (:id item))
(te/field "Database ID" (get-in item [:query :database]))
(te/field "Tables used" (some->> (:used_tables item)
(map format-entity)
te/lines))))
;; Format native SQL query viewing context.
;; The :query field can be either a plain SQL string (legacy / explicit `type: "native"`)
;; or a dataset-query map (from the frontend with `type: "adhoc"`) where the actual SQL
;; lives at [:native :query].
(defmethod format-entity "native"
[item]
(let [query-val (:query item)
sql-text (cond
;; Plain SQL string (legacy)
(string? query-val) query-val
;; MLv2/pMBQL or legacy dataset-query map: normalize and use lib
(and (map? query-val) (:database query-val))
(try
(lib/raw-native-query (lib-be/normalize-query query-val))
(catch Exception _
;; Fall back to manual extraction
(or (some :native (:stages query-val))
(get-in query-val [:native :query]))))
;; Other map shapes
(map? query-val) (get-in query-val [:native :query])
:else nil)]
(te/lines
"The user is currently in the SQL editor."
(te/field "Query ID" (:id item))
(te/field "Current SQL query" (te/code sql-text "sql"))
(te/field "Database SQL engine" (:sql_engine item))
(te/field "Query error" (te/code (:error item)))
(te/field "Tables used" (some->> (:used_tables item)
(map format-entity)
te/lines)))))
(defn- transform-query-source-text
[source]
(let [query (:query source)]
(cond
(string? query) query
(string? (:query-content query)) (:query-content query)
(string? (get-in query [:native :query])) (get-in query [:native :query])
(and (map? query) (:database query))
(try
(let [normalized (lib-be/normalize-query query)]
(if (lib/native-only-query? normalized)
(or (lib/raw-native-query normalized)
(some :native (:stages normalized))
(get-in normalized [:native :query]))
(u/pprint-to-str normalized)))
(catch Exception _
(u/pprint-to-str query)))
(map? query) (u/pprint-to-str query)
:else (some-> query str))))
(defn- transform-source-type
[source]
(normalize-context-type (:type source)))
(defmulti format-transform-source
"Format a transform source for LLM representation."
{:arglists '([source])}
transform-source-type)
(defmethod format-transform-source :default
[source]
(log/warn "Unknown transform source type:" (:type source))
(te/lines "Transform source"
(te/field "Type" (transform-source-type source))
(te/field "Value" (u/pprint-to-str source))))
(defmethod format-transform-source "query"
[source]
(let [source-text (transform-query-source-text source)]
(te/lines "Transform source"
(te/field "Type" (:type source))
(te/field "Query type" (:transform-source-type source))
(te/field "Source database ID" (or (:source-database source)
(get-in source [:query :database])))
(te/field "Query" (te/code source-text (when (= "native" (normalize-context-type (:transform-source-type source)))
"sql"))))))
(defmethod format-transform-source "python"
[source]
(te/lines "Transform source"
(te/field "Type" (:type source))
(te/field "Source database ID" (:source-database source))
(te/field "Source tables" (some-> (:source-tables source) u/pprint-to-str))
(te/field "Source code" (te/code (:body source) "python"))))
(defmethod format-entity "transform"
[item]
(te/lines "The user is currently viewing a Transform."
(te/field "Transform ID" (:id item))
(te/field "Transform name" (:name item))
(te/field "Transform description" (:description item))
(te/field "Source type" (:source_type item))
(te/field "Source" (some-> (:source item)
(assoc :transform-source-type (:source_type item))
format-transform-source))
(te/field "Transform error" (te/code (:error item)))
(te/field "Tables used" (some->> (:used_tables item)
(map format-entity)
te/lines))
(te/field "Created at" (:created_at item))
(te/field "Updated at" (:updated_at item))))
(defmethod format-entity "code_editor"
[{:keys [buffers]}]
(if (empty? buffers)
"The user is in the code editor but no active buffers are available."
(te/lines "The user is currently in the code editor with the following buffer(s):"
(for [{:keys [source cursor selection] :as buffer} buffers]
(te/lines
(format "Buffer ID: %s | Language: %s | Database ID: %s"
(:id buffer) (:language source) (:database_id source))
(when cursor
(format "Cursor: Line %s, Column %s" (:line cursor) (:column cursor)))
(when-let [{:keys [start end text]} selection]
(te/lines
(te/field "Selected lines" (str (:line start) "-" (:line end)))
(te/field "Selected text" text))))))))
(defn format-viewing-context
"Format user's current viewing context for injection into system message.
Handles different context types:
- adhoc: Notebook query editor
- native: SQL editor with schema context
- transform: Transform definition and code
- code_editor: Code editor buffers with cursor position
- table/model/question/metric/dashboard: Entity details
Returns formatted string for template variable {{viewing_context}}."
[context]
(str/join "\n\n"
(for [item (:user_is_viewing context)]
(try
(format-entity item)
(catch Exception e
(log/error e "Error formatting viewing context item:" (:type item))
"")))))
;;; Recent Views Formatting
(defn format-recent-views
"Format user's recently viewed items for injection into system message.
Returns formatted string for template variable {{recent_views}}."
[context]
(if-not (:user_recently_viewed context)
""
(let [items (:user_recently_viewed context)]
(te/lines "Here are some items the user has recently viewed:"
(for [item items]
(format-simple-entity (select-keys item [:type :id :name :description])))
""
"**Important:** These items might be relevant for answering the user's request."
"If any item seems relevant, try to fetch its full details using the appropriate tool."
"Otherwise, use the search tool to find relevant entities."))))
(defn format-current-user-info
"Format the current user and glossary for injection into the system message.
Returns XML for template variable {{current_user_info}}."
[_context]
(try
(when-let [{:keys [id name email-address glossary]} (:structured-output (entity-details/get-current-user nil))]
(llm-rep/user->xml {:id id
:name name
:email email-address
:glossary glossary}))
(catch Exception e
(log/error e "Error formatting current user info")
nil)))
;;; Context Enrichment
(defn enrich-context-for-template
"Enrich context with all necessary variables for system prompt template rendering.
Takes raw context from API and returns map suitable for template rendering:
- :current_time - Formatted user time string
- :first_day_of_week - Calendar week start (default 'Sunday')
- :sql_dialect - SQL dialect name (lowercase)
- :current_user_info - Formatted current user info and glossary
- :viewing_context - Formatted viewing context
- :recent_views - Formatted recent views"
[context]
{:current_time (format-current-time context)
:first_day_of_week (get context :first_day_of_week "Sunday")
:sql_dialect (extract-sql-dialect context)
:current_user_info (format-current-user-info context)
:viewing_context (format-viewing-context context)
:recent_views (format-recent-views context)})

View File

@@ -2,7 +2,9 @@
"`/api/ee/metabot-v3/` routes"
(:require
[clj-http.client :as http]
[clojure.core.async :as a]
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.core :as agent]
[metabase-enterprise.metabot-v3.api.document]
[metabase-enterprise.metabot-v3.api.metabot]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
@@ -10,6 +12,7 @@
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
[metabase-enterprise.metabot-v3.self.core :as self.core]
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
[metabase.api.common :as api]
@@ -17,15 +20,23 @@
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.app-db.core :as app-db]
[metabase.config.core :as config]
[metabase.premium-features.core :as premium-features]
[metabase.server.streaming-response :as sr]
[metabase.store-api.core :as store-api]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
[toucan2.core :as t2])
(:import
(java.io OutputStream)))
(defn- store-message! [conversation-id profile-id messages]
(set! *warn-on-reflection* true)
(defn- store-aiservice-messages!
"Store messages that are going from ai-service"
[conversation-id profile-id messages]
(let [finish (let [m (u/last messages)]
(when (= (:_type m) :FINISH_MESSAGE)
m))
@@ -50,7 +61,6 @@
(filter map?)
(map #(+ (:prompt %) (:completion %)))
(apply +))})))
(defn- check-metabot-enabled!
"Checks that the Metabot instance identified by `metabot-id` is enabled. Throws a 403 if it's not."
[metabot-id]
@@ -60,27 +70,170 @@
(api/check (metabot-v3.settings/metabot-enabled?)
[403 "Metabot is not enabled."])))
(defn- extract-usage
"Extract usage from parts, taking the last `:usage` per model.
The agent loop emits cumulative usage — each `:usage` part subsumes all prior
usage for that model — so we simply take the last one per model rather than
summing. Returns a map keyed by model name:
{\"model-name\" {:prompt X :completion Y}}"
[parts]
(transduce
(filter #(= :usage (:type %)))
(completing
(fn [acc {:keys [usage model]}]
(let [model (or model "unknown")]
(assoc acc model {:prompt (:promptTokens usage 0)
:completion (:completionTokens usage 0)}))))
{}
parts))
(defn- store-native-parts!
"Store assistant response parts directly to the database.
Takes AI SDK parts (after aisdk-xf combining) and stores them in the native format,
avoiding the intermediate 'aisdk messages' format.
Parts format: [{:type :text :text \"...\"} {:type :tool-input ...} ...]"
[conversation-id profile-id parts]
(let [state-part (u/seek #(and (= :data (:type %))
(= "state" (:data-type %)))
parts)
usage (extract-usage parts)
;; Filter out :start, :usage, :finish, :data - these are metadata, not message content
;; :data is like `:navigate_to`
content (->> parts
(remove #(#{:start :usage :finish :data} (:type %)))
vec)]
(t2/with-transaction [_conn]
(when state-part
(app-db/update-or-insert! :model/MetabotConversation {:id conversation-id}
(constantly {:user_id api/*current-user-id*
:state (:data state-part)})))
(t2/insert! :model/MetabotMessage
{:conversation_id conversation-id
:data content
:usage usage
:role :assistant
:profile_id profile-id
:total_tokens (->> (vals usage)
(map #(+ (:prompt %) (:completion %)))
(reduce + 0))}))))
(defn- streaming-writer-rf
"Creates a reducing function that writes AI SDK lines to an OutputStream.
Lines are written immediately with a newline and flushed for real-time streaming.
When `canceled-chan` is provided, polls it before each write and returns `reduced`
to stop the pipeline when the client has disconnected. Also catches EofException
(client closed connection) and converts it to `reduced` so the pipeline shuts down
cleanly without triggering upstream retries."
[^java.io.OutputStream os canceled-chan]
(fn
([] nil)
([_] nil)
([acc ^String line]
(if (and canceled-chan (a/poll! canceled-chan))
(reduced acc)
(try
(.write os (.getBytes (str line "\n") "UTF-8"))
(.flush os)
(catch org.eclipse.jetty.io.EofException _
(reduced acc)))))))
(defn- combine-text-parts-xf []
(fn [rf]
(let [pending (volatile! nil)]
(fn
([] (rf))
([result]
(let [p @pending]
(rf (if p (rf result p) result))))
([result part]
(let [prev @pending]
(if (and prev (= :text (:type prev) (:type part)))
(do (vswap! pending update :text str (:text part))
result)
(do (vreset! pending part)
(if prev (rf result prev) result)))))))))
(defn- native-agent-streaming-request
"Handle streaming request using native Clojure agent.
Streams AI SDK v4 line protocol to the client in real-time while simultaneously
collecting parts for database storage. Text parts are combined before storage
to consolidate streaming chunks into single text parts.
Monitors `canceled-chan` for client disconnection — when the client closes the
connection, the pipeline stops via `reduced` and collected parts are still persisted.
When `:debug?` is true, enables debug logging which emits a `debug_log` data
part at the end of the stream with full LLM request/response data per iteration."
[{:keys [profile-id message context history conversation-id state debug?]}]
(let [enriched-context (metabot-v3.context/create-context context)
messages (concat history [message])]
(sr/streaming-response {:content-type "text/event-stream"} [^OutputStream os canceled-chan]
(let [parts-atom (atom [])
;; Compose: collect parts AND convert to lines for streaming.
;; In dev mode, emit usage parts in the SSE stream for debugging/benchmarking.
xf (comp (u/tee-xf parts-atom)
(self.core/aisdk-line-xf {:emit-usage? config/is-dev?}))]
(try
(transduce xf
(streaming-writer-rf os canceled-chan)
(agent/run-agent-loop
(cond-> {:messages messages
:state state
:profile-id (keyword profile-id)
:context enriched-context
:tracking-opts {:session-id conversation-id
:track-user-intent? true}}
debug? (assoc :debug? true))))
(catch org.eclipse.jetty.io.EofException _
(log/debug "Client disconnected during native agent streaming"))
(finally
(store-native-parts! conversation-id profile-id (into [] (combine-text-parts-xf) @parts-atom))))))))
(defn streaming-request
"Handles an incoming request, making all required tool invocation, LLM call loops, etc."
[{:keys [metabot_id profile_id message context history conversation_id state]}]
[{:keys [metabot_id profile_id message context history conversation_id state debug]}]
(let [message (metabot-v3.envelope/user-message message)
metabot-id (metabot-v3.config/resolve-dynamic-metabot-id metabot_id)
_ (check-metabot-enabled! metabot-id)
profile-id (metabot-v3.config/resolve-dynamic-profile-id profile_id metabot-id)
session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(store-message! conversation_id profile-id [message])
(metabot-v3.client/streaming-request
{:context (metabot-v3.context/create-context context)
:metabot-id metabot-id
:profile-id profile-id
:session-id session-id
:conversation-id conversation_id
:message message
:history history
:state state
:on-complete (fn [lines]
(store-message! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
:store-in-db)})))
;; Only allow debug mode in dev — never in production
debug? (and config/is-dev? (boolean debug))]
(store-aiservice-messages! conversation_id profile-id [message])
(if (metabot-v3.settings/use-native-agent)
;; Use native Clojure agent
(do
(log/info "Using native Clojure agent" {:profile-id profile-id :debug? debug?})
(native-agent-streaming-request
{:profile-id profile-id
:message message
:context context
:history history
:conversation-id conversation_id
:state state
:debug? debug?}))
;; Fallback to Python AI Service
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(log/info "Using Python AI Service" {:profile-id profile-id :debug? debug?})
(metabot-v3.client/streaming-request
{:context (metabot-v3.context/create-context context)
:metabot-id metabot-id
:profile-id profile-id
:session-id session-id
:conversation-id conversation_id
:message message
:history history
:state state
:debug? debug?
:on-complete (fn [lines]
(store-aiservice-messages! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
:store-in-db)})))))
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
@@ -97,7 +250,8 @@
[:context ::metabot-v3.context/context]
[:conversation_id ms/UUIDString]
[:history [:maybe ::metabot-v3.client.schema/messages]]
[:state :map]]]
[:state :map]
[:debug {:optional true} [:maybe :boolean]]]]
(metabot-v3.context/log body :llm.log/fe->be)
(streaming-request body))

View File

@@ -2,7 +2,9 @@
"`/api/ee/metabot-v3/document` routes"
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.core :as metabot-v3.agent]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
@@ -32,6 +34,109 @@
(catch Exception e
(ex-message e))))
(defn- maybe-fix-native-sql
[response references]
(let [query (get-in response [:draft_card :dataset_query])]
(if-let [error (when (= "native" (name (:type query))) (check-query query))]
(let [db-id (-> references
keys
first
name
(#(str/split % #":"))
last
Integer/parseInt)
db (warehouses/get-database db-id)
schema-ddl (table-utils/schema-full db-id)]
;; Attempt to fix SQL 5 times before returning.
(loop [attempts-left 5
sql (get-in query [:native :query])
error error]
(let [fixed-sql (fix-sql {:sql sql
:dialect (:engine db)
:error_message error
:schema_ddl schema-ddl})
;; Only re-check query if new SQL was produced.
error (when fixed-sql
(check-query (assoc-in query [:native :query] fixed-sql)))]
(if (and error (> attempts-left 1))
(recur (dec attempts-left) fixed-sql error)
(assoc-in response [:draft_card :dataset_query :native :query] (or fixed-sql sql))))))
response)))
(defn- part->structured-output
[part]
(or (get-in part [:result :structured-output])
(get-in part [:result :structured_output])))
(defn- latest-chart-structured-output
[parts]
(->> parts
(filter #(= :tool-output (:type %)))
(keep part->structured-output)
(filter map?)
(filter #(or (:chart-id %)
(= :chart-draft (:result-type %))
(and (:dataset_query %) (:display %))))
last))
(defn- draft-card-from-chart-output
[chart-output]
(let [chart-name (:name chart-output)
query (:dataset_query chart-output)
chart-type (or (:display chart-output)
(:chart_type chart-output))]
(when (and chart-name (map? query) chart-type)
{:name chart-name
:display (name chart-type)
:dataset_query query
:database_id (:database query)
:parameters []
:visualization_settings {}})))
(defn- last-agent-message
[parts]
(let [text-groups (reduce (fn [groups part]
(if (= :text (:type part))
(update groups (dec (count groups)) conj (:text part))
(conj groups [])))
[[]]
parts)
last-text-message (some->> text-groups
reverse
(map #(str/trim (str/join "" %)))
(remove str/blank?)
first)]
last-text-message))
(defn- native-generate-content
[{:keys [instructions references]}]
(let [context (assoc
(metabot-v3.context/create-context {:capabilities #{"permission:write_sql_queries"}})
:references references)
parts (into [] (metabot-v3.agent/run-agent-loop
{:messages [{:role :user
:content instructions}]
:profile-id :document-generate-content
:state {}
:context context
:tracking-opts {:source "document_generate_content"
:track-user-intent? true}}))
chart-output (latest-chart-structured-output parts)
draft-card (draft-card-from-chart-output chart-output)
description (or (:description chart-output)
(:name chart-output)
(:name draft-card))]
(if draft-card
(maybe-fix-native-sql {:draft_card draft-card
:description description
:error nil} references)
{:draft_card nil
:description nil
:error (or
(last-agent-message parts)
"Unable to generate chart content.")})))
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
@@ -47,35 +152,26 @@
(let [response (metabot-v3.client/document-generate-content {:instructions (:instructions body)
:references (:references body)
:user_id api/*current-user-id*
:conversation_id (str (random-uuid))})
query (get-in response [:draft_card :dataset_query])]
(if (and query (= "native" (:type query)))
(if-let [error (check-query query)]
(let [db-id (-> (:references body)
keys
first
name
(#(str/split % #":"))
last
Integer/parseInt)
db (warehouses/get-database db-id)
schema-ddl (table-utils/schema-full db-id)]
;; Attempt to fix SQL 5 times before returning.
(loop [attempts-left 5
sql (get-in query [:native :query])
error error]
(let [fixed-sql (fix-sql {:sql sql
:dialect (:engine db)
:error_message error
:schema_ddl schema-ddl})
;; Only re-check query if new SQL was produced.
error (when fixed-sql
(check-query (assoc-in query [:native :query] fixed-sql)))]
(if (and error (> attempts-left 1))
(recur (dec attempts-left) fixed-sql error)
(assoc-in response [:draft_card :dataset_query :native :query] (or fixed-sql sql))))))
response)
response)))
:conversation_id (str (random-uuid))})]
(maybe-fix-native-sql response (:references body))))
(api.macros/defendpoint :post "/native-generate-content" :- [:map
[:draft_card [:maybe [:map
[:name ms/NonBlankString]
[:dataset_query ms/Map]
[:database_id ms/PositiveInt]
[:parameters [:maybe [:sequential ms/Map]]]
[:visualization_settings ms/Map]]]]
[:error [:maybe ms/NonBlankString]]
[:description [:maybe ms/NonBlankString]]]
"Create a new piece of content to insert into the document using the native Clojure agent."
[_route-params
_query-params
body :- [:map
[:instructions ms/NonBlankString]
[:references {:optional true} ms/Map]]]
(native-generate-content body))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3/document` routes."

View File

@@ -94,9 +94,6 @@
(defn- metric-selection-endpoint-url []
(str (metabot-v3.settings/ai-service-base-url) "/v1/select-metric"))
(defn- find-outliers-endpoint-url []
(str (metabot-v3.settings/ai-service-base-url) "/v1/find-outliers"))
(defn- fix-sql-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/sql/fix"))
@@ -115,9 +112,6 @@
(defn- document-generate-content-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/document/generate-content"))
(defn- generate-embeddings-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/embeddings"))
(defn- quick-closing-body
"Some requests come with body wrapped in ContentLengthInputStream, and that will never close the underlying stream.
So we just close the client itself.
@@ -147,7 +141,7 @@
Response chunks are encoded in the format understood by the frontend and AI Service, Clojure backend doesn't
know anything about it and just shuttles them over.
"
[{:keys [context message history profile-id conversation-id session-id state on-complete]}
[{:keys [context message history profile-id conversation-id session-id state debug? on-complete]}
:- [:map
[:context :map]
[:message ::metabot-v3.client.schema/message]
@@ -156,16 +150,18 @@
[:conversation-id :string]
[:session-id :string]
[:state :map]
[:debug? {:optional true} [:maybe :boolean]]
[:on-complete {:optional true} [:function [:=> [:cat :any] :any]]]]]
(premium-features/assert-has-feature :metabot-v3 "MetaBot")
(try
(let [url (ai-url "/v2/agent/stream")
body {:messages (conj (vec history) message)
:context context
:conversation_id conversation-id
:profile_id profile-id
:user_id api/*current-user-id*
:state state}
body (cond-> {:messages (conj (vec history) message)
:context context
:conversation_id conversation-id
:profile_id profile-id
:user_id api/*current-user-id*
:state state}
debug? (assoc :debug true))
_ (metabot-v3.context/log body :llm.log/be->llm)
_ (log/debugf "V2 request to AI Proxy:\n%s" (u/pprint-to-str body))
options (cond-> {:headers {"Accept" "text/event-stream"
@@ -241,21 +237,6 @@
{}
e)))))
(mu/defn find-outliers-request
"Make a request to AI Service to find outliers"
[values :- [:sequential [:map [:dimension :any] [:value :any]]]]
(try
(let [url (find-outliers-endpoint-url)
body {:values values}
options (build-request-options body)
response (post! url options)]
(u/prog1 (check-response! response body)
(log/debugf "Response:\n%s" (u/pprint-to-str <>))))
(catch Throwable e
(throw (ex-info (format "Error in request to AI service: %s" (ex-message e))
{}
e)))))
(mu/defn fix-sql
"Ask the AI service to propose fixes a SQL query and a given error."
[body :- [:map
@@ -373,14 +354,3 @@
options (build-request-options payload)
response (post! url options)]
(check-response! response payload)))
(defn generate-embeddings
"Generate vector embeddings for a batch of inputs questions for the given models and metrics."
[model-name texts]
(let [url (generate-embeddings-endpoint)
body {:model model-name
:input texts
:encoding_format "base64"}
options (build-request-options body)
response (post! url options)]
(check-response! response body)))

View File

@@ -16,7 +16,7 @@
"The name of the collection exposed by the answer-sources tool."
{internal-metabot-id {:profile-id "internal"
:entity-id "metabotmetabotmetabot"}
embedded-metabot-id {:profile-id "embedding"
embedded-metabot-id {:profile-id "embedding_next"
:entity-id "embeddedmetabotmetabo"}})
(defn metabot-id->profile-id
@@ -30,7 +30,7 @@
(defn normalize-metabot-id
"Return the primary key for the metabot instance identified by `metabot-id`.
Returns nil, no entry can be found.
Returns nil if no entry can be found.
The provided ID can be a UUID from [[metabot-config]] or an entity_id of a Metabot instance."
[metabot-id]
(t2/select-one-pk :model/Metabot :entity_id (get-in metabot-config [metabot-id :entity-id] metabot-id)))
@@ -45,11 +45,11 @@
(defn resolve-dynamic-profile-id
"Resolve the ultimate ai-service profile ID with logical fall backs
Precedence: explicit profile_id > env profile_id > metabot-id->profile-id > default (embedded)"
Precedence: explicit profile_id > env profile_id > metabot-id->profile-id > default (embedding_next)"
([profile-id]
(resolve-dynamic-profile-id profile-id (resolve-dynamic-metabot-id nil)))
([profile-id metabot-id]
(or profile-id
(metabot-v3.settings/ai-service-profile-id)
(metabot-id->profile-id metabot-id)
"default")))
"embedding_next")))

View File

@@ -12,7 +12,8 @@
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr])
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2])
(:import
(java.time OffsetDateTime)
(java.time.format DateTimeFormatter)))
@@ -114,8 +115,47 @@
(log/error e "Error getting Python transform tables for context")
[])))
(defn- mbql-source-table-ids
"Given a context item with an MBQL query, return [database-id [table-id ...]] if it has
source-table references, or nil otherwise. Handles both MLv2/pMBQL and legacy formats."
[item]
(when (= "adhoc" (:type item))
(let [query (:query item)
database-id (:database query)
normalized (try (lib-be/normalize-query query) (catch Exception _ nil))
table-ids (->> (:stages normalized)
(keep :source-table)
(filter pos-int?)
distinct
vec)]
(when (seq table-ids)
[database-id table-ids]))))
(defn- mbql-source-tables-for-context
"Get source tables for an MBQL query, formatted for metabot context (with :type, :display_name, :fields, etc.).
Uses a direct table lookup without native-query permission checks. The user is already
viewing these tables in the notebook editor, so they have at least query-builder access.
The standard `used-tables-from-ids` requires `:query-builder-and-native` permissions
which is too restrictive for MBQL viewing context enrichment."
[[database-id table-ids]]
(try
(let [raw-tables (t2/select [:model/Table :id :name :schema]
:db_id database-id
:id [:in table-ids]
:active true
:visibility_type nil)
tables (when (seq raw-tables)
(table-utils/enhanced-database-tables database-id
{:priority-tables raw-tables
:all-tables-limit (count raw-tables)}))]
(m/distinct-by :id tables))
(catch Exception e
(log/error e "Error getting MBQL source tables for context")
nil)))
(defn- enhance-context-with-schema
"Enhance context by adding table schema information for native queries, SQL transforms, and Python transforms"
"Enhance context by adding table schema information for native queries, MBQL queries, SQL transforms, and Python transforms."
[context]
(if-let [user-viewing (get context :user_is_viewing)]
(let [enhanced-viewing
@@ -126,6 +166,11 @@
(when-let [tables (seq (database-tables-for-context {:query query}))]
(assoc item :used_tables tables)))
;; Handle MBQL/notebook queries
(when-let [db-and-table-ids (mbql-source-table-ids item)]
(when-let [tables (seq (mbql-source-tables-for-context db-and-table-ids))]
(assoc item :used_tables tables)))
;; Handle Python transforms
(when-let [db-and-table-ids (python-transform-db-and-table-ids item)]
(when-let [tables (seq (python-transform-tables-for-context db-and-table-ids))]

View File

@@ -0,0 +1,185 @@
(ns metabase-enterprise.metabot-v3.example-question-generator
"Native Clojure generator for example questions (prompts) for Metabot.
Replaces the Python ai-service `/v1/example-question-generation/batch` endpoint
when `use-native-agent` is true.
Architecture mirrors the Python service:
- Separate LLM call per table/metric (not one batch prompt)
- Two distinct prompt templates: table.jinja → table.selmer, metric.jinja → metric.selmer
- Parallel execution within batches of 10
- 5 questions generated per item
- Uses tool_choice pattern for structured output (matching Python's structured_completion)
- Temperature 0.3 (matching Python default)
- Response shape matches: `{:table_questions [{:questions [...]}] :metric_questions [{:questions [...]}]}`"
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.util.log :as log]
[selmer.parser :as selmer]))
(set! *warn-on-reflection* true)
;;; Template loading (cached)
(def ^:private table-template-path "metabot/prompts/example_questions_table.selmer")
(def ^:private metric-template-path "metabot/prompts/example_questions_metric.selmer")
(def ^:private template-cache
"In-memory cache for loaded prompt templates. Map of path -> template string."
(atom {}))
(defn- load-template [path]
(or (get @template-cache path)
(if-let [content (some-> (io/resource path) slurp)]
(do (swap! template-cache assoc path content)
content)
(throw (ex-info "Prompt template not found" {:path path})))))
(defn clear-template-cache!
"Clear the template cache. Useful for development/testing."
[]
(reset! template-cache {}))
;;; Column LLM name formatting (mirrors Python ColumnSchema.get_llm_name / get_llm_description)
(defn- llm-name
"Format column name for LLM. If it has a table-reference, format as table_reference__name."
[{:keys [name table-reference]}]
(if (not (str/blank? table-reference))
(str table-reference "__" name)
name))
(defn- llm-description
"Format column description for LLM. If it has a table-reference, prepend 'From the related X table.'"
[{:keys [description table-reference]}]
(cond
(and (not (str/blank? table-reference)) (not (str/blank? description)))
(str "From the related " table-reference " table. " description)
(not (str/blank? table-reference))
(str "From the related " table-reference " table.")
:else
(or description "")))
(defn- enrich-column
"Add :llm_name and :llm_description to a column map for template rendering."
[col]
(assoc col
:llm_name (llm-name col)
:llm_description (llm-description col)))
;;; Output validation
(def ^:private questions-json-schema
"JSON Schema for the structured output tool, matching Python's ExampleQuestionGenerationSchema."
{:type "object"
:properties {:questions {:type "array"
:items {:type "string"}
:description "List of example questions. No additional explanation."}}
:required ["questions"]
:additionalProperties false})
(defn- validate-questions-response!
"Validate that LLM response has the expected shape: {:questions [string ...]}.
Throws on invalid response so fallback can kick in."
[response source-name]
(when-not (and (map? response)
(sequential? (:questions response))
(every? string? (:questions response)))
(throw (ex-info "Invalid LLM response shape for example question generation"
{:response response
:source source-name
:expected "{:questions [\"string\" ...]}"}))))
;;; LLM call
(def ^:private temperature 0.3)
(defn- call-llm
"Make a structured LLM call for example question generation.
Delegates to the shared self/call-llm-structured infrastructure which provides
retry logic, error handling, and OTel tracing."
[rendered-prompt]
(self/call-llm-structured
(llm/ee-ai-metabot-provider)
[{:role "user" :content rendered-prompt}]
questions-json-schema
temperature
300
{:request-id (str (random-uuid))
;; example_question_generation_batch was the name of the old ai-service api endpoint
:source "example_question_generation_batch"
:tag "example-question-generation"}))
;;; Per-item generation (mirrors Python generate_table_example_questions / generate_metric_example_questions)
(defn- generate-table-questions
"Generate example questions for a single table/model."
[table]
(let [template (load-template table-template-path)
rendered (selmer/render template
{:table_name (:name table)
:table_description (or (:description table) "")
:columns (map enrich-column (:fields table))})
response (call-llm rendered)]
(validate-questions-response! response (:name table))
{:questions (vec (:questions response))}))
(defn- generate-metric-questions
"Generate example questions for a single metric."
[metric]
(let [template (load-template metric-template-path)
rendered (selmer/render template
{:metric_name (:name metric)
:metric_description (or (:description metric) "")
:dimensions (map enrich-column
(:queryable-dimensions metric))
:default_time_dimension (some-> (:default-time-dimension metric)
enrich-column)})
response (call-llm rendered)]
(validate-questions-response! response (:name metric))
{:questions (vec (:questions response))}))
;;; Batch processing (mirrors Python generate_example_questions_batch with asyncio.gather)
(def ^:private max-batch-size 10)
(defn- process-batch-parallel
"Process items in parallel batches, matching Python's asyncio.gather behavior."
[items generate-fn]
(vec
(mapcat
(fn [batch]
(let [futures (mapv #(future (generate-fn %)) batch)]
(mapv deref futures)))
(partition-all max-batch-size items))))
(defn generate-example-questions
"Generate example questions natively using LLM via OpenRouter.
Accepts the same payload shape as `metabot-v3.client/generate-example-questions`:
{:tables [{:name ... :fields [...]}]
:metrics [{:name ... :queryable-dimensions [...]}]}
Returns the same shape:
{:table_questions [{:questions [...]}]
:metric_questions [{:questions [...]}]}"
[payload]
(log/info "Generating example questions natively"
{:table-count (count (:tables payload))
:metric-count (count (:metrics payload))})
(let [table-questions (if (seq (:tables payload))
(process-batch-parallel (:tables payload) generate-table-questions)
[])
metric-questions (if (seq (:metrics payload))
(process-batch-parallel (:metrics payload) generate-metric-questions)
[])]
(log/info "Native example question generation complete"
{:table-results (count table-questions)
:metric-results (count metric-questions)})
{:table_questions table-questions
:metric_questions metric-questions}))

View File

@@ -0,0 +1,313 @@
(ns metabase-enterprise.metabot-v3.self
"LLM client infrastructure using reducible streams.
Key design decisions:
- LLM APIs return IReduceInit (reducible) instead of core.async channels
- Standard Clojure transducers work directly: (into [] xf (claude-raw {...}))
- Tools can return plain values or IReduceInit (for streaming results)
- No core.async required anywhere
TODO:
- figure out what's lacking compared to ai-service"
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.self.claude :as claude]
[metabase-enterprise.metabot-v3.self.core :as core]
[metabase-enterprise.metabot-v3.self.openai :as openai]
[metabase-enterprise.metabot-v3.self.openrouter :as openrouter]
[metabase.analytics.core :as analytics]
[metabase.analytics.prometheus :as prometheus]
[metabase.api.common :as api]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.o11y :refer [with-span]]))
(set! *warn-on-reflection* true)
(defn- resolve-adapter [provider]
;; a `case` inside of function instead of a map so that with-redefs work well
(case provider
"anthropic" claude/claude
"openai" openai/openai
"openrouter" openrouter/openrouter
(throw (ex-info (str "Unknown LLM provider: " provider)
{:provider provider}))))
(defn- parse-provider-model [s]
(let [[prov model] (str/split s #"/" 2)]
{:provider prov
:stream-fn (resolve-adapter prov)
:model model}))
;;; General LLM calling
;; Matches the Python ai-service retry behavior:
;; - tenacity @retry on _get_stream_response: 3 attempts on RateLimitError
;; - litellm _should_retry: retries on 408, 409, 429, and >= 500
;; - litellm _calculate_retry_after: exponential backoff 0.5 * 2^attempt,
;; clamped to [0, 8s], plus random jitter up to 0.75s.
;; Respects Retry-After header if present and ≤ 60s.
(def ^:private ^:const max-llm-retries
"Maximum number of LLM call attempts (1 initial + 2 retries)."
3)
(def ^:private ^:const initial-retry-delay-ms
"Base delay for exponential backoff (milliseconds). Matches litellm INITIAL_RETRY_DELAY = 0.5s."
500)
(def ^:private ^:const max-retry-delay-ms
"Maximum delay between retries (milliseconds). Matches litellm MAX_RETRY_DELAY = 8.0s."
8000)
(def ^:private ^:const max-jitter-ms
"Maximum random jitter added to retry delay (milliseconds). Matches litellm JITTER = 0.75s."
750)
(defn- retryable-status?
"Whether an HTTP status code should trigger a retry.
Matches litellm._should_retry: 408 (timeout), 409 (conflict), 429 (rate limit), >= 500."
[status]
(when status
(or (= status 408)
(= status 409)
(= status 429)
(>= status 500))))
(defn- retryable-error?
"Whether an exception represents a transient LLM error worth retrying.
Checks for retryable HTTP status codes in ex-data (set by claude-raw/openai-raw)
and connection-level failures."
[^Exception e]
(or (retryable-status? (:status (ex-data e)))
;; Connection errors (e.g. under load, connection refused/reset)
(instance? java.net.ConnectException e)
(instance? java.net.SocketTimeoutException e)
(instance? java.io.IOException e)))
(defn- parse-retry-after-header
"Extract retry-after seconds from response headers in ex-data, if present and ≤ 60s.
Returns nil if not present or not a reasonable value."
[^Exception e]
(when-let [headers (:headers (ex-data e))]
(when-let [retry-after (or (get headers "retry-after")
(get headers "Retry-After"))]
(try
(let [seconds (Long/parseLong retry-after)]
(when (<= 0 seconds 60)
(* seconds 1000)))
(catch NumberFormatException _ nil)))))
(defn- retry-delay-ms
"Calculate retry delay in milliseconds using exponential backoff with jitter.
Respects Retry-After header when present. Matches litellm._calculate_retry_after."
[attempt ^Exception e]
(let [header-ms (parse-retry-after-header e)
jitter (long (* max-jitter-ms (Math/random)))
backoff-ms (-> (* initial-retry-delay-ms (Math/pow 2.0 (dec attempt)))
(long)
(max 0)
(min max-retry-delay-ms))]
(+ (if (and header-ms (pos? header-ms))
header-ms
backoff-ms)
jitter)))
(defn- report-aisdk-errors-xf
"Transducer that increments the llm-errors counter for :error parts in the aisdk stream."
[tracking-opts]
(map (fn [part]
(when (= (:type part) :error)
(prometheus/inc! :metabase-metabot/llm-errors
{:model (:model tracking-opts "unknown")
:source (:tag tracking-opts "none")
:error-type "llm-sse-error"}))
part)))
(defn- report-token-usage-xf
"Transducer that reports token_usage metrics for :usage parts in the aisdk stream.
Prometheus + Snowplow:
- `:profile-id` — the profile id (e.g. `:internal`)
- `:model` — the model (e.g. `anthropic/claude-haiku-4-5`)
- `:tag` — the specific purpose for which the tokens were used (e.g. 'agent', 'sql-fixing')
Snowplow only:
- `:request-id` — UUID string for this request
- `:session-id` — conversation UUID string
- `:source` — the source of the request (e.g., 'metabot_agent', 'document_generate_content').
Indicates which API endpoint or workflow initiated the LLM call."
[{:keys [model profile-id request-id session-id source tag]}]
(let [start-ms (u/start-timer)]
(map (fn [part]
(when (= (:type part) :usage)
(let [usage (:usage part)
model (or (:model part) model "unknown")
prompt (:promptTokens usage 0)
completion (:completionTokens usage 0)]
(analytics/track-token-usage!
;; The caller can omit request-id (and other snowplow opts) to skip snowplow tracking.
{:prometheus true
:snowplow (some? request-id)
:profile (some-> profile-id name)
:model-id model
:prompt-tokens prompt
:completion-tokens completion
:total-tokens (+ prompt completion)
:estimated-costs-usd 0.0
:duration-ms (long (u/since-ms start-ms))
:user-id api/*current-user-id*
:request-id (some-> request-id analytics/uuid->ai-service-hex-uuid)
:session-id session-id
:source source
:tag tag})))
part))))
(defn- report-tool-usage-xf
"Transducer that fires an agent_used_tool :snowplow/ai_service_event per tool call.
Only fires when :source and :request-id are present in tracking-opts."
[{:keys [request-id session-id source profile-id iteration]}]
(map (fn [part]
(when (and (some? source)
(some? request-id)
(= (:type part) :tool-output))
(analytics/track-event! :snowplow/ai_service_event
{:hashed-metabase-license-token (analytics/hashed-metabase-token-or-uuid)
:request-id (analytics/uuid->ai-service-hex-uuid request-id)
:source source
:event "agent_used_tool"
:user-id api/*current-user-id*
:session-id session-id
:profile (some-> profile-id name)
:duration-ms (some-> (:duration-ms part) long)
:result (if (:error part) "error" "success")
:event-details (cond-> {"tool_name" (:function part)}
(some? iteration) (assoc "step" iteration))}))
part)))
(defn- with-retries
"Execute `(thunk)` with retry logic for transient LLM errors.
Retries up to `max-llm-retries` attempts with exponential backoff.
Records prometheus metrics with `:model` and `:tag` from `tracking-opts` as labels."
[tracking-opts thunk]
(let [labels {:model (:model tracking-opts) :source (:tag tracking-opts)}]
(loop [attempt 1]
(prometheus/inc! :metabase-metabot/llm-requests labels)
(let [timer (u/start-timer)
result (try
{:ok (thunk)}
(catch Exception e
(if (and (< attempt max-llm-retries)
(retryable-error? e))
(let [delay (retry-delay-ms attempt e)]
(log/warn e "LLM call failed with retryable error, retrying"
{:attempt attempt
:max max-llm-retries
:delay delay
:status (:status (ex-data e))})
(prometheus/inc! :metabase-metabot/llm-retries labels)
{:retry delay})
(do (prometheus/inc! :metabase-metabot/llm-errors
(assoc labels :error-type (.getSimpleName (class e))))
(throw e))))
(finally
(prometheus/observe! :metabase-metabot/llm-duration-ms labels (u/since-ms timer))))]
(if-let [delay (:retry result)]
(do (Thread/sleep ^long delay)
(recur (inc attempt)))
(:ok result))))))
(defn call-llm
"Call an LLM and stream processed parts.
`provider-and-model` is a string like `anthropic/claude-haiku-4-5` or
`openrouter/anthropic/claude-haiku-4-5`. The first segment selects the
provider adapter; the rest is the model name passed to the API.
`parts` is a sequence of AISDK parts (`:text`, `:tool-input`, `:tool-output`)
and user messages (`{:role :user, :content ...}`). Each adapter converts
these into its own wire format.
`tracking-opts` is a map with analytics context for prometheus and snowplow events. See [[report-token-usage-xf]]
above for details.
`llm-opts` is an optional map of provider-facing call options. Currently this
supports `:tool-choice`, used by profiles like `:sql` that must end in a tool
call instead of plain assistant text.
Returns a reducible that, when consumed, traces the full LLM round-trip
(HTTP call + streaming response) as an OTel span. Retries transient errors
(429 rate limit, 529 overloaded, connection errors) up to 3 attempts with
exponential backoff, matching the Python ai-service retry behavior."
([provider-and-model system-msg parts tools tracking-opts]
(call-llm provider-and-model system-msg parts tools tracking-opts nil))
([provider-and-model system-msg parts tools tracking-opts {:keys [tool-choice]}]
(let [{:keys [provider stream-fn model]} (parse-provider-model provider-and-model)]
(log/info "Calling LLM" {:provider provider :model model :parts (count parts) :tools (count tools)
:tool-choice tool-choice})
(let [tracking-opts (assoc tracking-opts :model model)
streaming-opts (cond-> {:model model :input parts :tools (vec tools)}
system-msg (assoc :system system-msg)
(and (seq tools)
tool-choice) (assoc :tool_choice tool-choice))
make-source (fn []
(eduction (comp (core/tool-executor-xf tools)
(core/lite-aisdk-xf)
(report-aisdk-errors-xf tracking-opts)
(report-token-usage-xf tracking-opts)
(report-tool-usage-xf tracking-opts))
(stream-fn streaming-opts)))]
(reify clojure.lang.IReduceInit
(reduce [_ rf init]
(with-span :info {:name :metabot-v3.agent/call-llm
:provider provider
:model model
:part-count (count parts)
:tool-count (count tools)}
(with-retries
tracking-opts
#(reduce rf init (make-source))))))))))
(defn call-llm-structured
"Make an LLM call that returns structured JSON output.
Uses tool_choice to force the model to call a 'json' tool with the given schema,
then collects the streamed response and extracts the parsed tool arguments.
Includes the same retry logic as [[call-llm]] for transient errors.
Args:
model - Model identifier (e.g. \"openrouter/anthropic/claude-haiku-4-5\")
messages - Sequence of Chat Completions message maps
(e.g. [{:role \"user\" :content \"...\"}])
json-schema - JSON Schema map for the expected response shape
temperature - Sampling temperature
max-tokens - Maximum tokens in the response
tracking-opts - See [[call-llm]] for fields; `:model` is added automatically
Returns the parsed JSON map from the forced tool call."
[provider-and-model messages json-schema temperature max-tokens tracking-opts]
(let [{:keys [provider stream-fn model]} (parse-provider-model provider-and-model)
_ (log/info "Calling LLM (structured)" {:provider provider :model model :msg-count (count messages)})
tracking-opts (assoc tracking-opts :model model)
streaming-opts {:model model
:input messages
:schema json-schema
:temperature temperature
:max-tokens max-tokens}]
(with-span :info {:name :metabot-v3.agent/call-llm-structured
:model model
:msg-count (count messages)}
(with-retries
tracking-opts
(fn []
(let [parts (into []
(comp (core/aisdk-xf)
(report-aisdk-errors-xf tracking-opts)
(report-token-usage-xf tracking-opts))
(stream-fn streaming-opts))
result (some (fn [{:keys [type arguments]}]
(when (= type :tool-input)
arguments))
parts)]
(or result
(throw (ex-info "LLM returned no tool call in structured response"
{:parts parts})))))))))

View File

@@ -0,0 +1,246 @@
(ns metabase-enterprise.metabot-v3.self.claude
(:require
[clj-http.client :as http]
[clojure.string :as str]
[malli.json-schema :as mjs]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self.core :as core]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.malli :as mu]
[metabase.util.o11y :refer [with-span]]))
(set! *warn-on-reflection* true)
(defn claude->aisdk-chunks-xf
"Translates Claude /v1/messages streaming events into AI SDK v5 protocol chunks.
https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
Claude Streaming Format:
- Each event: {:type \"message_start\" :message {...}}
{:type \"content_block_start\" :index 0 :content_block {:type \"text\"}}
{:type \"content_block_delta\" :index 0 :delta {:type \"text_delta\" :text \"...\"}}
{:type \"content_block_stop\" :index 0}
{:type \"message_delta\" :delta {:stop_reason \"end_turn\"}}
{:type \"message_stop\"}
AI SDK v5 Format (SSE protocol):
- Message parts: {:type :start, :messageId ...}
- Part types: start, text-start, text-delta, text-end, finish-step, finish
- Ends with: 'data: [DONE]\\n'"
[]
(fn [rf]
(let [current-type (volatile! nil)
current-id (volatile! nil)
message-id (volatile! nil)
model-name (volatile! nil)
payload (volatile! {})
;; Track the latest usage we've seen (from any event) and whether we
;; already emitted it. Claude reports usage at message_start and
;; message_delta with cumulative values — we only emit at message_delta
;; normally, but if the stream is interrupted we flush the last known
;; usage in the completion arity so we don't lose data entirely.
last-usage (volatile! nil)
close! (fn [result]
(u/prog1 (rf result (merge {:type (case @current-type
:text :text-end
:tool_use :tool-input-available)}
@payload))
(vreset! current-type nil)
(vreset! current-id nil)
(vreset! payload {})))]
(fn
([result]
(cond-> result
;; close up latest type if incomplete
@current-type (close!)
;; flush last-known usage if stream ended before message_delta
@last-usage (rf {:type :usage
:usage {:promptTokens (:input_tokens @last-usage 0)
:completionTokens (:output_tokens @last-usage 0)}
:id @message-id
:model @model-name})
true (rf)))
([result {t :type :keys [message content_block delta error] :as chunk}]
(let [block-type (when content_block
(keyword (:type content_block)))
chunk-id (or (:id content_block) @current-id (core/mkid))]
(cond-> result
;; start of message
(= t "message_start") (-> (rf {:type :start :messageId (:id message)})
(u/prog1
(vreset! message-id (:id message))
(vreset! model-name (:model message))
(vreset! last-usage (:usage message))))
;; start of new content block
(= t "content_block_start") (-> (u/prog1
(vreset! current-type block-type)
(vreset! current-id chunk-id)
(vreset! payload
(case block-type
:text {:id chunk-id}
:tool_use {:toolCallId chunk-id
:toolName (:name content_block)}
nil)))
(rf (merge (case block-type
:text {:type :text-start}
:tool_use {:type :tool-input-start})
@payload)))
;; content block delta
(= t "content_block_delta") (rf (case (:type delta)
"text_delta" {:type :text-delta
:id (:id @payload)
:delta (:text delta)}
"input_json_delta" {:type :tool-input-delta
:toolCallId (:toolCallId @payload)
:inputTextDelta (:partial_json delta)}))
;; end of content block
(= t "content_block_stop") (close!)
;; Claude reports usage at both message_start and message_delta,
;; but message_delta values are cumulative and include the earlier
;; counts.
;; https://platform.claude.com/docs/en/build-with-claude/streaming#event-types
(= t "message_delta") (u/prog1
(vreset! last-usage (:usage chunk)))
;; end of message
(= t "message_stop") identity
;; catch errors if any
(= t "error") (rf {:type :error
:errorText (:message error)}))))))))
;;; AISDK parts → Claude messages
(defn- ->content-blocks
"Coerce content into a sequence of Claude content blocks."
[content]
(if (and (string? content) (not (str/blank? content)))
[{:type "text" :text content}]
content))
(defn- merge-consecutive
"Merge consecutive assistant messages into a single message with combined content.
Claude API doesn't allow consecutive messages with the same role."
[messages]
(into [] (comp (partition-by :role)
(mapcat (fn [group]
[{:role (:role (first group))
:content (into [] (mapcat (comp ->content-blocks :content)) group)}])))
messages))
(defn parts->claude-messages
"Convert a sequence of AISDK parts into Claude API messages.
Input: flat sequence of AISDK parts and user messages:
{:role :user, :content \"...\"}
{:type :text, :text \"...\"}
{:type :tool-input, :id ..., :function ..., :arguments ...}
{:type :tool-output, :id ..., :result ...}
Output: Claude messages with tool_use/tool_result content blocks, consecutive
assistant messages merged."
[parts]
(->> parts
(mapv (fn [part]
(case (:type part)
:text {:role "assistant"
:content (:text part)}
:tool-input {:role "assistant"
:content [{:type "tool_use"
:id (:id part)
:name (:function part)
:input (:arguments part)}]}
:tool-output {:role "user"
:content [{:type "tool_result"
:tool_use_id (:id part)
:content (or (get-in part [:result :output])
(when-let [err (:error part)]
(str "Error: " (:message err)))
(pr-str (:result part)))}]}
;; User messages pass through
{:role (name (or (:role part) "user"))
:content (:content part)})))
merge-consecutive
vec))
;;; ──────────────────────────────────────────────────────────────────
;;; Tool definition format
;;; ──────────────────────────────────────────────────────────────────
(defn- tool->claude
"Convert a [name, var-or-map] tool entry to Claude API format."
[[tool-name tool]]
(let [{:keys [doc schema] :as tool} (if (map? tool) tool (meta tool))
[_:=> [_:cat params] _out] schema
doc (if (str/starts-with? (or doc "") "Inputs: ")
;; strip that stuff we're appending in mu/defn
(second (str/split doc #"\n\n " 2))
doc)]
{:name (or tool-name (name (:name tool)) "unknown")
:description doc
:input_schema (mjs/transform params {:additionalProperties false})}))
(mu/defn claude-raw
"Perform a streaming request to Claude API."
[{:keys [model system input tools schema tool_choice temperature max-tokens]
:or {model "claude-haiku-4-5"}} :- core/LLMRequestOpts]
(when-not (llm/ee-anthropic-api-key)
(throw (ex-info "No Anthropic API key is set" {:api-error true})))
(let [messages (parts->claude-messages input)
all-tools (when (seq tools) (mapv tool->claude tools))
req (cond-> {:model model
:max_tokens (or max-tokens 4096)
:stream true
:messages messages}
system (assoc :system system)
all-tools (assoc :tools all-tools)
(and all-tools
tool_choice) (assoc :tool_choice (case (name tool_choice)
"auto" {:type "auto"}
"required" {:type "any"}))
temperature (assoc :temperature temperature)
schema (assoc :tool_choice {:type "tool"
:name "structured_output"}
:tools [{:name "structured_output"
:description "Output structured data"
:input_schema schema}]))]
(with-span :info {:name :metabot-v3.claude/request
:model model
:msg-count (count input)
:tool-count (count tools)}
(try
(let [res (http/post (str (llm/ee-anthropic-api-base-url) "/v1/messages")
{:as :stream
:headers {"x-api-key" (llm/ee-anthropic-api-key)
"anthropic-version" "2023-06-01"
"content-type" "application/json"}
:body (json/encode req)})]
(core/sse-reducible (:body res)))
(catch Exception e
(if-let [res (some-> (ex-data e)
json/decode-body)]
(let [status (:status res)
msg (case (int status)
401 "Anthropic API key expired or invalid"
403 "Anthropic API key has not enough permissions"
404 "Anthropic API is telling us we cannot access this URL anymore?"
413 "Anthropic API is not happy with our request being too big"
429 "Anthropic API has rate limited us"
500 "Anthropic API is not working but not saying why"
529 "Anthropic API is overloaded and is asking us to wait"
"Unhandled error accessing Anthropic API")]
(throw (ex-info msg (assoc res :api-error true) e)))
(throw e)))))))
(defn claude
"Call Claude API, return AISDK stream"
[& args]
(eduction (claude->aisdk-chunks-xf) (apply claude-raw args)))
(comment
;; Now just use standard `into` - no core.async needed!
(def q (into [] (claude-raw {:input [{:role "user" :content "How are you feeling today?"}]})))
(into [] (comp (claude->aisdk-chunks-xf) core/aisdk-xf) q))

View File

@@ -0,0 +1,483 @@
(ns metabase-enterprise.metabot-v3.self.core
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.o11y :refer [with-span]])
(:import
(java.io BufferedReader Closeable)
(java.util.concurrent Callable Executors ExecutorService)))
(set! *warn-on-reflection* true)
;;; Canonical LLM request schema
;;
;; Every provider adapter (`claude-raw`, `openai-raw`, `openrouter-raw`) must
;; accept this same opts map. Provider-specific translation (e.g. tool_choice
;; "required" → {:type "any"} for Claude, system-message placement, tool wire
;; format) happens inside each adapter, but the **input contract is identical**.
(def ToolEntry
"A tool entry: [name, var] or [name, wrapped-map].
The wrapped-map form comes from `wrap-tools-with-state` and carries
:doc, :schema, :fn, and optionally :decode/:prompt."
[:tuple :string [:or [:fn var?]
[:map
[:doc {:optional true} [:maybe :string]]
[:schema :any]
[:fn [:fn fn?]]]]])
(def LLMRequestOpts
"Canonical schema for the opts map passed to every LLM provider adapter.
Required:
:model - Model name string (e.g. \"claude-haiku-4-5\", \"gpt-4.1-mini\")
Optional:
:system - System prompt string
:input - Sequence of AISDK parts and user messages
:tools - Sequence of [name, var-or-map] tool entries
:tool_choice - \"auto\" or \"required\"
:temperature - Sampling temperature
:max-tokens - Maximum tokens in the response
:schema - JSON Schema map for structured output; each provider forces a
tool call (Claude, OpenRouter) or uses json_schema mode (OpenAI)"
[:map
[:model {:optional true} :string]
[:system {:optional true} [:maybe :string]]
[:input {:optional true} [:sequential :map]]
[:tools {:optional true} [:sequential ToolEntry]]
[:tool_choice {:optional true} [:maybe [:enum "auto" "required"]]]
[:temperature {:optional true} [:maybe number?]]
[:max-tokens {:optional true} [:maybe :int]]
[:schema {:optional true} :any]])
(defn mkid
"Generate a random id"
[]
(str "mb-" (random-uuid)))
(defn reducible?
"Checks if argument implements IReduceInit (reducible source)."
[x]
(instance? clojure.lang.IReduceInit x))
(defn sse-reducible
"Turn an SSE InputStream into a reducible source of parsed JSON events.
Returns an IReduceInit that:
- Parses SSE 'data: {...}' lines as JSON
- Stops on 'data: [DONE]' or EOF
- Supports early termination via `reduced`
- Closes the input stream on completion or early termination
Also implements Closeable for explicit cleanup if needed."
[^Closeable input]
(reify
clojure.lang.IReduceInit
(reduce [_ rf init]
(with-open [r (io/reader input)]
(loop [acc init]
(if (reduced? acc)
@acc
(if-let [line (.readLine ^BufferedReader r)]
;; spec: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
(cond
(= line "data: [DONE]")
acc
(str/starts-with? line "data: ")
;; NOTE: we can do that since not one of providers spit json in multiple lines
(recur (rf acc (json/decode+kw (subs line 6))))
(= line "")
(recur acc)
(str/starts-with? line ":")
(recur acc)
:else
(do
(log/warn "SSE unexpected line" {:line line})
(recur acc)))
acc)))))
Closeable
(close [_]
(.close input))))
;;; AISDK5
(defn- parse-tool-arguments
"Parse concatenated tool input deltas as JSON.
Falls back to returning the raw string wrapped in a map when parsing fails,
e.g. when the LLM produces malformed JSON in tool call arguments."
[chunks]
(let [raw (->> (map :inputTextDelta chunks)
(str/join ""))]
(try
(json/decode+kw raw)
(catch Exception e
(log/warn "Failed to parse tool arguments as JSON, passing raw string"
{:tool (:toolName (first chunks))
:error (.getMessage e)
:raw-len (count raw)})
;; Return a map with a sentinel key so the tool sees an error via schema validation
;; rather than a cryptic JSON parse stacktrace.
{:_raw_arguments raw}))))
(defn- aisdk-chunks->part [[chunk :as chunks]]
(case (:type chunk)
:start {:type :start
:id (:messageId chunk)}
:usage chunk
:error chunk
:text-start {:type :text
:id (:id chunk)
:text (->> (map :delta chunks)
(str/join ""))}
:tool-input-start {:type :tool-input
:id (:toolCallId chunk)
:function (:toolName chunk)
:arguments (parse-tool-arguments chunks)}
:tool-output-available {:type :tool-output
:id (:toolCallId chunk)
:function (:toolName chunk)
:result (:result chunk)
:error (:error chunk)
:duration-ms (::duration-ms chunk)}))
(defn aisdk-xf
"Collect a stream of AI SDK v5 chunks into a list of parts (joins by id)."
([] (aisdk-xf nil))
([{:keys [stream-text?]}]
(fn [rf]
;; NOTE: logic relies on chunks pieces not being interleaved, but even `tool-executor-xf` doesn't break that rule
;; if we ever change it to stream tools rather than collect them in one piece, we'd need to improve logic here too
(let [current-id (volatile! nil)
acc (volatile! [])
;; is it not nice how consistent the AI SDK format is?
getid #(or (:id %) (:messageId %) (:toolCallId %))
flush! (fn [result]
(u/prog1 (cond-> result
(seq @acc) (rf (aisdk-chunks->part @acc)))
(vreset! current-id nil)
(vreset! acc [])))]
(fn
([result]
(cond-> result
(seq @acc) (rf (aisdk-chunks->part @acc))
true rf))
([result chunk]
(let [chunk-id (getid chunk)]
(cond
;; Self-contained chunk types: flush previous group, emit immediately.
;; :tool-output-available shares toolCallId with preceding :tool-input-*
;; chunks, so the id-based grouping below would incorrectly merge them.
(#{:tool-output-available :start :usage :error} (:type chunk))
(-> (flush! result)
(rf (aisdk-chunks->part [chunk])))
(and stream-text? (#{:text-start :text-end} (:type chunk)))
(flush! result)
(and stream-text? (#{:text-delta :text-start :text-end} (:type chunk)))
(-> (flush! result)
;; TODO: check if I can just pass through text-delta?
(rf {:type :text :id (:id chunk) :text (:delta chunk)}))
(not= chunk-id @current-id)
(u/prog1 (flush! result)
(vreset! current-id chunk-id)
(vreset! acc [chunk]))
:else
(u/prog1 result
(vswap! acc conj chunk))))))))))
(defn lite-aisdk-xf
"Like `aisdk-xf` but text is streamed through as chunks"
[]
(aisdk-xf {:stream-text? true}))
;;; AI SDK v4 Line Protocol Output
;;
;; Converts internal parts to the AI SDK v4 line protocol format used by the Python ai-service.
;; Format examples:
;; 0:"text content" - Text (just the string, JSON encoded)
;; 2:{"type":"state",...} - Data parts
;; 3:"error message" - Errors
;; 9:{"toolCallId":...} - Tool calls
;; a:{"toolCallId":...} - Tool results
;; d:{"finishReason":...} - Finish message
;; e:{...} - Finish step
;; f:{"messageId":...} - Start step
(defn format-text-line
"Format text part as AI SDK line: 0:\"content\""
[{:keys [text]}]
(str "0:" (json/encode text)))
(defn format-data-line
"Format data part as AI SDK line: 2:{\"type\":...,\"version\":1,\"value\":...}"
[{:keys [data-type data] :as part}]
;; Support both old format (data-type + data) and new format (data map with type inside)
(let [type-str (or data-type
(get data :type)
"data")
value (or data (dissoc part :type :id))]
(str "2:" (json/encode {:type (if (keyword? type-str) (name type-str) type-str)
:version 1
:value value}))))
(defn format-error-line
"Format error part as AI SDK line: 3:\"error message\""
[{:keys [error]}]
(str "3:" (json/encode (or (:message error) (str error)))))
(defn format-tool-call-line
"Format tool-input part as AI SDK line: 9:{\"toolCallId\":...,\"toolName\":...,\"args\":...}"
[{:keys [id function arguments]}]
(str "9:" (json/encode {:toolCallId id
:toolName function
:args (if (string? arguments)
arguments
(json/encode arguments))})))
(defn format-tool-result-line
"Format tool-output part as AI SDK line: a:{\"toolCallId\":...,\"result\":...}
Always includes :result key (AI SDK protocol requirement). When there is only
an error and no result, :result is set to the error message string."
[{:keys [id result error]}]
(let [output (cond
(string? result) result
(map? result) (or (:output result) (json/encode result))
;; When result is nil but error is present, use the error message as result
;; so the `result` key is always present in the output.
(nil? result) (some-> error :message str)
:else (str result))]
(str "a:" (json/encode (cond-> {:toolCallId id
:result (or output "")}
error (assoc :error (json/encode error)))))))
(defn format-finish-line
"Format finish part as AI SDK line: d:{\"finishReason\":\"stop\",\"usage\":{...}}"
[error? usage]
(str "d:" (json/encode {:finishReason (if error? "error" "stop") :usage (or usage {})})))
(defn format-start-line
"Format start part as AI SDK line: f:{\"messageId\":...}"
[{:keys [id messageId]}]
(str "f:" (json/encode {:messageId (or messageId id)})))
(defn aisdk-line-xf
"Transducer that converts internal parts to AI SDK v4 line protocol format.
Returns strings ready to be written to the output stream (without newlines).
Options:
:emit-usage? - When true, emit `:usage` parts as data lines (2:) in the SSE
stream. Useful for dev/benchmarking. Default false.
Input types and their output:
:text -> 0:\"content\"
:data -> 2:{\"type\":...,\"version\":1,\"value\":...}
:error -> 3:\"message\"
:tool-input -> 9:{\"toolCallId\":...,\"toolName\":...,\"args\":...}
:tool-output -> a:{\"toolCallId\":...,\"result\":...}
:start -> f:{\"messageId\":...}
:finish -> d:{\"finishReason\":\"stop\",\"usage\":{...}}
:usage -> 2:{\"type\":\"usage\",...} (when :emit-usage? true, else skipped)"
([] (aisdk-line-xf nil))
([{:keys [emit-usage?]}]
(fn [rf]
(let [error? (volatile! false)
usage (volatile! nil)]
(fn
([] (rf))
([result]
(-> result
;; Emit finish message with accumulated usage at the end
(rf (format-finish-line @error? (when emit-usage? @usage)))
(rf)))
([result part]
(case (:type part)
:text (rf result (format-text-line part))
:data (rf result (format-data-line part))
:error (do
(vreset! error? true)
(rf result (format-error-line part)))
:tool-input (rf result (format-tool-call-line part))
:tool-output (rf result (format-tool-result-line part))
:start (rf result (format-start-line part))
:finish result ;; Don't emit here, we emit in completion arity
:usage (do
(vreset! usage (:usage part))
result)
;; Pass through unknown types as data
(rf result (format-data-line part)))))))))
;;; Tool executor
(defonce ^:private tool-executor
(Executors/newFixedThreadPool
20
(.. (Thread/ofVirtual) (name "tool-executor-") factory)))
(defn- submit-virtual
"Submit a thunk to run on a virtual thread, bounded by pool size."
[thunk]
(.submit ^ExecutorService tool-executor ^Callable thunk))
(defn- collect-tool-result
"Normalize tool result into a vector of output chunks.
Tools can return:
- Plain value (map): wrapped as single :tool-output-available
- IReduceInit (reducible): reduced into chunks, filtering :start/:finish"
[tool-call-id tool-name result]
(let [ids {:toolCallId tool-call-id :toolName tool-name}]
(if (reducible? result)
(into []
(comp (remove #(#{:start :finish} (:type %)))
(map #(merge % ids)))
result)
[(assoc ids :type :tool-output-available :result result)])))
(defn- concise-tool-error
"Produce a concise error message for the LLM from a tool execution exception.
For malli validation errors, extracts the humanized map and summarizes it.
For other errors, uses the exception message."
[^Exception e]
(let [data (ex-data e)]
(if-let [humanized (:humanized data)]
;; Malli validation error — produce a short summary the LLM can act on
(str "Invalid tool arguments: " (pr-str humanized))
;; Other errors
(or (ex-message e) "Unknown error"))))
(defn- try-decode-json-string
"If `v` is a string that looks like a JSON object or array, decode it.
Returns the decoded value on success, or the original value on failure."
[v]
(if (and (string? v)
(let [trimmed (str/trim v)]
(or (str/starts-with? trimmed "{")
(str/starts-with? trimmed "["))))
(try
(json/decode+kw v)
(catch Exception _ v))
v))
(defn- coerce-stringified-json
"Walk tool arguments and decode any string values that are actually stringified
JSON objects/arrays. LLMs sometimes double-encode nested arguments."
[args]
(if (map? args)
(reduce-kv (fn [m k v]
(assoc m k (cond
(string? v) (try-decode-json-string v)
(map? v) (coerce-stringified-json v)
:else v)))
{}
args)
args))
(defn- tool-decode-fn
"Extract the `:decode` function from a tool (var, wrapped map, or fn with metadata).
The decode function transforms tool arguments before the tool runs.
Returns `nil` if the tool has no decoder."
[tool]
(cond
(map? tool) (:decode tool)
:else (:decode (meta tool))))
(defn- tool-call-fn
"Extract the callable function from a tool (var or wrapped map)."
[tool]
(if (map? tool) (:fn tool) tool))
(defn- run-tool
"Execute a tool and return output chunks. Handles errors gracefully.
If the tool has a `:decode` metadata function, it is applied to the parsed
arguments before invocation. The decode function can coerce values and throw
`:agent-error?` exceptions for validation failures.
Chunks have a ::duration-ms key added for internal use which is not part of the aisdk spec."
[tool-call-id tool-name tool chunks]
(with-span :info {:name :metabot-v3.agent/run-tool
:tool-name tool-name
:tool-call-id tool-call-id}
(let [start-ms (u/start-timer)
assoc-ms (fn [duration-ms]
(fn [chunk]
(cond-> chunk
(= (:type chunk) :tool-output-available) (assoc ::duration-ms duration-ms))))
results (try
(let [{:keys [arguments]} (into {} (aisdk-xf) chunks)
arguments (coerce-stringified-json arguments)
decode (tool-decode-fn tool)
arguments (cond-> arguments decode decode)]
(log/debug "Executing tool" {:tool-name tool-name :arguments arguments})
(let [tool-fn (tool-call-fn tool)
result (tool-fn arguments)]
(log/debug "Tool returned" {:tool-name tool-name :result-type (type result)})
(collect-tool-result tool-call-id tool-name result)))
(catch Exception e
(if (:agent-error? (ex-data e))
(log/debugf "Tool %s: agent validation error: %s" tool-name (ex-message e))
(log/warn e "Tool execution failed" {:tool-name tool-name}))
[{:type :tool-output-available
:toolCallId tool-call-id
:toolName tool-name
:error {:message (concise-tool-error e)
:type (str (type e))}}]))]
(mapv (assoc-ms (u/since-ms start-ms))
results))))
(defn tool-executor-xf
"Transducer that executes tool calls in parallel on virtual threads.
Behavior:
- Passes all chunks through unchanged as they arrive
- Tracks tool calls from :tool-input-start through :tool-input-available
- Spawns virtual thread for each tool when input is complete
- At completion, waits for all tools and appends results
Tools can return: plain values, IReduceInit (reducible), or channels (legacy)."
[tools]
(fn [rf]
(let [active (volatile! {})] ;; tool-call-id -> {:chunks [...]} or {:task derefable}
(fn
([result]
(let [{tasks true
chunks false} (group-by #(contains? (val %) :task) @active)]
(when (seq chunks)
(log/warn "Multiple tool calls were not collected fully before stream finish"
{:tool-calls (map first chunks)}))
(if-let [tasks (some->> (seq tasks) (map #(:task (second %))))]
(rf (reduce rf result (mapcat deref tasks)))
(rf result))))
([result {:keys [type toolCallId toolName] :as chunk}]
(case type
:tool-input-start
(when (contains? tools toolName)
(vswap! active assoc toolCallId {:chunks [chunk]}))
:tool-input-delta
(when (contains? @active toolCallId)
(vswap! active update-in [toolCallId :chunks] conj chunk))
:tool-input-available
(when-let [{:keys [chunks]} (get @active toolCallId)]
(let [tool (get tools toolName)
task (submit-virtual (bound-fn* #(run-tool toolCallId toolName tool chunks)))]
(vswap! active assoc toolCallId {:task task})))
;; otherwise: do nothing
nil)
(rf result chunk))))))

View File

@@ -0,0 +1,196 @@
(ns metabase-enterprise.metabot-v3.self.openai
(:require
[clj-http.client :as http]
[clojure.string :as str]
[malli.json-schema :as mjs]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self.core :as core]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn openai->aisdk-chunks-xf
"Translates OpenAI /v1/responses streaming events into AI SDK v5 protocol chunks.
https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
OpenAI Responses Format:
- Each event: {:type \"response.output_text.delta\" :delta ...}
{:type \"response.output_item.added\" :item {:id :type :output ...}}
AI SDK v5 Format (SSE protocol):
- Message parts: {:type :start, :messageId ...}
- Part types: start, text-start, text-delta, text-end, finish-step, finish
- Ends with: 'data: [DONE]\\n'"
[]
(fn [rf]
;; we've got lots of state since aisdk has lots of start/stop/etc messages that raw openai does not
(let [current-type (volatile! nil)
current-id (volatile! nil)
model-name (volatile! nil)
payload (volatile! {})
close! (fn [result]
(u/prog1 (rf result (merge {:type (case @current-type
:text :text-end
:function_call :tool-input-available)}
@payload))
(vreset! current-type nil)
(vreset! current-id nil)
(vreset! payload {})))]
;; some notes about the approach:
;; - most of message types carry similar payload, like id for messages, or id+name for tool calls
;; - this trick with u/prog1 was chosen deliberately, a few approaches were made and they all looked worse
;; - most of dispatch is inlined rather than separated as multimethods is not an overlook to make function
;; smaller, I'd rather contain this hairyness in a single piece while it's possible
(fn
([result]
(cond-> result
;; in case the response was incomplete we'll close up latest type
@current-type (close!)
true (rf)))
([result {t :type :keys [response item delta error] :as chunk}]
(let [middle (second (str/split t #"\."))
chunk-type (case middle
"output_item" (case (:type item)
"message" :text
(keyword (:type item)))
"content_part" :text
"output_text" :text
"function_call_arguments" :function_call
(keyword middle))
chunk-id (or (case chunk-type
;; chunks that have natural id in API response go here
:function_call (:call_id item)
:text (:id chunk)
nil)
@current-id
(core/mkid))]
(cond-> result
(= t "response.created") (-> (rf {:type :start :messageId (:id response)})
(u/prog1
(vreset! model-name (:model response))))
;; time to finish previous chunk
;; this logic will skip most of the *.done types, but they seem to be always followed by one of those two?
(or (= t "response.output_item.done")
(and @current-id
(not= chunk-id
@current-id))) (close!)
;; start of a new chunk
(= t "response.output_item.added") (-> (u/prog1
(vreset! current-type chunk-type)
(vreset! current-id chunk-id)
(vreset! payload
(case @current-type
;; no :type in payloads since we'll use that for finish msg too
:text {:id chunk-id}
:function_call {:toolCallId chunk-id
:toolName (:name item)}
nil)))
(rf (merge (case @current-type
:text {:type :text-start}
:function_call {:type :tool-input-start})
@payload)))
;; just a middle of a chunk
delta (rf (case @current-type
:text {:type :text-delta
:id @current-id
:delta delta}
:function_call {:type :tool-input-delta
:toolCallId (:toolCallId @payload)
:inputTextDelta delta}))
(= (:type chunk)
"response.completed") (rf {:type :usage
:usage (:usage response)
;; non-standard extension, not in AISDK5
:id (:id response)
:model @model-name})
(= t "error") (rf {:type :error
:errorText (or (:message error) (:message chunk))}))))))))
;;; AISDK parts → OpenAI Responses API input items
(defn parts->openai-input
"Convert a sequence of AISDK parts into OpenAI Responses API input items.
Input: flat sequence of AISDK parts and user messages.
Output: OpenAI Responses API input array."
[parts]
(mapv (fn [part]
(case (:type part)
:text {:type "message"
:role "assistant"
:content [{:type "output_text"
:text (:text part)}]}
:tool-input {:type "function_call"
:call_id (:id part)
:name (:function part)
:arguments (let [args (:arguments part)]
(if (string? args) args (json/encode args)))}
:tool-output {:type "function_call_output"
:call_id (:id part)
:output (or (get-in part [:result :output])
(when-let [err (:error part)]
(str "Error: " (:message err)))
(pr-str (:result part)))}
;; User messages
{:role (name (or (:role part) "user"))
:content (or (:content part) "")}))
parts))
;;; Tool definition format
(defn- tool->openai [[tool-name tool]]
(let [{:keys [doc schema] :as tool} (if (map? tool) tool (meta tool))
[_:=> [_:cat params] _out] schema
doc (if (str/starts-with? doc "Inputs: ")
;; strip that stuff we're appending in mu/defn
(second (str/split doc #"\n\n " 2))
doc)]
{:type "function"
:name (or tool-name (name (:name tool)))
:description doc
:parameters (mjs/transform params {:additionalProperties false})}))
(mu/defn openai-raw
"Perform a streaming request to OpenAI Responses API."
[{:keys [model system input tools schema tool_choice temperature max-tokens]
:or {model "gpt-4.1-mini"}} :- core/LLMRequestOpts]
(when-not (llm/ee-openai-api-key)
(throw (ex-info "No OpenAI API key is set" {:api-error true})))
(let [all-tools (or (when schema
;; Structured output: force a tool call with the given JSON schema
[{:type "function"
:name "structured_output"
:description "Output structured data"
:parameters schema}])
(when (seq tools) (mapv tool->openai tools)))
req (cond-> {:model model
:stream true
:store false
:instructions system
:input (parts->openai-input input)}
all-tools (assoc :tool_choice (cond
schema "required"
tool_choice tool_choice
:else "auto")
:tools all-tools)
temperature (assoc :temperature temperature)
max-tokens (assoc :max_tokens max-tokens))]
(try
(let [res (http/post (str (llm/ee-openai-api-base-url) "/v1/responses")
{:as :stream
:headers {"Authorization" (str "Bearer " (llm/ee-openai-api-key))
"Content-Type" "application/json"}
:body (json/encode req)})]
(core/sse-reducible (:body res)))
(catch Exception e
(if-let [res (ex-data e)]
(throw (ex-info (.getMessage e) (json/decode-body res)))
(throw e))))))
(defn openai
"Call OpenAI API, return AISDK stream."
[& args]
(eduction (openai->aisdk-chunks-xf) (apply openai-raw args)))

View File

@@ -0,0 +1,279 @@
(ns metabase-enterprise.metabot-v3.self.openrouter
"OpenRouter / Chat Completions adapter.
OpenRouter exposes an OpenAI-compatible Chat Completions API (`/v1/chat/completions`)
which is different from the newer OpenAI Responses API (`/v1/responses`) that our
`openai.clj` adapter speaks.
The agent loop produces AISDK parts as its canonical message format. This
adapter converts those directly to Chat Completions messages."
(:require
[clj-http.client :as http]
[clojure.string :as str]
[malli.json-schema :as mjs]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self.core :as core]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.o11y :refer [with-span]]))
(set! *warn-on-reflection* true)
;;; AISDK parts → Chat Completions messages
(defn- merge-consecutive-assistant-messages
"Merge consecutive assistant messages.
Chat Completions allows text + tool_calls on a single assistant message, so
when we see a :text part followed by :tool-input parts we fold them together."
[messages]
(into [] (comp (partition-by :role)
(mapcat (fn [group]
(if (and (< 1 (count group))
(= "assistant" (:role (first group))))
(let [text (->> group (keep :content) (str/join ""))
tool-calls (into [] (mapcat :tool_calls) group)]
;; :content should be always there, even if empty/nil
[(cond-> {:role "assistant" :content text}
(seq tool-calls) (assoc :tool_calls tool-calls))])
group))))
messages))
(defn parts->cc-messages
"Convert a sequence of AISDK parts into Chat Completions messages.
Input: flat sequence of AISDK parts and user messages:
{:role :user, :content \"...\"}
{:type :text, :text \"...\"}
{:type :tool-input, :id ..., :function ..., :arguments ...}
{:type :tool-output, :id ..., :result ...}
Output: Chat Completions messages (user, assistant with tool_calls, tool)."
[parts]
(->> parts
(map (fn [part]
(case (:type part)
:text {:role "assistant" :content (:text part)}
:tool-input {:role "assistant"
:content nil
:tool_calls [{:id (:id part)
:type "function"
:function {:name (:function part)
:arguments (let [args (:arguments part)]
(if (string? args) args (json/encode args)))}}]}
:tool-output {:role "tool"
:tool_call_id (:id part)
:content (or (get-in part [:result :output])
(when-let [err (:error part)]
(str "Error: " (:message err)))
(pr-str (:result part)))}
;; User messages pass through
{:role (name (or (:role part) "user"))
:content (or (:content part) "")})))
merge-consecutive-assistant-messages))
;;; Tool definition format
(defn- tool->openai-chat
"Convert a tool (name + var/map pair) to Chat Completions tool format.
Accepts [name, var] or [name, {:doc :schema :fn}] pairs — the same format
that the agent loop provides."
[[tool-name tool]]
(let [{:keys [doc schema]} (if (map? tool) tool (meta tool))
[_:=> [_:cat params] _out] schema
doc (if (str/starts-with? (or doc "") "Inputs: ")
(second (str/split doc #"\n\n " 2))
doc)
final-name (or tool-name
(when (var? tool) (name (:name (meta tool))))
"unknown")]
{:type "function"
:function {:name final-name
:description doc
:parameters (mjs/transform params {:additionalProperties false})}}))
;;; Streaming response → AISDK v5 chunks
(defn openrouter->aisdk-chunks-xf
"Translates Chat Completions streaming chunks into AI SDK v5 protocol chunks.
Chat Completions streaming format:
{\"id\":\"chatcmpl-xxx\",
\"object\":\"chat.completion.chunk\",
\"model\":\"...\",
\"choices\":[{\"index\":0,
\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\"},
\"finish_reason\":null}],
\"usage\":{...}}
Emits the same internal chunk types as claude.clj and openai.clj:
:start, :text-start, :text-delta, :text-end,
:tool-input-start, :tool-input-delta, :tool-input-available,
:usage
Chat Completions has no explicit start/stop events per content block like
Claude or OpenAI Responses do — we infer transitions from the delta shape.
Parallel tool calls arrive with different `index` values; when a new index
appears the previous tool is complete."
[]
(fn [rf]
(let [current-type (volatile! nil) ;; :text | :function_call | nil
current-id (volatile! nil) ;; active chunk id (text-id or tool call_id)
message-id (volatile! nil)
model-name (volatile! nil)
payload (volatile! {}) ;; carried across start/delta/end, same as openai.clj
close! (fn [result]
(u/prog1 (rf result (merge {:type (case @current-type
:text :text-end
:function_call :tool-input-available)}
@payload))
(vreset! current-type nil)
(vreset! current-id nil)
(vreset! payload {})))]
(fn
([result]
(cond-> result
@current-type (close!)
true (rf)))
([result {:keys [id model choices usage] :as _chunk}]
(let [choice (first choices)
delta (:delta choice)
finish-reason (:finish_reason choice)
tool-call (first (:tool_calls delta))
;; Determine what kind of content this chunk carries.
;; Empty-string content (common between tool calls) is ignored
;; to avoid spurious text blocks that would close open tools.
chunk-type (cond
(not-empty (:content delta)) :text
(some? tool-call) :function_call
:else nil)
;; For new tool calls, the id comes from the chunk; for deltas
;; on the same tool, we keep current-id.
chunk-id (or (:id tool-call) @current-id (core/mkid))]
(cond-> result
;; Emit :start on first chunk
(and id (not @message-id)) (-> (rf {:type :start :messageId id})
(u/prog1
(vreset! message-id id)
(vreset! model-name model)))
;; Close previous block when type changes, or when a new tool
;; call arrives (different id = different tool in parallel)
(and @current-type
(or (and chunk-type
(not= chunk-type @current-type))
(and (= chunk-type :function_call)
(not= chunk-id @current-id)))) (close!)
;; Start a new text block
(and (= chunk-type :text)
(not= @current-type :text)) (-> (u/prog1
(let [tid (core/mkid)]
(vreset! current-type :text)
(vreset! current-id tid)
(vreset! payload {:id tid})))
(rf (merge {:type :text-start} @payload)))
;; Text delta
(and (= chunk-type :text)
(some? (:content delta))) (rf {:type :text-delta
:id @current-id
:delta (:content delta)})
;; Start a new tool call block
(and (= chunk-type :function_call)
(:id tool-call)
(:name (:function tool-call))) (-> (u/prog1
(vreset! current-type :function_call)
(vreset! current-id (:id tool-call))
(vreset! payload {:toolCallId (:id tool-call)
:toolName (:name (:function tool-call))}))
(rf (merge {:type :tool-input-start} @payload))
;; Emit initial arguments if present
(cond-> (not (str/blank? (:arguments (:function tool-call))))
(rf {:type :tool-input-delta
:toolCallId (:id tool-call)
:inputTextDelta (:arguments (:function tool-call))})))
;; Tool argument delta (continuation of existing tool call)
(and (= chunk-type :function_call)
(not (:id tool-call))
(some? (:arguments (:function tool-call)))) (rf {:type :tool-input-delta
:toolCallId (:toolCallId @payload)
:inputTextDelta (:arguments (:function tool-call))})
;; Finish reason — close whatever is open
(some? finish-reason) (cond->
@current-type (close!))
;; Usage (often on a separate final chunk with empty choices)
(some? usage) (rf {:type :usage
:usage {:promptTokens (:prompt_tokens usage 0)
:completionTokens (:completion_tokens usage 0)}
:id @message-id
:model @model-name}))))))))
;;; HTTP request
(mu/defn openrouter-raw
"Perform a streaming request to the Chat Completions API.
Works with OpenRouter, or any OpenAI-compatible endpoint that supports
`/v1/chat/completions` (e.g. vLLM, Ollama, Together, etc.)."
[{:keys [model system input tools temperature max-tokens tool_choice schema]
:or {model "anthropic/claude-haiku-4-5"}} :- core/LLMRequestOpts]
(when-not (llm/ee-openrouter-api-key)
(throw (ex-info "No OpenRouter API key is set" {:api-error true})))
(let [messages (cond-> (parts->cc-messages input)
system (as-> msgs (into [{:role "system" :content system}] msgs)))
all-tools (or (when schema
;; Structured output: force a tool call with the given JSON schema
[{:type "function"
:function {:name "structured_output"
:description "Output structured data"
:parameters schema}}])
(seq (mapv tool->openai-chat tools)))
req (cond-> {:model model
:stream true
:stream_options {:include_usage true}
:messages messages}
all-tools (assoc :tools (vec all-tools)
:tool_choice (cond
schema "required"
tool_choice tool_choice
:else "auto"))
temperature (assoc :temperature temperature)
max-tokens (assoc :max_tokens max-tokens))]
(log/debug "OpenRouter request" {:model model :msg-count (count messages) :tools (count (or tools []))})
(with-span :info {:name :metabot-v3.openrouter/request
:model model
:msg-count (count messages)
:tool-count (count (or tools []))}
(try
(let [res (http/post (str (llm/ee-openrouter-api-base-url) "/v1/chat/completions")
{:as :stream
:headers {"Authorization" (str "Bearer " (llm/ee-openrouter-api-key))
"Content-Type" "application/json"
"HTTP-Referer" "https://metabase.com"
"X-Title" "Metabase"}
:body (json/encode req)})]
(core/sse-reducible (:body res)))
(catch Exception e
(if-let [res (some-> (ex-data e) json/decode-body)]
(let [status (:status res)
msg (case (int status)
401 "OpenRouter API key expired or invalid"
402 "OpenRouter: insufficient credits"
403 "OpenRouter API key has insufficient permissions"
404 "OpenRouter: model not found or endpoint unavailable"
429 "OpenRouter: rate limited"
500 "OpenRouter: internal server error"
502 "OpenRouter: upstream provider error"
503 "OpenRouter: service unavailable"
(or (-> res :body :error :message)
"Unhandled error accessing OpenRouter API"))]
(throw (ex-info msg (assoc res :api-error true) e)))
(throw e)))))))
(defn openrouter
"Call OpenRouter Chat Completions API, return AISDK stream."
[& args]
(eduction (openrouter->aisdk-chunks-xf) (apply openrouter-raw args)))

View File

@@ -4,7 +4,7 @@
[metabase.util.i18n :refer [deferred-tru]]))
(defsetting ai-service-base-url
(deferred-tru "URL for the a AI Service")
(deferred-tru "URL for the AI Service")
:type :string
:encryption :no
:default "http://localhost:8000"
@@ -67,3 +67,12 @@
:feature :metabot-v3
:export? true
:doc false)
(defsetting use-native-agent
(deferred-tru "Enable native Clojure agent instead of external Python AI Service.")
:type :boolean
:default false
:visibility :internal
:feature :metabot-v3
:export? false
:doc false)

View File

@@ -2,11 +2,16 @@
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.example-question-generator :as native-generator]
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
[metabase-enterprise.metabot-v3.tools.entity-details :as metabot-v3.tools.entity-details]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.lib-be.core :as lib-be]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- column-input
[answer-source-column]
(some-> answer-source-column (select-keys [:name :type :description :table-reference])))
@@ -31,6 +36,30 @@
(def ^:private default-opts
{:limit 20})
(defn- generate-questions-with-fallback
"Generate example questions using the configured path.
When `use-native-agent` is true, calls the native Clojure generator.
On native failure, falls back to the Python ai-service and logs the error.
When `use-native-agent` is false, calls the Python ai-service directly."
[payload]
(if (metabot-v3.settings/use-native-agent)
(try
(log/info "Using native generator for example questions"
{:table-count (count (:tables payload))
:metric-count (count (:metrics payload))})
(let [result (native-generator/generate-example-questions payload)]
(log/info "Native example question generation succeeded"
{:table-results (count (:table_questions result))
:metric-results (count (:metric_questions result))})
result)
(catch Exception e
(log/error e "Native example question generation failed, falling back to Python ai-service"
{:error-class (.getName (class e))
:error-msg (ex-message e)})
(metabot-v3.client/generate-example-questions payload)))
(metabot-v3.client/generate-example-questions payload)))
(defn generate-sample-prompts
"Generate suggested prompts for instance of Metabot."
[metabot-id & {:as opts}]
@@ -51,7 +80,7 @@
metric-inputs (map metric-input metrics)
model-inputs (map model-input models)
{:keys [table_questions metric_questions]}
(metabot-v3.client/generate-example-questions {:metrics metric-inputs, :tables model-inputs})
(generate-questions-with-fallback {:metrics metric-inputs, :tables model-inputs})
->prompt (fn [{:keys [questions]} {::keys [origin]}]
(let [base {:metabot_id metabot-id
:model (:type origin)

View File

@@ -0,0 +1,80 @@
(ns metabase-enterprise.metabot-v3.tmpl
"Simple bag of functions for templating markdown-like strings"
(:require
[clojure.string :as str]))
(defn lines
"Just join lines."
[& elements]
(->> elements
flatten
(remove nil?)
(str/join "\n")))
(defn code
"```{lang}\n{content}\n```"
([content] (code content nil))
([content lang]
(when content
(str "```" (or lang "") "\n" content "\n```\n"))))
(defn field
"`{label}: {value}` or `{label}:\n{value}` if multiline value or skip if no value"
[label value]
(cond
(nil? value) nil
(str/includes? value "\n") (str label ":\n" value)
:else (str label ": " value "\n")))
(defn link
"[{label}]({(join link)})"
[label & bits]
(format "[%s](%s)" label (apply str bits)))
(defn markdown-table
"Render a sequence of maps as a markdown table.
Arguments:
- rows: A sequence of maps/vectors/a map with the data to render
- column-mapping: Either a map of {key \"Header Name\"} or a vector of keys
If nil, uses all keys from first row with title-cased headers
- opts: Optional map with:
- :value-fn - (fn [key value] string) to format cell values.
Defaults to stringifying + escaping pipe characters.
Example:
(markdown-table
[{:name \"foo\" :field_id \"c1\" :type \"string\"}
{:name \"bar\" :field_id \"c2\" :type \"number\"}]
{:name \"Field Name\" :field_id \"Field ID\" :type \"Type\"})
Produces:
| Field Name | Field ID | Type |
| ---------- | -------- | ---- |
| foo | c1 | string |
| bar | c2 | number |"
([rows column-mapping]
(markdown-table rows column-mapping nil))
([rows column-mapping {:keys [value-fn]}]
(when (seq rows)
(let [columns (cond
(nil? column-mapping) (keys (first rows))
(vector? column-mapping) column-mapping
(map? column-mapping) (keys column-mapping))
headers (cond
(nil? column-mapping) (map #(-> % name (str/replace "_" " ") str/capitalize) columns)
(vector? column-mapping) (map #(-> % name (str/replace "_" " ") str/capitalize) column-mapping)
(map? column-mapping) (map #(get column-mapping %) columns))
default-fn (fn [_k v]
(if (nil? v)
""
(str/replace (str v) "|" "\\|")))
fmt (or value-fn default-fn)
mkline #(str "| " (str/join " | " %) " |")
header-row (mkline headers)
separator-row (mkline (map #(apply str (repeat (count %) "-")) headers))
data-rows (for [row rows]
(if (map? row)
(mkline (for [col columns] (fmt col (get row col))))
(mkline (map fmt columns row))))]
(str/join "\n" (concat [header-row separator-row] data-rows))))))

View File

@@ -14,7 +14,6 @@
[metabase-enterprise.metabot-v3.tools.entity-details :as metabot-v3.tools.entity-details]
[metabase-enterprise.metabot-v3.tools.field-stats :as metabot-v3.tools.field-stats]
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
[metabase-enterprise.metabot-v3.tools.search :as metabot-v3.tools.search]
[metabase-enterprise.metabot-v3.tools.snippets :as metabot-v3.tools.snippets]
@@ -530,8 +529,9 @@
[:structured_output
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:field_id :string]
[:statistics {:optional true} [:maybe ::statistics]]
[:values {:optional true} [:maybe [:sequential :any]]]]]]
[:value_metadata [:map
[:field_values {:optional true} [:maybe [:sequential :any]]]
[:statistics {:optional true} [:maybe ::statistics]]]]]]]
[:map
[:output :string]]])
@@ -541,42 +541,6 @@
:result-schema ::field-values-result
:handler metabot-v3.tools.field-stats/field-values})
(mr/def ::find-outliers-arguments
[:and
[:map
[:data_source [:and
[:or
[:map
[:query [:map
[:database :int]]]
[:query_id {:optional true} :string]
[:result_field_id :string]]
[:map
[:metric_id :int]]
[:map
[:report_id :int]
[:result_field_id :string]]
[:map
[:table_id :string]
[:result_field_id :string]]]
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]]]]
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
(mr/def ::find-outliers-result
[:or
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:sequential
[:map
[:dimension :any]
[:value [:or :int :double]]]]]]
[:map [:output :string]]])
(deftool "/find-outliers"
"Find outliers in the values provided by a data source for a given column."
{:args-schema ::find-outliers-arguments
:result-schema ::find-outliers-result
:handler metabot-v3.tools.find-outliers/find-outliers})
(mr/def ::generate-insights-arguments
[:map
;; query should not be changed to kebab-case
@@ -923,7 +887,7 @@
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
(deftool "/filter-records"
"Construct a query from a metric."
"Apply filters to records from a data source (query, report, or table)."
{:args-schema ::filter-records-arguments
:result-schema ::filtering-result
:handler metabot-v3.tools.filters/filter-records})

View File

@@ -0,0 +1,30 @@
(ns metabase-enterprise.metabot-v3.tools.ask-clarification
"Tool for asking users clarification questions about SQL query requests.
This is an 'external' tool that signals the agent should stop and wait for user input."
(:require
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(defn ask-for-sql-clarification
"Ask the user for clarification about their SQL query request.
This tool is used when the agent needs more information from the user
to properly complete their SQL request. It returns a final response
that stops the agent loop and waits for user input.
Parameters:
- question: The clarification question to ask the user
- options: Optional list of suggested answers/choices
Returns map with:
- :structured-output - The question and options for the LLM
- :final-response? - Signals that the agent should stop and wait for user input
- :instructions - Instructions for the LLM"
[{:keys [question options]}]
(log/info "Asking for SQL clarification" {:question question
:option-count (count options)})
{:structured-output {:question question
:options (or options [])}
:final-response? true
:instructions "The clarification question has been presented to the user. Stop and wait for their response before continuing."})

View File

@@ -0,0 +1,118 @@
(ns metabase-enterprise.metabot-v3.tools.create-autogenerated-dashboard
"Tool for creating auto-generated dashboards using Metabase's X-ray feature.
X-ray builds complete dashboards with multiple visualizations that showcase
different aspects of data - distributions, breakdowns, trends, and relationships."
(:require
[clojure.string :as str]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
;;; Source Resolution
(defn- resolve-source
"Resolve the source entity from the provided source parameters.
Returns the entity to analyze and the path for navigation."
[{:keys [table_id model_id metric_id query_id report_id]} memory]
(cond
table_id
(let [table (t2/select-one :model/Table :id table_id)]
(when-not table
(throw (ex-info (str "Table not found with ID: " table_id)
{:agent-error? true :table-id table_id})))
{:entity table
:path (str "/auto/dashboard/table/" table_id)})
model_id
(let [model (t2/select-one :model/Card :id model_id :type :model)]
(when-not model
(throw (ex-info (str "Model not found with ID: " model_id)
{:agent-error? true :model-id model_id})))
{:entity model
:path (str "/auto/dashboard/model/" model_id)})
metric_id
(let [metric (t2/select-one :model/Card :id metric_id :type :metric)]
(when-not metric
(throw (ex-info (str "Metric not found with ID: " metric_id)
{:agent-error? true :metric-id metric_id})))
{:entity metric
:path (str "/auto/dashboard/metric/" metric_id)})
report_id
(let [report (t2/select-one :model/Card :id report_id :type :question)]
(when-not report
(throw (ex-info (str "Report/Question not found with ID: " report_id)
{:agent-error? true :report-id report_id})))
{:entity report
:path (str "/auto/dashboard/question/" report_id)})
query_id
;; For query_id, we need to resolve from memory
(let [queries-state (get-in memory [:state :queries] {})
query (get queries-state query_id)]
(when-not query
(throw (ex-info (str "Query not found in memory with ID: " query_id
". Available queries: [" (str/join ", " (keys queries-state)) "]")
{:agent-error? true
:query-id query_id
:available-queries (keys queries-state)})))
;; For queries, we create a transient card-like structure
{:entity {:dataset_query query}
:path (str "/auto/dashboard/adhoc/" query_id)})
:else
(throw (ex-info "Invalid source: must specify one of table_id, model_id, metric_id, query_id, or report_id"
{:agent-error? true}))))
;;; Response Messages
(def ^:private response-messages
["Ok, here is a dashboard that may answer it for you."
"I've created a dashboard that may answer your question."
"Here is a dashboard that may help you with your question."
"I've generated a dashboard with insights about your data."])
(defn- random-response-message []
(rand-nth response-messages))
;;; Main Tool Implementation
(defn create-autogenerated-dashboard
"Create an auto-generated dashboard using Metabase's X-ray feature.
X-ray builds complete dashboards with multiple visualizations that showcase
different aspects of your data - distributions, breakdowns by categories,
trends over time, and relationships between fields.
Parameters:
- source: Map specifying what to analyze. One of:
- {:table_id 123} - Create dashboard for a table
- {:model_id 456} - Create dashboard for a model
- {:metric_id 789} - Create dashboard for a metric
- {:query_id \"abc\"} - Create dashboard for query results
- {:report_id 101} - Create dashboard for an existing report/question
- memory-atom: Atom containing agent memory (for query resolution)
Returns map with:
- :structured-output - Message and dashboard info
- :reactions - Navigation action to show the dashboard
- :final-response? - Signals that the agent should stop (user is viewing dashboard)"
[{:keys [source memory-atom]}]
(log/info "Creating autogenerated dashboard" {:source source})
(let [memory (when memory-atom @memory-atom)
{:keys [path]} (resolve-source source memory)
response-message (str (random-response-message) "\n\nLet me know if you need anything else.")]
;; Note: The actual automagic analysis happens on navigation to the /auto/dashboard/... URL
;; The frontend handles rendering the X-ray dashboard
(log/info "Autogenerated dashboard created" {:path path})
{:structured-output {:message response-message
:path path}
:reactions [{:type :metabot.reaction/redirect :url path}]
:final-response? true
:instructions "The dashboard has been created and the user has been navigated to view it. Do not describe the dashboard contents."}))

View File

@@ -0,0 +1,100 @@
(ns metabase-enterprise.metabot-v3.tools.create-chart
"Tool for creating charts from queries."
(:require
[buddy.core.codecs :as codecs]
[clojure.string :as str]
[metabase.lib.core :as lib]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:private valid-chart-types
"Valid chart types supported by Metabase."
#{:table :bar :line :pie :sunburst :area :combo :row :pivot
:scatter :waterfall :sankey :scalar :smartscalar :gauge
:progress :funnel :object :map})
(defn- query->url-hash
"Convert an MLv2/MBQL query to a base64-encoded URL hash."
[query]
#_{:clj-kondo/ignore [:discouraged-var]}
(let [dataset-query (if (and (map? query) (:lib/type query))
(lib/->legacy-MBQL query)
query)]
(-> {:dataset_query dataset-query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str)))
(defn- format-chart-for-llm
"Format chart data as XML for LLM consumption."
[{:keys [chart-id query-id chart-type]}]
(str "<chart id=\"" chart-id "\">\n"
"<query-id>" query-id "</query-id>\n"
"<visualization>{\"chart_type\": \"" (name chart-type) "\"}</visualization>\n"
"</chart>"))
(defn- format-chart-link
"Format a metabase:// link to the chart."
[chart-id]
(str "metabase://chart/" chart-id))
(defn create-chart
"Create a chart from a query.
Parameters:
- query-id: ID of the query to visualize
- chart-type: Type of chart to create (e.g., :bar, :line, :pie)
- queries-state: Map of query-id to query data from agent state
Returns a map with:
- :chart-id - Unique ID for the chart
- :chart-content - XML representation of the chart
- :chart-link - Metabase link to the chart
- :chart-type - Type of chart created
- :query-id - ID of the source query
- :reactions - Navigation action to show the chart"
[{:keys [query-id chart-type queries-state]}]
(log/info "Creating chart" {:query-id query-id
:chart-type chart-type
:available-queries (keys queries-state)})
;; Validate chart type
(when-not (contains? valid-chart-types chart-type)
(throw (ex-info (str "Invalid chart type: " (name chart-type)
". Valid types are: " (pr-str valid-chart-types))
{:agent-error? true
:chart-type chart-type})))
;; Look up query from state - try both string and original key
(let [query (or (get queries-state query-id)
(get queries-state (str query-id)))]
(when-not query
(throw (ex-info (str "Query not found with ID: " query-id
". Available queries: [" (str/join ", " (keys queries-state)) "]. "
"Make sure you're using a query ID from a previous query_model or query_metric result.")
{:agent-error? true
:query-id query-id
:available-queries (keys queries-state)})))
;; Create the chart and generate navigation URL
(let [chart-id (str (random-uuid))
results-url (str "/question#" (query->url-hash query))
chart-data {:chart-id chart-id
:query-id query-id
:chart-type chart-type}]
(log/info "Created chart" {:chart-id chart-id
:chart-type chart-type
:results-url results-url})
{:chart-id chart-id
:chart-content (format-chart-for-llm chart-data)
:chart-link (format-chart-link chart-id)
:chart-type chart-type
:query-id query-id
:instructions (str "Chart created successfully. The user is now viewing the chart.\n"
"Reference the chart using: [Chart](" (format-chart-link chart-id) ") "
"where 'Chart' is a meaningful description.")
:reactions [{:type :metabot.reaction/redirect :url results-url}]})))

View File

@@ -0,0 +1,70 @@
(ns metabase-enterprise.metabot-v3.tools.create-sql-query
"Tool for creating new SQL queries."
(:require
[metabase-enterprise.metabot-v3.tools.sql.common :as metabot-v3.tools.sql.common]
[metabase-enterprise.metabot-v3.tools.sql.validation :as metabot-v3.tools.sql.validation]
[metabase.api.common :as api]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- create-native-query
"Create a native (SQL) query structure."
[database-id sql-content]
(let [mp (lib-be/application-database-metadata-provider database-id)]
(-> (lib/native-query mp sql-content)
;; Going forward we should ensure consumers are fine processing MBQL 5.
^{:clj-kondo/ignore [:discouraged-var]}
lib/->legacy-MBQL)))
(defn- validate-database-access
"Check if the current user has access to the database."
[database-id]
(when-not (t2/exists? :model/Database :id database-id)
(throw (ex-info (tru "Database {0} not found" database-id)
{:agent-error? true
:database-id database-id})))
(api/read-check :model/Database database-id))
(mu/defn create-sql-query :- ::metabot-v3.tools.sql.common/operation-result
"Create a new SQL query in memory.
Parameters:
- database-id: ID of the database to query
- sql: The SQL query string
- name: Name for the query (optional)
- description: Description for the query (optional)
- collection-id: Collection to save the query in (optional, defaults to user's personal collection)
Returns a map with:
- :query-id - The ID of the created query
- :query-content - The SQL content
- :database - Database ID"
[{:keys [database-id sql name]}]
(log/info "Creating SQL query"
{:database-id database-id
:name name
:sql-length (count sql)})
;; Validate access
(validate-database-access database-id)
(let [dialect (metabot-v3.tools.sql.validation/database-id->dialect database-id)
{:keys [valid? transpiled-sql] :as validation-result}
(metabot-v3.tools.sql.validation/validate-sql dialect sql)]
(merge {:validation-result validation-result}
(when valid?
(let [;; Create the in-memory query structure
dataset-query (create-native-query database-id transpiled-sql)
query-id (u/generate-nano-id)]
{:action-result {:query-id query-id
:query-content transpiled-sql
:query dataset-query
:database database-id}})))))

View File

@@ -0,0 +1,86 @@
(ns metabase-enterprise.metabot-v3.tools.edit-chart
"Tool for editing chart visualization settings."
(:require
[metabase.util.log :as log]))
(def ^:private valid-chart-types
"Valid chart types supported by Metabase."
#{:table :bar :line :pie :sunburst :area :combo :row :pivot
:scatter :waterfall :sankey :scalar :smartscalar :gauge
:progress :funnel :object :map})
(defn- format-chart-for-llm
"Format chart data as XML for LLM consumption."
[{:keys [chart-id query-id chart-type query-content]}]
(str "<chart id=\"" chart-id "\">\n"
"The chart is powered by the following query:\n"
"<query id=\"" query-id "\">\n"
query-content "\n"
"</query>\n"
"<visualization>{\"chart_type\": \"" (name chart-type) "\"}</visualization>\n"
"</chart>"))
(defn- format-chart-link
"Format a metabase:// link to the chart."
[chart-id]
(str "metabase://chart/" chart-id))
(defn edit-chart
"Edit an existing chart's visualization settings.
Parameters:
- chart-id: ID of the chart to edit
- new-chart-type: New chart type to use
- charts-state: Map of chart-id to chart data from agent state
Returns a map with:
- :chart-id - New unique ID for the edited chart
- :chart-content - XML representation of the chart
- :chart-link - Metabase link to the chart
- :chart-type - Type of chart created
- :query-id - ID of the source query"
[{:keys [chart-id new-chart-type charts-state]}]
(log/info "Editing chart" {:chart-id chart-id :new-chart-type new-chart-type})
;; Validate chart type
(when-not (contains? valid-chart-types new-chart-type)
(throw (ex-info (str "Invalid chart type: " (name new-chart-type)
". Valid types are: " (pr-str valid-chart-types))
{:agent-error? true
:chart-type new-chart-type})))
;; Look up chart from state
(let [chart-data (get charts-state (str chart-id))]
(when-not chart-data
(throw (ex-info "Sorry, I have issues accessing the chart data. Is there anything else I can help you with?"
{:agent-error? true
:chart-id chart-id})))
(when-not (:query-id chart-data)
(throw (ex-info "Sorry, I have issues accessing the chart data. Is there anything else I can help you with?"
{:agent-error? true
:chart-id chart-id})))
;; Create the new chart with updated settings
(let [new-chart-id (str (random-uuid))
query-id (:query-id chart-data)
query-content (or (:query-content chart-data) "")
new-chart-data {:chart-id new-chart-id
:query-id query-id
:chart-type new-chart-type
:query-content query-content}]
(log/info "Edited chart" {:old-chart-id chart-id
:new-chart-id new-chart-id
:new-chart-type new-chart-type})
{:chart-id new-chart-id
:chart-content (format-chart-for-llm new-chart-data)
:chart-link (format-chart-link new-chart-id)
:chart-type new-chart-type
:query-id query-id
:instructions (str "Chart has been created successfully.\n\n"
"Next steps to present the chart to the user:\n"
"- Always provide a direct link using: `[Chart](" (format-chart-link new-chart-id) ")` "
"where Chart is a meaningful link text\n"
"- If creating multiple charts, present all chart links")})))

View File

@@ -0,0 +1,89 @@
(ns metabase-enterprise.metabot-v3.tools.edit-sql-query
"Tool for editing existing SQL queries."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.sql.common :as metabot-v3.tools.sql.common]
[metabase-enterprise.metabot-v3.tools.sql.validation :as metabot-v3.tools.sql.validation]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- extract-sql-content
"Extract SQL content from a dataset_query map.
Handles both legacy format and lib/query format."
[query]
(or
;; Try lib/query format (with stages)
(get-in query [:stages 0 :native])
;; Try legacy format
(get-in query [:native :query])))
(defn- apply-sql-edit
"Apply a targeted string replacement to SQL content."
[sql {:keys [old_string new_string replace_all]}]
(when-not (and (string? old_string) (string? new_string))
(throw (ex-info (tru "Each edit must include old_string and new_string")
{:agent-error? true})))
(let [pattern (re-pattern (java.util.regex.Pattern/quote old_string))
occurrences (count (re-seq pattern sql))]
(cond
(zero? occurrences)
(throw (ex-info (tru "Text to replace not found: {0}" old_string)
{:agent-error? true
:old old_string}))
(and (> occurrences 1) (not replace_all))
(throw (ex-info (tru "Text to replace found multiple times: {0}" old_string)
{:agent-error? true
:old old_string
:occurrences occurrences}))
replace_all
(str/replace sql old_string new_string)
:else
(str/replace-first sql old_string new_string))))
(mu/defn edit-sql-query :- ::metabot-v3.tools.sql.common/operation-result
"Edit an existing SQL query from in-memory state.
Parameters:
- query-id: ID of the query to edit
- edit: Edit specification map (see apply-sql-edit for format)
- name: New name for the query (optional)
- description: New description for the query (optional)
Returns an `operation-result` map. For details see its docstring."
[{:keys [query-id edits queries-state]}]
(log/info "Editing SQL query" {:query-id query-id :edit-count (count edits)})
;; Look up query from in-memory state
(let [query-id (str query-id)
query (get queries-state query-id)]
(when-not query
(throw (ex-info (tru "Query {0} not found" query-id)
{:agent-error? true
:query-id query-id
:available-queries (keys queries-state)})))
(let [current-sql (extract-sql-content query)]
(when-not current-sql
(throw (ex-info (tru "Query {0} is not a SQL query" query-id)
{:agent-error? true
:query-id query-id})))
(let [;; Apply edits sequentially
new-sql (reduce apply-sql-edit current-sql edits)
dialect (metabot-v3.tools.sql.validation/query->dialect query)
{:keys [valid? transpiled-sql] :as validation-result}
(metabot-v3.tools.sql.validation/validate-sql dialect new-sql)]
(merge {:validation-result validation-result}
(when valid?
(let [updated-query (metabot-v3.tools.sql.common/update-query-sql query transpiled-sql)]
{:action-result {:query-id query-id
:query-content transpiled-sql
:query updated-query
:database (:database query)}})))))))

View File

@@ -336,7 +336,8 @@
detail (cards-details card-type database-id cards options)]
detail)
(group-by :type))]
{:structured-output {:metrics (vec metrics)
{:structured-output {:result-type :answer-sources
:metrics (vec metrics)
:models (vec models)}}))
(throw (ex-info (i18n/tru "Invalid metabot_id {0}" metabot-id)
{:metabot_id metabot-id, :status-code 400}))))
@@ -375,7 +376,7 @@
:else
(throw (ex-info "Invalid arguments: must provide table_id or model_id"
{:agent-error? true :status-code 400})))]
{:structured-output details}))
{:structured-output (assoc details :result-type :entity)}))
(catch Exception e
(if (= (:status-code (ex-data e)) 404)
{:output (ex-message e) :status-code 404}
@@ -392,7 +393,7 @@
(metric-details metric-id options)
(throw (ex-info "Invalid metric_id format"
{:agent-error? true :status-code 400})))]
{:structured-output details}))
{:structured-output (assoc details :result-type :entity)}))
(catch Exception e
(if (= (:status-code (ex-data e)) 404)
{:output (ex-message e) :status-code 404}
@@ -412,7 +413,7 @@
(assoc :result-columns (:fields details))))
(throw (ex-info "Invalid report_id format"
{:agent-error? true :status-code 400})))]
{:structured-output details}))
{:structured-output (assoc details :result-type :entity)}))
(catch Exception e
(if (= (:status-code (ex-data e)) 404)
{:output (ex-message e) :status-code 404}
@@ -424,7 +425,8 @@
(if (int? document-id)
(try
(if-let [doc (documents/get-document document-id)]
{:structured-output {:id (:id doc)
{:structured-output {:result-type :entity
:id (:id doc)
:name (:name doc)
:document (:document doc)}}
{:output "document not found"})
@@ -452,4 +454,5 @@
"Get the details of a query (supports both MBQL v4 and v5)."
[{:keys [query]}]
(lib-be/with-metadata-provider-cache
{:structured-output (execute-query (u/generate-nano-id) query)}))
{:structured-output (assoc (execute-query (u/generate-nano-id) query)
:result-type :query)}))

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.metabot-v3.tools.field-stats
(:require
[clojure.set :as set]
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.lib.core :as lib]
[metabase.parameters.field-values :as params.field-values]
@@ -15,7 +16,7 @@
(set/rename-keys {:nil% :percent-null})
(into (vals (:type fp))))})
(when-let [fvs (-> fvs :values not-empty)]
{:values (into [] (if limit (take limit) identity) fvs)})))
{:field_values (into [] (if limit (take limit) identity) fvs)})))
(defn- get-or-create-fingerprint! [{:keys [id fingerprint] :as field}]
(or fingerprint
@@ -35,39 +36,55 @@
(defn- table-field-stats
[table-id agent-field-id limit]
(try
(let [query (or (metabot-v3.tools.u/table-query table-id)
(throw (ex-info (str "No table found with ID " table-id)
{:agent-error? true :status-code 404})))
field-id-prefix (metabot-v3.tools.u/table-field-id-prefix table-id)
visible-cols (lib/visible-columns query)
col (:column (metabot-v3.tools.u/resolve-column {:field-id agent-field-id} field-id-prefix visible-cols))]
{:structured-output (field-statistics col limit)})
(let [field-id-prefix (metabot-v3.tools.u/table-field-id-prefix table-id)
;; When the field ID belongs to a different table (e.g., agent saw a related field
;; listed under table 111 as t111-21 but requests it via metabase://table/173/fields/t111-21),
;; resolve against the correct table from the field ID prefix.
effective-table-id (if (str/starts-with? agent-field-id field-id-prefix)
table-id
(let [parsed (metabot-v3.tools.u/parse-field-id agent-field-id)]
(if (and parsed (= "t" (:model-tag parsed)) (:model-id parsed))
(:model-id parsed)
table-id)))
query (or (metabot-v3.tools.u/table-query effective-table-id)
(throw (ex-info (str "No table found with ID " effective-table-id)
{:agent-error? true :status-code 404})))
eff-prefix (metabot-v3.tools.u/table-field-id-prefix effective-table-id)
visible-cols (lib/visible-columns query)
col (:column (metabot-v3.tools.u/resolve-column {:field-id agent-field-id} eff-prefix visible-cols))]
{:structured-output {:result-type :field-metadata
:field_id agent-field-id
:value_metadata (field-statistics col limit)}})
(catch Exception ex
(metabot-v3.tools.u/handle-agent-error ex))))
(defn- card-field-stats
[card-id agent-field-id limit card-type]
(try
(let [query (or (metabot-v3.tools.u/card-query card-id)
(throw (ex-info (str "No " card-type " found with ID " card-id)
{:agent-error? true :status-code 404})))
(let [query (or (metabot-v3.tools.u/card-query card-id)
(throw (ex-info (str "No " card-type " found with ID " card-id)
{:agent-error? true :status-code 404})))
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix card-id)
visible-cols (lib/visible-columns query)
col (:column (metabot-v3.tools.u/resolve-column {:field-id agent-field-id} field-id-prefix visible-cols))]
{:structured-output (field-statistics col limit)})
visible-cols (lib/visible-columns query)
col (:column (metabot-v3.tools.u/resolve-column {:field-id agent-field-id} field-id-prefix visible-cols))]
{:structured-output {:result-type :field-metadata
:field_id agent-field-id
:value_metadata (field-statistics col limit)}})
(catch Exception ex
(metabot-v3.tools.u/handle-agent-error ex))))
(defn- metric-field-stats
[metric-id agent-field-id limit]
(try
(let [query (or (metabot-v3.tools.u/metric-query metric-id)
(throw (ex-info (str "No metric found with ID " metric-id)
{:agent-error? true :status-code 404})))
(let [query (or (metabot-v3.tools.u/metric-query metric-id)
(throw (ex-info (str "No metric found with ID " metric-id)
{:agent-error? true :status-code 404})))
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix metric-id)
filterable-cols (lib/filterable-columns query)
col (:column (metabot-v3.tools.u/resolve-column {:field-id agent-field-id} field-id-prefix filterable-cols))]
{:structured-output (field-statistics col limit)})
col (:column (metabot-v3.tools.u/resolve-column {:field-id agent-field-id} field-id-prefix filterable-cols))]
{:structured-output {:result-type :field-metadata
:field_id agent-field-id
:value_metadata (field-statistics col limit)}})
(catch Exception ex
(metabot-v3.tools.u/handle-agent-error ex))))

View File

@@ -1,5 +1,8 @@
(ns metabase-enterprise.metabot-v3.tools.filters
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.lib-be.core :as lib-be]
@@ -8,7 +11,32 @@
[metabase.lib.types.isa :as lib.types.isa]
[metabase.lib.util :as lib.util]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]))
[metabase.util.i18n :refer [tru]]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; URL Generation for Auto-Navigation
(defn- query->url-hash
"Convert an MLv2/MBQL query to a base64-encoded URL hash."
[query]
#_{:clj-kondo/ignore [:discouraged-var]}
(let [dataset-query (if (and (map? query) (:lib/type query))
(lib/->legacy-MBQL query)
query)]
(-> {:dataset_query dataset-query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str)))
(defn- query->results-url
"Convert a query to a /question# URL for navigation."
[query]
(str "/question#" (query->url-hash query)))
;;; Filter Operations
(defn- apply-filter-bucket
[column bucket]
@@ -74,6 +102,178 @@
(lib.types.isa/temporal? column))
(apply-bucket bucket)))
;;; Temporal bucket metadata
;;
;; Extraction buckets (e.g., :year-of-era) extract an integer component from a
;; date/datetime. Truncation buckets (e.g., :day) round to a boundary and expect
;; date/datetime string values.
;;
;; This metadata drives both coercion (date-string → integer for extraction buckets)
;; and validation (range checks, rejecting negative/numeric values for truncation
;; buckets). It is used by [[coerce-and-validate-temporal-filter]].
(def ^:private extraction-bucket-specs
"Extraction bucket → `{:min :max :hint}`.
Filter values for extraction buckets must be integers (or integer-strings).
Date strings are rejected — the LLM should filter date fields directly without a bucket
for date range comparisons."
{:year-of-era {}
:quarter-of-year {:min 1 :max 4 :hint "quarter-of-year expects 1-4"}
:month-of-year {:min 1 :max 12 :hint "month-of-year expects 1-12"}
:week-of-year {:min 1 :max 53 :hint "week-of-year expects 1-53"}
:day-of-month {:min 1 :max 31 :hint "day-of-month expects 1-31"}
:day-of-week {:min 1 :max 7 :hint "day-of-week expects 1-7 (1=Monday, 7=Sunday)"}
:hour-of-day {:min 0 :max 23 :hint "hour-of-day expects 0-23"}
:minute-of-hour {:min 0 :max 59 :hint "minute-of-hour expects 0-59"}
:second-of-minute {:min 0 :max 59 :hint "second-of-minute expects 0-59"}})
(def ^:private truncation-buckets
"Temporal buckets that truncate a date/datetime to a boundary (e.g., `:day` truncates to midnight).
Filter values for these buckets should be date/datetime strings, not relative integers."
#{:millisecond :second :minute :hour :day :week :month :quarter :year :day-of-year})
(defn- agent-error!
"Throw an `:agent-error?` exception (returned to the LLM so it can retry)."
[msg]
(throw (ex-info msg {:agent-error? true :status-code 400})))
(defn- coerce-extraction-value
"Coerce a single filter value to an integer for an extraction bucket.
Parses integer strings but rejects date strings — extraction buckets require plain
integers, not dates. If the LLM passes a date string like \"2025-09-01\" with bucket
\"day-of-month\", it almost certainly meant to filter the date field directly without
a bucket (date range comparison), not extract the day component.
Validates that the result is within the expected range."
[v {:keys [min max hint] :as _spec} bucket]
(letfn [(validate-range [n]
(when (neg? n)
(agent-error!
(str "Filter value " n " cannot be negative for temporal bucket '" (name bucket) "'. "
"Temporal buckets extract components from dates (e.g., month-of-year extracts 1-12). "
"If you need to filter relative to today (e.g., 'last 60 days'), don't use temporal "
"buckets — filter the date field directly with a date range instead.")))
(when (and min max (not (<= min n max)))
(agent-error! (str "Filter value " n " is out of range for bucket '" (name bucket) "'. " hint ".")))
n)]
(cond
(number? v)
(validate-range v)
(string? v)
(try
(validate-range (Integer/parseInt v))
(catch NumberFormatException _
(agent-error!
(str "Filter with temporal bucket '" (name bucket) "' requires an integer value, "
"got: \"" v "\". For example, 'day-of-month' expects 15, not \"2024-01-15\". "
"If you need to filter by a date range (e.g., 'last 30 days'), don't use a "
"temporal bucket — filter the date field directly with operation "
"'greater-than-or-equal' and value '2025-01-15' (no bucket)."))))
:else v)))
(defn- validate-truncation-value
"Reject numeric values for truncation buckets — they need date strings."
[v bucket]
(when (and (number? v) (or (neg? v) (< v 100)))
(agent-error!
(str "Filter value " v " is not valid for temporal bucket '" (name bucket) "'. "
"Temporal buckets like '" (name bucket) "' require date/datetime string values "
"(e.g., \"2024-01-15\"), not relative numbers. "
"To filter for 'last 30 days', use a direct date comparison without a bucket, "
"e.g., operation 'greater-than-or-equal' with value '2025-01-15' (the actual date)."))))
(defn coerce-and-validate-temporal-filter
"Coerce and validate temporal filter values based on the bucket type.
For extraction buckets (e.g., `:year-of-era`): coerce date-string values to
integers and validate the range.
For truncation buckets (e.g., `:day`): reject numeric values that indicate
the LLM is trying to use relative date math.
Works on normalized filters (keyword keys and keyword buckets).
Called from [[add-filter]].
See also [[decode-temporal-filter]] for the raw (pre-normalization) variant
used in Malli schema decode transforms."
[{:keys [value values bucket] :as llm-filter}]
(if-let [spec (extraction-bucket-specs bucket)]
;; Extraction bucket — coerce and validate values
(let [coerce-val #(coerce-extraction-value % spec bucket)]
(cond-> llm-filter
value (update :value coerce-val)
values (update :values (partial mapv coerce-val))))
;; Truncation bucket — validate values aren't numeric nonsense
(if (truncation-buckets bucket)
(do (doseq [v (or values (some-> value vector))]
(validate-truncation-value v bucket))
llm-filter)
;; No bucket or unknown — pass through
llm-filter)))
(defn decode-temporal-filter
"Decode a raw (pre-normalization) filter map for Malli schema `:decode/tool` transforms.
Operates on string keys (`\"bucket\"`, `\"value\"`, `\"values\"`) as they arrive from
the LLM's JSON, coercing and validating temporal filter values.
Example usage in a Malli schema:
[:map {:decode/tool filters/decode-temporal-filter}
[:bucket {:optional true} [:maybe :string]]
[:value {:optional true} :any]]"
[m]
(let [bucket-str (or (get m "bucket") (get m :bucket))
bucket-kw (when bucket-str (keyword bucket-str))]
(if-not bucket-kw
m
(let [v-key (if (contains? m "value") "value" :value)
vs-key (if (contains? m "values") "values" :values)
as-norm {:bucket bucket-kw
:value (get m v-key)
:values (get m vs-key)}
coerced (coerce-and-validate-temporal-filter as-norm)]
(cond-> m
(get m v-key) (assoc v-key (:value coerced))
(get m vs-key) (assoc vs-key (:values coerced)))))))
(def ^:private temporal-extraction-operations
"Operations that extract an integer component from a date/datetime field (e.g. month 1-12).
These operations inherently expect small integer values, so they must be exempt from the
validation that rejects small integers on temporal columns."
#{:year-equals :year-not-equals
:quarter-equals :quarter-not-equals
:month-equals :month-not-equals
:day-of-week-equals :day-of-week-not-equals
:hour-equals :hour-not-equals
:minute-equals :minute-not-equals
:second-equals :second-not-equals})
(defn- validate-temporal-column-values
"Reject clearly-wrong numeric values on temporal columns when no bucket is specified.
Negative numbers and small integers (< 100) on a datetime field are almost certainly the
LLM trying to express relative dates (e.g., -30 for 'last 30 days').
This validation requires the resolved `:column` metadata, so it runs in [[add-filter]]
rather than in the Malli decode layer.
Skips validation for temporal extraction operations (e.g. `month-equals`, `year-equals`)
because those operations explicitly expect small integer values."
[{:keys [value values bucket operation column] :as llm-filter}]
(when (and (nil? bucket)
column
(lib.types.isa/temporal? column)
(not (temporal-extraction-operations operation)))
(doseq [v (or values (some-> value vector))]
(when (and (number? v) (or (neg? v) (< v 100)))
(agent-error!
(str "Filter value " v " is not valid for a date/datetime field. "
"Date fields require date/datetime string values (e.g., \"2024-01-15\"), "
"not relative numbers. "
"To filter for 'last 30 days', compute the actual date and use "
"operation 'greater-than-or-equal' with value '2025-01-15' (the actual date).")))))
llm-filter)
(defn- add-filter
[query llm-filter]
(if-let [segment-id (:segment-id llm-filter)]
@@ -85,7 +285,10 @@
:status-code 404
:segment-id segment-id})))
;; Standard field-based filter logic
(let [{:keys [operation value values]} llm-filter
(let [llm-filter (-> llm-filter
coerce-and-validate-temporal-filter
validate-temporal-column-values)
{:keys [operation value values]} llm-filter
expr (filter-bucketed-column llm-filter)
with-values-or-value (fn with-values-or-value
([f]
@@ -181,11 +384,16 @@
returned-cols)}))
(defn query-metric
"Create a query based on a metric."
"Create a query based on a metric.
Returns structured output with the query and a navigate_to data part."
[{:keys [metric-id] :as arguments}]
(try
(if (int? metric-id)
{:structured-output (query-metric* arguments)}
(let [result (query-metric* arguments)
results-url (query->results-url (:query result))]
{:structured-output (assoc result :result-type :query)
:instructions (instructions/query-created-instructions-for (:query-id result))
:data-parts [(streaming/navigate-to-part results-url)]})
(throw (ex-info (str "Invalid metric_id " metric-id)
{:agent-error? true :status-code 400})))
(catch Exception e
@@ -219,11 +427,15 @@
(lib/count)
(let [expr (bucketed-column aggregation)]
(case (:function aggregation)
:count-distinct (lib/distinct expr)
(:count-distinct :distinct) (lib/distinct expr)
:sum (lib/sum expr)
:min (lib/min expr)
:max (lib/max expr)
:avg (lib/avg expr))))]
:avg (lib/avg expr)
(throw (ex-info (str "Unsupported aggregation function: " (:function aggregation)
". Supported: count, count-distinct, sum, min, max, avg")
{:agent-error? true
:status-code 400})))))]
(lib/aggregate query agg-expr)))]
(apply-aggregation-sort-order query-with-aggregation sort-order)))
@@ -266,6 +478,10 @@
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix model-id)
visible-cols (lib/visible-columns base-query)
resolve-visible-column #(metabot-v3.tools.u/resolve-column % field-id-prefix visible-cols)
_ (log/debug "query_model field-id expectations"
{:model-id model-id
:field-id-prefix field-id-prefix
:field-ids (vec (keep :field-id (concat fields filters group-by)))})
resolve-order-by-column (fn [{:keys [field direction]}] {:field (resolve-visible-column field) :direction direction})
projection (map (comp (juxt filter-bucketed-column (fn [{:keys [column bucket]}]
(let [column (cond-> column
@@ -297,11 +513,16 @@
returned-cols)}))
(defn query-model
"Create a query based on a model."
"Create a query based on a model.
Returns structured output with the query and a navigate_to data part."
[{:keys [model-id] :as arguments}]
(try
(if (int? model-id)
{:structured-output (query-model* arguments)}
(let [result (query-model* arguments)
results-url (query->results-url (:query result))]
{:structured-output (assoc result :result-type :query)
:instructions (instructions/query-created-instructions-for (:query-id result))
:data-parts [(streaming/navigate-to-part results-url)]})
(throw (ex-info (str "Invalid model_id " model-id)
{:agent-error? true :status-code 400})))
(catch Exception e
@@ -375,27 +596,18 @@
[{:keys [table-id model-id] :as arguments}]
(try
(cond
(and table-id model-id)
(throw (ex-info "Cannot provide both table_id and model_id"
{:agent-error? true :status-code 400}))
(int? model-id)
{:structured-output (query-datasource* arguments)}
(int? table-id)
{:structured-output (query-datasource* arguments)}
model-id
(throw (ex-info (str "Invalid model_id " model-id)
{:agent-error? true :status-code 400}))
table-id
(throw (ex-info (str "Invalid table_id " table-id)
{:agent-error? true :status-code 400}))
:else
(throw (ex-info "Either table_id or model_id must be provided"
{:agent-error? true :status-code 400})))
(and table-id model-id) (throw (ex-info "Cannot provide both table_id and model_id"
{:agent-error? true :status-code 400}))
(int? model-id) {:structured-output (-> (query-datasource* arguments)
(assoc :result-type :query))}
(int? table-id) {:structured-output (-> (query-datasource* arguments)
(assoc :result-type :query))}
model-id (throw (ex-info (str "Invalid model_id " model-id)
{:agent-error? true :status-code 400}))
table-id (throw (ex-info (str "Invalid table_id " table-id)
{:agent-error? true :status-code 400}))
:else (throw (ex-info "Either table_id or model_id must be provided"
{:agent-error? true :status-code 400})))
(catch Exception e
(if (= (:status-code (ex-data e)) 404)
{:output (ex-message e) :status-code 404}
@@ -452,7 +664,8 @@
query-id (u/generate-nano-id)
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)]
{:structured-output
{:type :query
{:result-type :query
:type :query
:query-id query-id
:query query
:result-columns (into []

View File

@@ -1,79 +0,0 @@
(ns metabase-enterprise.metabot-v3.tools.find-outliers
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.lib.types.isa :as lib.types.isa]
[metabase.query-processor :as qp]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn- checked-card-dataset-query
[card-id]
(-> (t2/select-one [:model/Card :collection_id :dataset_query] card-id)
api/read-check
:dataset_query))
(defn- find-dataset-query
[{:keys [query report-id metric-id] :as data-source}]
(cond
metric-id (if (int? metric-id)
(checked-card-dataset-query metric-id)
(throw (ex-info "Invalid metric_id as data_source" {:agent-error? true
:data-source data-source})))
report-id (if (int? report-id)
(checked-card-dataset-query report-id)
(throw (ex-info "Invalid report_id as data_source" {:agent-error? true
:data-source data-source})))
query (do (api/read-check :model/Database (:database query))
query)
:else (throw (ex-info "Invalid data_source" {:agent-error? true
:data-source data-source}))))
(defn find-outliers
"Find outliers in the values provided by `data-source` for a given column."
[{:keys [data-source]}]
(let [{:keys [metric-id result-field-id]} data-source]
(try
(let [dataset-query (find-dataset-query data-source)
{:keys [data]} (u/prog1 (-> dataset-query
(qp/userland-query-with-default-constraints {:context :ad-hoc})
qp/process-query)
(when-not (= :completed (:status <>))
(throw (ex-info "Unexpected error running query" {:agent-error? true
:status (:status <>)}))))
dimension-col-idx (or (->> data
:cols
(map-indexed vector)
(m/find-first (fn [[_i col]]
(lib.types.isa/temporal? (u/normalize-map col))))
first)
(throw (ex-info "No temporal dimension found. Outliers can only be detected when a temporal dimension is available."
{:agent-error? true})))
value-col-idx (if metric-id
(or (->> data
:cols
(map-indexed vector)
(m/find-first (fn [[_i col]]
(lib.types.isa/numeric? (u/normalize-map col))))
first)
(throw (ex-info "Could not determine result field."
{:agent-error? true})))
;; Parse field-id and extract the numeric index
(if-let [{:keys [field-index]} (metabot-v3.tools.u/parse-field-id result-field-id)]
field-index
(throw (ex-info (str "Invalid result_field_id format: " result-field-id)
{:agent-error? true
:result-field-id result-field-id}))))]
(when-not (< -1 value-col-idx (-> data :rows first count))
(throw (ex-info (str "Invalid result_field_id " result-field-id)
{:agent-error? true})))
{:structured-output (->> data
:rows
(map (fn [row]
{:dimension (nth row dimension-col-idx)
:value (nth row value-col-idx)}))
(metabot-v3.client/find-outliers-request))})
(catch Exception e
(metabot-v3.tools.u/handle-agent-error e)))))

View File

@@ -0,0 +1,94 @@
(ns metabase-enterprise.metabot-v3.tools.get-metadata
"Tool for retrieving metadata for tables, models, and metrics."
(:require
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details-tools]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:private max-input-ids 5)
(defn- validate-id-count
[ids label]
(when (> (count ids) max-input-ids)
(throw (ex-info (tru "Too many {0} IDs provided ({1}). Limit to {2}."
label (count ids) max-input-ids)
{:agent-error? true
:label label
:count (count ids)}))))
(defn- safe-fetch
[fetch-fn id]
(try
(let [result (fetch-fn id)
structured (:structured-output result)
output (:output result)]
(if structured
{:value structured}
{:error (or output (str "No metadata returned for ID " id))}))
(catch Exception e
(log/error e "Failed to fetch metadata" {:id id})
{:error (or (ex-message e) (str "Failed to fetch metadata for ID " id))})))
(defn get-metadata
"Fetch metadata for tables, models, and metrics.
Returns:
{:structured-output {:tables [...] :models [...] :metrics [...] :errors [...]}}"
[{:keys [table-ids model-ids metric-ids]}]
(try
(doseq [[ids label] [[table-ids "table"] [model-ids "model"] [metric-ids "metric"]]]
(validate-id-count ids label))
(let [table-results (mapv #(safe-fetch
(fn [table-id]
(entity-details-tools/get-table-details
{:table-id table-id
:with-fields? true
:with-field-values? false
:with-related-tables? false
:with-metrics? false
:with-default-temporal-breakout? false
:with-measures? false
:with-segments? false}))
%)
table-ids)
model-results (mapv #(safe-fetch
(fn [model-id]
(entity-details-tools/get-table-details
{:model-id model-id
:with-fields? true
:with-field-values? false
:with-related-tables? false
:with-metrics? false
:with-default-temporal-breakout? false
:with-measures? false
:with-segments? false}))
%)
model-ids)
metric-results (mapv #(safe-fetch
(fn [metric-id]
(entity-details-tools/get-metric-details
{:metric-id metric-id
:with-default-temporal-breakout? false
:with-field-values? false
:with-queryable-dimensions? false
:with-segments? false}))
%)
metric-ids)
tables (->> table-results (keep :value) vec)
models (->> model-results (keep :value) vec)
metrics (->> metric-results (keep :value) vec)
errors (->> (concat table-results model-results metric-results)
(keep :error)
vec)]
{:structured-output {:result-type :metadata
:tables tables
:models models
:metrics metrics
:errors errors}})
(catch Exception e
(log/error e "Failed to fetch metadata")
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to fetch metadata: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,118 @@
(ns metabase-enterprise.metabot-v3.tools.instructions
"Instruction constants for LLM tool results.
These guide the LLM on how to interpret and use tool outputs.
Matches Python AI Service InstructionResultSchema patterns exactly.")
(def search-result-instructions
"Instructions for LLM when processing search results."
"Search results ranked by relevance (highest first).
When using results:
- Prioritize verified dashboards, questions, and metrics over raw data exploration when they match the user's request
- Check available fields or metadata of promising results before recommending them to ensure they contain needed data
- **Be proactive with clear matches**: When the top result(s) have names or descriptions that clearly match the user's request, use them to achieve the users goal first, then list alternatives at the end. Don't ask for confirmation when there's an obvious best choice.
- **Collection metadata**: Results may include collection information (name, description, authority level) that can help you determine whether the result is relevant to the user's query. Use this context to assess relevance.
- **Present options for ambiguous results**: When search returns multiple plausible options without a clear best match, present them and ask the user to choose
- Reference results using the metabase protocol link format: [display name](metabase://type/id)
Examples: [Customer Metrics](metabase://metric/42), [Sales Dashboard](metabase://dashboard/158)")
(def entity-metadata-instructions
"Instructions for LLM when processing entity metadata (tables, models, metrics)."
"The data above contains the metadata for the requested tables, models, and metrics.
Metabot needs to:
- Use this metadata to understand the structure of the data
- Handle ambiguous field names by asking clarifying questions if necessary.
- Always check the actual field values before applying filters to avoid empty results.")
(def field-metadata-instructions
"Instructions for LLM when processing field metadata with sample values."
"The field values above are sample data (like df.sample(n).describe() in pandas) - a
statistical snapshot of a subset of rows, not the complete dataset.
Metabot needs to:
- Use these samples to understand FORMAT patterns: how values are structured (codes vs names,
capitalization, date formats, numeric encodings)
- Understand that samples show rough ranges but not all possible values - the full dataset may
contain data outside these ranges
- ALWAYS build queries even when the user's requested value is not in samples or outside sample
ranges
- For categorical fields: Apply the observed format pattern (e.g., if samples show \"US\", \"DE\",
\"FR\", use ISO code \"AT\" for Austria)
- For date/time fields: Apply the user's requested date even if it falls outside the sample date
range
- For numeric fields: Apply the user's requested number even if it falls outside the sample
numeric range
- For text fields: Search for the user's keywords/phrases even if not seen in sample text
- Disclose in your FINAL message to the user when you used values outside sample ranges or not in
samples, noting that the full dataset may contain data the samples did not show
- Use statistics ONLY for query planning (e.g., percent_null indicates whether to handle NULLs,
distinct_count suggests field cardinality), NEVER to answer the user's question directly")
(defn chart-created-instructions
"Generate instructions for chart creation result."
[chart-id]
(str "Chart created successfully.\n"
"Present link to user using: [Chart](metabase://chart/" chart-id ")\n"
"Replace 'Chart' with a meaningful description of what the chart shows."))
(def answer-sources-instructions
"Instructions for LLM when processing available answer sources (metrics and models)."
"These are the available metrics and models for answering questions.
Use this information to understand what data sources are available.
Reference items using: [name](metabase://type/id)")
(defn query-created-instructions-for
"Generate instructions for a newly created SQL query, embedding the query ID
in the link template. Matches Python CreateSQLQueryToolV2._create_result."
[query-id]
(str "Metabot needs to:\n"
"- Remember you cannot view the results directly yourself\n"
"- Always provide a direct link using `[Link text](metabase://query/" query-id ")` "
"so the user can open it themselves\n"
"- Consider whether to create a chart or graph when that better matches the user's intent\n"
"- Present the results in a way that matches the user's intent and follow up if they need clarification"))
(defn edit-sql-query-instructions-for
"Generate instructions for an edited SQL query, embedding the query ID
in the link template. Matches Python EditSqlQueryToolV2._create_result."
[query-id]
(str "The updated query is shown in the result data above.\n\n"
"After you have edited the query, do a thorough analysis of the query to find any potential errors.\n\n"
"**If the returned SQL query is NOT correct:**\n\n"
"- Make further refinements using this tool again\n\n"
"**If the returned SQL query is correct:**\n\n"
"- Always provide a direct link using: "
"`[Updated Query](metabase://query/" query-id ")` "
"where Updated Query is a meaningful link text"))
(defn replace-sql-query-instructions-for
"Generate instructions for a replaced SQL query, embedding the query ID
in the link template. Matches Python ReplaceSqlQueryToolV2._create_result."
[query-id]
(str "The updated query is shown in the result data above.\n\n"
"After you have replaced the query, do a thorough analysis of the query to find any potential errors.\n\n"
"**If the returned SQL query is NOT correct:**\n\n"
"- Make further refinements using this tool or edit_sql_query again\n\n"
"**If the returned SQL query is correct:**\n\n"
"- Always provide a direct link using: "
"`[Updated Query](metabase://query/" query-id ")` "
"where Updated Query is a meaningful link text"))
(defn sql-validation-error-instructions
"Generate instructions for failed query validation. Matches Python `format_validation_error_instructions`."
[dialect error-message]
(str "The SQL query has a syntax error for the " dialect " dialect.\n"
"\n"
"Error details:\n"
error-message "\n"
"\n"
"Common issues to check:\n"
"- Dialect-specific function names (e.g., DATE_TRUNC vs TRUNC, ILIKE vs LIKE)\n"
"- Quote style for identifiers (double quotes vs backticks vs square brackets)\n"
"- String concatenation operators (|| vs + vs CONCAT)\n"
"- Date/time literal syntax and formatting functions\n"
"- NULL handling differences between dialects\n"
"- Case sensitivity of identifiers\n"
"\n"
"Please fix the syntax error and try again with the corrected SQL.\n"))

View File

@@ -1,41 +0,0 @@
(ns metabase-enterprise.metabot-v3.tools.interface
(:require
[metabase.lib.schema.common :as lib.schema.common]
[metabase.util :as u]
[metabase.util.malli.registry :as mr]))
(set! *warn-on-reflection* true)
(mr/def ::metadata.parameter.type
:keyword)
(mr/def ::metadata.parameter.name
[:and
{:decode/metadata-file (fn [x]
(keyword (u/->kebab-case-en x)))
:encode/api-request (fn [x]
(u/->snake_case_en (name x)))
:decode/api-response (fn [x]
(keyword (u/->kebab-case-en x)))}
:keyword])
(mr/def ::metadata.parameter
[:and
[:map
[:type {:optional true} [:or
::metadata.parameter.type
[:set ::metadata.parameter.type]]]
[:anyOf {:optional true} [:sequential [:ref ::metadata.parameter]]]
[:description {:optional true} [:maybe ::lib.schema.common/non-blank-string]]]
[:fn
{:error/message "metadata.parameter must specify either `:type`` or `:anyOf`, but not both."}
(fn [query]
(= (count (select-keys query [:type :anyOf])) 1))]])
(mr/def ::metadata.name
[:and
{:encode/api-request (fn [x] (u/->snake_case_en (name x)))
:decode/metadata-file keyword
:decode/api-request keyword
:decode/api-response keyword}
:keyword])

View File

@@ -1,16 +0,0 @@
(ns metabase-enterprise.metabot-v3.tools.invite-user
(:require
[metabase.users.core :as users]))
(defn invite-user
"Send a Metabase invitation to the address `email`."
[{:keys [email]}]
(try
(users/invite-user! {:email email})
{:output (format "%s has been invited to Metabase." email)}
(catch clojure.lang.ExceptionInfo e
(let [d (ex-data e)]
(if-let [message (when (= (:status-code d) 400)
(-> d :errors :email))]
{:output message}
(throw e))))))

View File

@@ -0,0 +1,543 @@
(ns metabase-enterprise.metabot-v3.tools.llm-representations
"LLM representation utilities for formatting entities as XML.
Matches Python AI Service patterns exactly for consistency."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.prompts :as prompts]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase.util :as u]
[metabase.util.log :as log]
[selmer.parser :as selmer]))
(set! *warn-on-reflection* true)
(def ^:private llm-template-name "llm_representations.selmer")
(defn- escape-xml
"Escape XML special characters in a string.
Only needed for content that bypasses Selmer's auto-escaping (marked with |safe)."
[s]
(when s
(-> (str s)
(str/replace "&" "&amp;")
(str/replace "<" "&lt;")
(str/replace ">" "&gt;")
(str/replace "\"" "&quot;"))))
(defn- database-type-or-unknown
"Return database type or 'unknown' if nil."
[database-type]
(or database-type "unknown"))
(defn- database-engine-or-unknown
"Return database engine or 'unknown' if nil."
[database-engine]
(or database-engine "unknown"))
(defn- type-flags
"Build template flags for a single representation type."
[type]
{:is_field (= type :field)
:is_collection (= type :collection)
:is_related_table (= type :related_table)
:is_metric (= type :metric)
:is_table (= type :table)
:is_model (= type :model)
:is_query_results (= type :query_results)
:is_query (= type :query)
:is_visualization (= type :visualization)
:is_chart (= type :chart)
:is_question (= type :question)
:is_text_card (= type :text_card)
:is_viz_card (= type :viz_card)
:is_dashboard (= type :dashboard)
:is_database_schema (= type :database_schema)
:is_database (= type :database)
:is_user (= type :user)
:is_search_result (= type :search_result)
:is_search_results (= type :search_results)
:is_field_values_metadata (= type :field_values_metadata)
:is_field_metadata (= type :field_metadata)
:is_get_metadata_result (= type :get_metadata_result)
:is_transform (= type :transform)})
(defn- render-llm-template
"Render a Selmer template with the supplied context."
[type context]
(let [template (prompts/get-cached-llm-representations-template)
payload (merge (type-flags type) context)]
(if template
(try
(-> (selmer/render template payload)
str/trim)
(catch Exception e
(log/error e "Error rendering LLM representations template" {:type type})
(pr-str payload)))
(do
(log/warn "LLM representations template missing" {:template llm-template-name})
(pr-str payload)))))
(defn format-fields-table
"Format fields as markdown table for LLM.
Matches Python render_table output format.
Note: This output is used with |safe in templates, so we must escape manually."
[fields & [{:keys [columns] :or {columns {:name "Field Name"
:field_id "Field ID"
:type "Type"
:database_type "Database Type"
:description "Description"}}}]]
(te/markdown-table
fields columns
{:value-fn (fn [k v]
(escape-xml
(cond
(nil? v) ""
(and (#{:type :base-type} k) (keyword? v)) (clojure.core/name v)
:else (str v))))}))
(defn field->xml
"Format a field/column as XML element.
Matches Python Column.llm_representation exactly."
[{:keys [field_id name display_name type database_type description]}]
(render-llm-template
:field
{:field_id field_id
;; Uses |safe in template to preserve literal \" quotes, so we escape the name for XML safety
:field_name_quoted (str "\\\"" (escape-xml name) "\\\"")
:field_display_name (or display_name name)
:field_base_type (when type (clojure.core/name type))
:field_database_type (database-type-or-unknown database_type)
:field_description description}))
(defn collection->xml
"Format collection for LLM consumption.
Matches Python Collection.llm_representation exactly."
[{:keys [name description authority_level]}]
(render-llm-template
:collection
{:collection_name (or name "Our analytics")
:collection_description description
:collection_authority_level authority_level}))
(defn- related-table->xml
"Format a related table for LLM consumption."
[{:keys [id name related_by fully_qualified_name fields]}]
(render-llm-template
:related_table
{:related_table_id (when (some? id) (str id))
:related_table_name name
:related_table_related_by related_by
:related_table_fqn fully_qualified_name
:related_table_fields_xml (when (seq fields) (str/join "\n" (map field->xml fields)))}))
(defn metric->xml
"Format metric for LLM consumption.
Matches Python Metric.get_llm_representation exactly."
[{:keys [id name description verified queryable-dimensions collection
default_time_dimension_field]}]
(render-llm-template
:metric
{:metric_id (str id)
:metric_name name
:metric_verified (boolean verified)
:metric_description description
:metric_collection_xml (when collection (collection->xml collection))
:metric_default_time_dimension (:name default_time_dimension_field)
:metric_dimensions_table (when (seq queryable-dimensions)
(format-fields-table queryable-dimensions))}))
(defn- fully-qualified-name
"Get fully qualified name for a table."
[database_schema name]
(if database_schema
(str database_schema "." name)
name))
(defn table->xml
"Format table for LLM consumption.
Matches Python Table.get_llm_representation exactly."
[{:keys [id name database_id database_engine database_schema
description fields related_tables]}]
(let [fqn (fully-qualified-name database_schema name)]
(render-llm-template
:table
{:table_id (str id)
:table_name name
:table_database_id (str database_id)
:table_database_engine (database-engine-or-unknown database_engine)
:table_fqn fqn
:table_description description
:table_fields_xml (when (seq fields)
(str/join "\n" (map field->xml fields)))
:table_related_tables_xml (when (seq related_tables)
(str/join "" (map related-table->xml related_tables)))})))
(defn- model-fully-qualified-name
"Get fully qualified name for a model (uses slug format)."
[id name]
;; Python uses: f"{{#{self.id}}}-{slug}"
;; We'll use a simplified version
(let [slug (-> (or name "")
u/lower-case-en
(str/replace #"[^a-z0-9]+" "-")
(str/replace #"^-|-$" ""))]
(str "{#" id "}-" slug)))
(defn model->xml
"Format model for LLM consumption.
Matches Python Model.get_llm_representation exactly.
Note: Python uses <metabase-model> tag but closes with </model>."
[{:keys [id name description verified fields database_id database_engine
related_tables]}]
(let [fqn (model-fully-qualified-name id name)]
(render-llm-template
:model
{:model_id (str id)
:model_name name
:model_verified (boolean verified)
:model_database_id (str database_id)
:model_database_engine (database-engine-or-unknown database_engine)
:model_fqn fqn
:model_description description
:model_fields_xml (when (seq fields)
(str/join "\n" (map field->xml fields)))
:model_related_tables_xml (when (seq related_tables)
(str/join "\n" (map related-table->xml related_tables)))})))
(defn- format-markdown-row
"Format a sequence of values as a markdown table row."
[values]
(str "| " (str/join " | " values) " |"))
(defn- format-rows-table
"Format query result rows as a markdown table.
Note: Uses |safe in template, so values must be escaped."
[columns rows]
(let [header (format-markdown-row (map :name columns))
data-rows (map (fn [row]
(format-markdown-row (map #(escape-xml (str %)) row)))
rows)]
(str header "\n" (str/join "\n" data-rows) "\n")))
(defn query-result->xml
"Format query result for LLM consumption.
Matches Python QueryResult.llm_representation exactly."
[{:keys [result_columns rows]}]
(render-llm-template
:query_results
{:result_columns_table (when (seq result_columns)
(format-fields-table result_columns
{:columns {:name "Field Name"
:display_name "Display Name"
:type "Type"
:description "Description"}}))
:result_rows_table (when (and (seq rows) (seq result_columns))
(format-rows-table result_columns rows))}))
(defn query->xml
"Format query for LLM consumption.
Matches Python Query.llm_representation exactly.
Accepts either canonical keys (`:query-type`, `:database_id`) or the keys
produced by the SQL / notebook tool results (`:result-type`, `:database`).
For SQL tool results the `:query` map carries `:type :native` which we surface
as query-type \"sql\"."
[{:keys [query-type result-type query-id database database_id query query-content result]}]
(let [qtype (or query-type
(when (= :native (:type query)) "sql")
(when result-type (name result-type))
"unknown")]
(render-llm-template
:query
{:query_type (if (keyword? qtype) (clojure.core/name qtype) qtype)
:query_id query-id
:query_database_id (str (or database_id database))
:query_content query-content
:query_results_xml (when result (query-result->xml result))})))
(defn visualization->xml
"Format visualization/chart for LLM consumption.
Matches Python Visualization.get_llm_representation exactly."
[{:keys [chart-id queries visualization_settings]}]
(render-llm-template
:visualization
{:visualization_chart_id chart-id
:visualization_queries_xml (when (seq queries)
(str/join "" (map query->xml queries)))
:visualization_settings_text (when visualization_settings
(pr-str visualization_settings))}))
(defn chart->xml
"Format chart for LLM consumption - simplified version.
For full chart representation, use visualization->xml."
[{:keys [chart-id query-id chart-type]}]
(render-llm-template
:chart
{:chart_id chart-id
:chart_query_id query-id
:chart_type (if chart-type (clojure.core/name chart-type) "table")}))
(defn question->xml
"Format question for LLM consumption.
Matches Python Question.llm_representation exactly."
[{:keys [id name description verified collection visualization]}]
(render-llm-template
:question
{:question_id (str id)
:question_verified (boolean verified)
:question_name name
:question_description description
:question_collection_xml (when collection (collection->xml collection))
:question_visualization_xml (when visualization (visualization->xml visualization))}))
(defn- text-card->xml
"Format a text card for LLM consumption."
[{:keys [id order width height text]}]
(render-llm-template
:text_card
{:text_card_id (str id)
:text_card_order (str order)
:text_card_width (str width)
:text_card_height (str height)
:text_card_text text}))
(defn- viz-card->xml
"Format a viz card for LLM consumption."
[{:keys [id order width height title description chart]}]
(render-llm-template
:viz_card
{:viz_card_id (str id)
:viz_card_order (str order)
:viz_card_width (str width)
:viz_card_height (str height)
:viz_card_title title
:viz_card_description description
:viz_card_chart_xml (when chart (visualization->xml chart))}))
(defn- dashcard->xml
"Format a dashboard card for LLM consumption."
[card]
(case (:type card)
"text" (text-card->xml card)
:text (text-card->xml card)
"chart" (viz-card->xml card)
:chart (viz-card->xml card)
;; Default to viz card
(viz-card->xml card)))
(defn dashboard->xml
"Format dashboard for LLM consumption.
Matches Python Dashboard.llm_representation exactly."
[{:keys [id name description verified collection dashcards]}]
;; Group cards by tab and sort
(let [tabs (group-by :dashboard_tab_id dashcards)
sorted-tabs (sort-by first tabs)
tabs-xml (when (seq dashcards)
(str " <tabs>\n"
(str/join ""
(map (fn [[tab-id cards]]
(let [sorted-cards (sort-by (juxt :row :col) cards)]
(str " <tab id=\"" tab-id "\">\n"
" <content>\n"
(str/join ""
(map #(str " " (dashcard->xml %) "\n")
sorted-cards))
" </content>\n"
" </tab>\n")))
sorted-tabs))
" </tabs>\n"))]
(render-llm-template
:dashboard
{:dashboard_id (str id)
:dashboard_verified (boolean verified)
:dashboard_name name
:dashboard_description description
:dashboard_collection_xml (when collection (collection->xml collection))
:dashboard_tabs_xml tabs-xml})))
(defn database-schema->xml
"Format database schema for LLM consumption."
[{:keys [name description]}]
(render-llm-template
:database_schema
{:database_schema_name name
:database_schema_description description}))
(defn database->xml
"Format database for LLM consumption.
Matches Python Database.llm_representation exactly."
[{:keys [id name description schemas]}]
(render-llm-template
:database
{:database_id (str id)
:database_name name
:database_description description
:database_schemas_xml (when (seq schemas)
(str/join "" (map database-schema->xml schemas)))}))
(defn user->xml
"Format user for LLM consumption.
Matches Python DummyGetCurrentUserResultSchema.llm_representation exactly.
Note: Glossary rows are used with |safe, so we must escape manually."
[{:keys [id name email glossary]}]
(let [glossary-rows (when (seq glossary)
(str/join "\n"
(map (fn [[term definition]]
(str "| " (escape-xml term) " | " (escape-xml definition) " |"))
glossary)))]
(render-llm-template
:user
{:user_name name
:user_id (str id)
:user_email email
:user_glossary_rows glossary-rows})))
(defn- search-result-tag-name
"Get the XML tag name for a search result type.
Maps to Python naming conventions."
[result-type]
(case result-type
:metric "metric"
:table "table"
:model "metabase-model"
:dataset "metabase-model"
:card "metabase_question"
:question "metabase_question"
:dashboard "dashboard"
:database "database"
:transform "transform"
(if result-type
(clojure.core/name result-type)
"item")))
(defn search-result->xml
"Format a single search result as XML element.
Includes database_id, database_engine, and fully_qualified_name for table/model results
to match Python AI Service search output."
[{:keys [id type name description verified collection
database_id database_engine database_schema]}]
(let [fqn (cond
(#{"table" :table} type)
(when name (fully-qualified-name database_schema name))
(#{"model" :model "dataset" :dataset} type)
(when name (model-fully-qualified-name id name))
:else nil)
engine (when database_engine
(database-engine-or-unknown
(if (keyword? database_engine)
(clojure.core/name database_engine)
database_engine)))]
(render-llm-template
:search_result
{:search_tag_name (search-result-tag-name type)
:search_id (str id)
:search_name name
:search_has_verified (some? verified)
:search_verified verified
:search_description description
:search_collection_name (:name collection)
:search_database_id (when database_id (str database_id))
:search_database_engine engine
:search_fqn fqn})))
(defn search-results->xml
"Format search results as XML wrapped in search-results element."
[results]
(render-llm-template
:search_results
{:search_results_xml (str/join "\n" (map search-result->xml results))}))
(defn field-values-metadata->xml
"Format field values metadata for LLM consumption.
Matches Python FieldValuesMetadata.llm_representation exactly.
Note: Tables are used with |safe in the template, so values must be escaped."
[{:keys [field_values statistics]}]
(let [escape-value (fn [_k v] (escape-xml (str v)))
sample-values-table (when (seq field_values)
(te/markdown-table
(map vector field_values)
{:value "Value"}
{:value-fn escape-value}))
stats-map (into {} (filter (fn [[_ v]] (some? v)) statistics))
stats-table (when (seq stats-map)
(te/markdown-table
(update-keys stats-map #(->> % name (str "sample-")))
[:statistic :value]
{:value-fn escape-value}))]
(render-llm-template
:field_values_metadata
{:sample_values_table sample-values-table
:stats_table stats-table})))
(defn field-metadata->xml
"Format field metadata for LLM consumption.
Matches Python GetFieldMetadataResultSchema.llm_representation exactly."
[{:keys [field_id value_metadata]}]
(render-llm-template
:field_metadata
{:field_metadata_field_id field_id
:field_metadata_value_xml (when value_metadata
(field-values-metadata->xml value_metadata))}))
(defn get-metadata-result->xml
"Format get_metadata result for LLM consumption.
Matches Python GetMetadataResultSchema.llm_representation exactly."
[{:keys [metrics tables models errors]}]
(let [metadata-metrics (when (seq metrics)
(str "<metrics>\n"
(str/join "\n" (map metric->xml metrics))
"\n</metrics>\n"))
metadata-tables (when (seq tables)
(str "<tables>\n"
(str/join "\n" (map table->xml tables))
"\n</tables>\n"))
metadata-models (when (seq models)
(str "<metabase-models>\n"
(str/join "\n" (map model->xml models))
"\n</metabase-models>\n"))]
(render-llm-template
:get_metadata_result
{:metadata_has_any (boolean (or metadata-metrics metadata-tables metadata-models))
:metadata_metrics_xml metadata-metrics
:metadata_tables_xml metadata-tables
:metadata_models_xml metadata-models
:metadata_errors (when (seq errors) (str/join "\n" errors))})))
(defn transform->xml
"Format a transform for LLM consumption."
[{:keys [id name description source target]}]
(render-llm-template
:transform
{:transform_id (str id)
:transform_name name
:transform_description description
:transform_source_type (some-> (:type source) clojure.core/name)
:transform_source_database (when-let [db (:source-database source)] (str db))
:transform_source_query (let [q (:query source)]
(when (string? q) q))
:transform_target (when target (pr-str target))}))
(def formatters
"XML formatters for different entity types"
{:metric metric->xml
:table table->xml
:model model->xml
:question question->xml
:dashboard dashboard->xml
:database database->xml
:user user->xml
:query query->xml
:collection collection->xml
:transform transform->xml})
(defn entity->xml
"Dispatch to appropriate XML formatter based on entity type."
[entity]
(if-let [formatter (get formatters (:type entity))]
(formatter entity)
(do
(log/warn "Unknown entity type" {:type (:type entity)})
(pr-str entity))))

View File

@@ -0,0 +1,128 @@
(ns metabase-enterprise.metabot-v3.tools.navigate
"Tool for navigating users to specific pages in Metabase UI."
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.agent.links :as links]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(defn- create-sql-editor-hash
"Create a hash for the SQL editor with optional database ID."
[database-id]
(let [query-data {:dataset_query {:database database-id
:type "native"
:native {:query ""}}}]
(-> query-data
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str)))
(defn- page->path
"Convert a page name to a URL path."
[page database-id]
(case page
"notebook_editor" "/question/notebook"
"metrics_browser" "/browse/metrics"
"model_browser" "/browse/models"
"database_browser" "/browse/databases"
"sql_editor" (str "/question#" (create-sql-editor-hash database-id))
"home" "/"
(throw (ex-info (str "Unknown page: " page ". Valid pages: notebook_editor, metrics_browser, model_browser, database_browser, sql_editor, home")
{:agent-error? true :page page}))))
(defn- entity->path
"Convert an entity type and ID to a URL path."
[entity-type entity-id]
(case entity-type
"table" (str "/table/" entity-id)
"model" (str "/model/" entity-id)
"question" (str "/question/" entity-id)
"metric" (str "/metric/" entity-id)
"dashboard" (str "/dashboard/" entity-id)
"database" (str "/browse/databases/" entity-id)
"collection" (str "/collection/" entity-id)
(throw (ex-info (str "Unknown entity type: " entity-type ". Valid types: table, model, question, metric, dashboard, database, collection")
{:agent-error? true :entity-type entity-type}))))
(defn- destination->path
"Convert navigation destination to a URL path.
Handles page navigation, entity navigation, and query/chart link resolution."
[destination memory]
(cond
;; Page navigation
(:page destination)
(page->path (:page destination) (:database_id destination))
;; Entity navigation
(and (:entity_type destination) (:entity_id destination))
(entity->path (:entity_type destination) (:entity_id destination))
;; Query result navigation (resolve from memory)
(:query_id destination)
(let [queries-state (get-in memory [:state :queries] {})
charts-state (get-in memory [:state :charts] {})]
(or (links/resolve-metabase-uri (str "metabase://query/" (:query_id destination))
queries-state
charts-state)
(throw (ex-info (str "Query not found: " (:query_id destination))
{:agent-error? true :query-id (:query_id destination)}))))
;; Chart navigation (resolve from memory)
(:chart_id destination)
(let [queries-state (get-in memory [:state :queries] {})
charts-state (get-in memory [:state :charts] {})]
(or (links/resolve-metabase-uri (str "metabase://chart/" (:chart_id destination))
queries-state
charts-state)
(throw (ex-info (str "Chart not found: " (:chart_id destination))
{:agent-error? true :chart-id (:chart_id destination)}))))
:else
(throw (ex-info "Invalid destination: must specify page, entity_type+entity_id, query_id, or chart_id"
{:agent-error? true :destination destination}))))
(defn- format-navigation-message
"Format a user-friendly message describing the navigation."
[destination]
(cond
(:page destination)
(str "Navigating to " (name (:page destination)) " page.")
(and (:entity_type destination) (:entity_id destination))
(str "Navigating to " (:entity_type destination) " " (:entity_id destination) ".")
(:query_id destination)
"Navigating to query results."
(:chart_id destination)
"Navigating to chart."
:else
"Navigating to destination."))
(defn navigate
"Navigate user to a specific page in Metabase UI.
Parameters:
- destination: Map describing where to navigate. One of:
- {:page \"page_name\"} - Navigate to a specific page
- {:page \"sql_editor\" :database_id 123} - Navigate to SQL editor with database
- {:entity_type \"model\" :entity_id 123} - Navigate to an entity
- {:query_id \"abc123\"} - Navigate to query results
- {:chart_id \"xyz789\"} - Navigate to chart
- memory-atom: Atom containing agent memory (for link resolution)
Returns map with:
- :structured-output - Message for the LLM
- :reactions - Navigation action"
[{:keys [destination memory-atom]}]
(log/info "Navigating user" {:destination destination})
(let [memory (when memory-atom @memory-atom)
path (destination->path destination memory)
message (format-navigation-message destination)]
(log/debug "Resolved navigation path" {:path path})
{:structured-output {:message message
:path path}
:reactions [{:type :metabot.reaction/redirect :url path}]}))

View File

@@ -0,0 +1,271 @@
(ns metabase-enterprise.metabot-v3.tools.read-resource
"Tool for reading Metabase resources via URI patterns.
Implements 'Context Engineering with Links' pattern where URIs serve as lightweight,
token-efficient references to resources that can be fetched on-demand at the
appropriate level of detail.
Supported URI patterns:
- metabase://table/{id} - Basic table info
- metabase://table/{id}/fields - Table with fields
- metabase://table/{id}/fields/{field_id} - Specific field details
- metabase://model/{id} - Basic model info
- metabase://model/{id}/fields - Model with fields
- metabase://model/{id}/fields/{field_id} - Specific field details
- metabase://metric/{id} - Basic metric info
- metabase://metric/{id}/dimensions - Metric with dimensions
- metabase://metric/{id}/dimensions/{dimension_id} - Specific dimension details
- metabase://transform/{id} - Transform details
- metabase://dashboard/{id} - Dashboard details"
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details]
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.tools.transforms :as transforms]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:private max-concurrent-uris
"Maximum number of URIs that can be fetched in a single call."
5)
(defn- parse-uri
"Parse a metabase:// URI into components.
Returns a map with:
- :resource-type - The type (table, model, metric, etc.)
- :resource-id - The ID of the resource
- :sub-resource - Optional sub-resource type (fields, dimensions)
- :sub-resource-id - Optional sub-resource ID"
[uri]
(when-not (str/starts-with? uri "metabase://")
(throw (ex-info (str "Invalid URI scheme. Expected 'metabase://' but got: " uri)
{:uri uri})))
(let [path (subs uri 11) ; Remove "metabase://"
parts (str/split path #"/")
parts (remove str/blank? parts)]
(when (< (count parts) 2)
(throw (ex-info
(str "Invalid URI format: " uri ". "
"Expected: metabase://{type}/{id}[/{sub_resource}[/{sub_resource_id}]]")
{:uri uri :parts parts})))
{:resource-type (first parts)
:resource-id (second parts)
:sub-resource (nth parts 2 nil)
:sub-resource-id (when (> (count parts) 3)
;; Handle field IDs with slashes (e.g., c75/17)
(str/join "/" (drop 3 parts)))}))
(defn- fetch-table-resource
"Fetch table resource based on URI components."
[{:keys [resource-id sub-resource sub-resource-id]}]
(let [table-id (parse-long resource-id)]
(cond
;; metabase://table/123/fields/FIELD_ID
(and (= sub-resource "fields") sub-resource-id)
(field-stats/field-values {:entity-type "table"
:entity-id table-id
:field-id sub-resource-id
:limit 30})
;; metabase://table/123/fields
(= sub-resource "fields")
(entity-details/get-table-details {:table-id table-id
:with-fields true
:with-field-values false
:with-related-tables false})
;; metabase://table/123
(nil? sub-resource)
(entity-details/get-table-details {:table-id table-id
:with-fields false
:with-field-values false
:with-related-tables false})
:else
(throw (ex-info (str "Unsupported sub-resource '" sub-resource "' for table. Supported: fields")
{:resource-id resource-id :sub-resource sub-resource})))))
(defn- fetch-model-resource
"Fetch model resource based on URI components."
[{:keys [resource-id sub-resource sub-resource-id]}]
(let [model-id (parse-long resource-id)]
(cond
;; metabase://model/123/fields/FIELD_ID
(and (= sub-resource "fields") sub-resource-id)
(field-stats/field-values {:entity-type "model"
:entity-id model-id
:field-id sub-resource-id
:limit 30})
;; metabase://model/123/fields
(= sub-resource "fields")
(entity-details/get-table-details {:model-id model-id
:with-fields true
:with-field-values false
:with-related-tables false})
;; metabase://model/123
(nil? sub-resource)
(entity-details/get-table-details {:model-id model-id
:with-fields false
:with-field-values false
:with-related-tables false})
:else
(throw (ex-info (str "Unsupported sub-resource '" sub-resource "' for model. Supported: fields")
{:resource-id resource-id :sub-resource sub-resource})))))
(defn- fetch-metric-resource
"Fetch metric resource based on URI components."
[{:keys [resource-id sub-resource sub-resource-id]}]
(let [metric-id (parse-long resource-id)]
(cond
;; metabase://metric/123/dimensions/DIMENSION_ID
(and (= sub-resource "dimensions") sub-resource-id)
(field-stats/field-values {:entity-type "metric"
:entity-id metric-id
:field-id sub-resource-id
:limit 30})
;; metabase://metric/123/dimensions
(= sub-resource "dimensions")
(entity-details/get-metric-details {:metric-id metric-id
:with-queryable-dimensions true
:with-field-values false})
;; metabase://metric/123
(nil? sub-resource)
(entity-details/get-metric-details {:metric-id metric-id
:with-queryable-dimensions false
:with-field-values false})
:else
(throw (ex-info (str "Unsupported sub-resource '" sub-resource "' for metric. Supported: dimensions")
{:resource-id resource-id :sub-resource sub-resource})))))
(defn- fetch-transform-resource
"Fetch transform resource."
[{:keys [resource-id sub-resource]}]
(when sub-resource
(throw (ex-info (str "Transforms do not support sub-resources. Got: " sub-resource)
{:resource-id resource-id :sub-resource sub-resource})))
(let [result (transforms/get-transform-details {:transform-id (parse-long resource-id)})]
(if-let [transform (:structured_output result)]
{:structured-output (assoc transform :result-type :entity :type :transform)}
result)))
(defn- fetch-dashboard-resource
"Fetch dashboard resource."
[{:keys [resource-id sub-resource]}]
(when sub-resource
(throw (ex-info (str "Dashboards do not support sub-resources. Got: " sub-resource)
{:resource-id resource-id :sub-resource sub-resource})))
(let [result (entity-details/get-dashboard-details {:dashboard-id (parse-long resource-id)})]
(if-let [dashboard (:structured-output result)]
{:structured-output (assoc dashboard :result-type :entity)}
{:status-code 404 :output (:output result)})))
(def ^:private resource-handlers
"Map of resource type to handler function."
{"table" fetch-table-resource
"model" fetch-model-resource
"metric" fetch-metric-resource
"transform" fetch-transform-resource
"dashboard" fetch-dashboard-resource})
(defn- fetch-single-uri
"Fetch a single URI and return formatted content.
Returns a map with either:
- {:uri uri :content result}
- {:uri uri :error error-message}"
[uri]
(try
(let [parsed (parse-uri uri)
{:keys [resource-type]} parsed
handler (get resource-handlers resource-type)]
(when-not handler
(throw (ex-info (str "Unknown resource type '" resource-type "'. "
"Supported: " (str/join ", " (keys resource-handlers)))
{:resource-type resource-type :supported (keys resource-handlers)})))
(let [result (handler parsed)]
(if (:status-code result)
{:uri uri :error (or (:output result) result)}
{:uri uri :content result})))
(catch Exception e
(log/warn "Error fetching resource" {:uri uri :error (ex-message e)})
{:uri uri :error (or (ex-message e) "Unknown error")})))
(defn- format-with-instructions
"Wrap content in `<result>` / `<instructions>` tags."
[content instruction-text]
(str "<result>\n" content "\n</result>\n"
"<instructions>\n" instruction-text "\n</instructions>"))
(defn- format-content
"Format a tool result as an LLM-ready string.
Dispatches to the right llm-rep formatter based on :result-type.
Returns the :output string directly for error results (404s etc.)."
[content]
(if-let [structured (:structured-output content)]
(case (:result-type structured)
;; NOTE: keep in sync with agent/tools/metadata.clj/format-field-metadata-output
:field-metadata (format-with-instructions
(llm-rep/field-metadata->xml structured)
instructions/field-metadata-instructions)
:entity (llm-rep/entity->xml structured)
;; fallback — should not happen, but better than EDN
(llm-rep/entity->xml structured))
;; error case — :output is already a string
(:formatted content)))
(defn- format-resources
"Format resources for LLM output."
[resources]
(str "<resources>\n"
(str/join "\n"
(for [{:keys [uri content error]} resources]
(str "<resource uri=\"" uri "\">"
(if content
(str "\n" (format-content content) "\n")
(str "\n**Error:** " error "\n"))
"</resource>")))
"\n</resources>"))
(defn read-resource
"Read one or more Metabase resources via URI patterns.
Parameters:
- uris: List of metabase:// URIs to fetch (max 5)
Returns a map with formatted resources or error details."
[{:keys [uris]}]
(log/info "Reading resources" {:uri-count (count uris)})
;; Validate URI count
(when (> (count uris) max-concurrent-uris)
(throw (ex-info
(str "Too many URIs provided (" (count uris) "). "
"Please limit to " max-concurrent-uris " URIs maximum. "
"Be more selective and focus on the most relevant items for the current task or fetch them in batches.")
{:uri-count (count uris) :max max-concurrent-uris})))
;; Fetch all URIs (sequentially for now, could parallelize with pmap)
(let [resources (mapv fetch-single-uri uris)
formatted (format-resources resources)]
(log/info "Fetched resources" {:total (count resources)
:successful (count (filter :content resources))
:errors (count (filter :error resources))})
{:resources resources
:output formatted}))

View File

@@ -0,0 +1,51 @@
(ns metabase-enterprise.metabot-v3.tools.replace-sql-query
"Tool for replacing SQL query content entirely while preserving metadata."
(:require
[metabase-enterprise.metabot-v3.tools.sql.common :as metabot-v3.tools.sql.common]
[metabase-enterprise.metabot-v3.tools.sql.validation :as metabot-v3.tools.sql.validation]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn replace-sql-query :- ::metabot-v3.tools.sql.common/operation-result
"Replace the SQL content of an existing query while preserving metadata.
This is different from edit-sql-query in that it completely replaces the SQL
rather than applying specific edits. Useful for refactoring or complete rewrites.
Parameters:
- query-id: ID of the query to replace
- sql: New SQL query string
- name: New name for the query (optional, preserves existing if not provided)
- description: New description (optional, preserves existing if not provided)
Returns a map with:
- :query-id - The ID of the updated query
- :query-content - The new SQL content
- :database - Database ID"
[{:keys [query-id sql queries-state]}]
(log/info "Replacing SQL query" {:query-id query-id :sql-length (count sql)})
;; Look up query from in-memory state
(let [query-id (str query-id)
query (get queries-state query-id)]
(when-not query
(throw (ex-info (tru "Query {0} not found" query-id)
{:agent-error? true
:query-id query-id
:available-queries (keys queries-state)})))
(let [dialect (metabot-v3.tools.sql.validation/query->dialect query)
{:keys [valid? transpiled-sql] :as validation-result}
(metabot-v3.tools.sql.validation/validate-sql dialect sql)]
(merge {:validation-result validation-result}
(when valid?
(let [;; Replace the SQL content - handle both formats
updated-query (metabot-v3.tools.sql.common/update-query-sql query transpiled-sql)]
{:action-result {:query-id query-id
:query-content transpiled-sql
:query updated-query
:database (:database query)}}))))))

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.metabot-v3.tools.search
(:require
[clojure.set :as set]
[medley.core :as m]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.reactions]
[metabase.api.common :as api]
@@ -69,38 +70,38 @@
(defn- enrich-with-collection-descriptions
"Fetch and merge collection descriptions for all search results that have collection IDs."
[results]
(let [collection-ids (->> results
(keep #(get-in % [:collection :id]))
distinct
seq)]
(if-not collection-ids
results
(let [descriptions (t2/select-pk->fn :description :model/Collection :id [:in collection-ids])]
(mapv (fn [result]
(if-let [coll-id (get-in result [:collection :id])]
(assoc-in result [:collection :description] (get descriptions coll-id))
result))
results)))))
(let [coll-ids (->> results (keep #(get-in % [:collection :id])) distinct)
descriptions (when (seq coll-ids)
(t2/select-pk->fn :description :model/Collection :id [:in coll-ids]))]
(cond->> results
(seq descriptions) (mapv (fn [r]
(let [cid (-> r :collection :id)]
(update r :collection m/assoc-some :description (get descriptions cid))))))))
(defn- enrich-with-database-engines
"Fetch and merge database engine info for search results that have database IDs."
[results]
(let [db-ids (->> results (keep :database_id) distinct)
engines (when (seq db-ids)
(t2/select-pk->fn :engine :model/Database :id [:in db-ids]))]
(cond->> results
(seq engines) (mapv (fn [r] (m/assoc-some r :database_engine (get engines (:database_id r))))))))
(defn- remove-unreadable-transforms
"Remove transforms from search results that the user cannot read.
This filters out transforms where the user doesn't have access to the source tables/database."
[results]
(let [transform-ids (->> results
(filter #(= "transform" (:type %)))
(map :id)
set)]
(if-not (seq transform-ids)
results
(let [readable-ids (->> (t2/select :model/Transform :id [:in transform-ids])
transforms/add-source-readable
(filter :source_readable)
(map :id)
set)]
(filterv (fn [result]
(or (not= "transform" (:type result))
(contains? readable-ids (:id result))))
results)))))
(let [transform-ids (->> results (filter #(= "transform" (:type %))) (map :id) set)
readable-ids (when (seq transform-ids)
(->> (t2/select :model/Transform :id [:in transform-ids])
transforms/add-source-readable
(filter :source_readable)
(map :id)
set))]
(cond->> results
(seq transform-ids) (filterv (fn [result]
(or (not= "transform" (:type result))
(contains? readable-ids (:id result))))))))
(defn- search-result-id
"Generate a unique identifier for a search result based on its id and model."
@@ -240,6 +241,7 @@
(take limit)
(map postprocess-search-result)
enrich-with-collection-descriptions
enrich-with-database-engines
remove-unreadable-transforms)))
(defn search-tool
@@ -248,7 +250,8 @@
[args]
(try
(let [results (search args)]
{:structured_output {:data results
{:structured_output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in search")

View File

@@ -1,18 +0,0 @@
(ns metabase-enterprise.metabot-v3.tools.show-results-to-user
(:require
[buddy.core.codecs :as codecs]
[metabase.system.core :as system]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(defn show-results-to-user
"Generate link where the query can be seen."
[{:keys [query]}]
(let [query-hash (-> {:dataset_query query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str)
results-url (str "/question#" query-hash)]
{:output (str "Results can be seen at: " (system/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect, :url results-url}]}))

View File

@@ -0,0 +1,52 @@
(ns metabase-enterprise.metabot-v3.tools.sql.common
"Namespace that aggregates functionality common to tools sql namespaces.
Those are:
- `metabot-v3.tools.create-sql-query`,
- `metabot-v3.tools.edit-sql-query`,
- `metabot-v3.tools.replace-sql-query`.
Each of those namespaces define an _operation_:
- `create-sql-query`,
- `edit-sql-query`,
- `replace-sql-query`."
(:require
[metabase-enterprise.metabot-v3.tools.sql.validation :as metabot-v3.tools.sql.validation]
[metabase.lib.core :as lib]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli.registry :as mr]))
(mr/def ::action-result
"Each of the _operations_ performs an _action_ manipulating a query.
Key of the action result represent
- query-id :: id of a query stored in the context or memory,
- query-content :: sql that is a result of an action,
- query :: query map that wraps the `query-content`,
- database :: id of the database that query belongs to."
[:map
[:query-id :any]
[:query-content :string]
[:query :map]
[:database :int]])
(mr/def ::operation-result
"Result of an operation as described this ns' docstring. Stores validation result and action result iff validation
was successful."
[:map
[:validation-result ::metabot-v3.tools.sql.validation/validation-result]
[:action-result {:optional true} ::action-result]])
(defn update-query-sql
"Update a dataset_query map with new SQL content."
[query new-sql]
(cond
(and (lib/native-only-query? query)
(string? (not-empty new-sql)))
(lib/with-native-query query new-sql)
(:native query)
(assoc-in query [:native :query] new-sql)
:else
(throw (ex-info (tru "Unsupported query format")
{:agent-error? true}))))

View File

@@ -0,0 +1,116 @@
(ns metabase-enterprise.metabot-v3.tools.sql.validation
(:require
[clojure.string :as str]
[metabase.driver.util :as driver.u]
[metabase.sql-tools.core :as sql-tools]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(def dialect-mapping
"Maps query dialect (Metabase driver name) to parser dialect. Matches ai-service
`SQLGLOT_DIALECT_MAP`. Values of `nil` mean the dialect is recognized but validation is skipped.
Dialects absent from this map are also skipped."
{;; PostgreSQL family
"postgres" "postgres",
"postgresql" "postgres",
;; MySQL family
"mysql" "mysql",
"mariadb" "mysql",
;; Cloud warehouses
"bigquery-cloud-sdk" "bigquery",
"bigquery" "bigquery",
"snowflake" "snowflake",
"redshift" "redshift",
;; Presto/Trino family (Athena uses Trino/Presto syntax)
"athena" "trino",
"presto" "presto",
"presto-jdbc" "presto",
"trino" "trino",
"starburst" "trino",
;; Analytics engines
"clickhouse" "clickhouse",
;; Spark family
"databricks" "databricks",
"sparksql" "spark",
"spark" "spark",
;; Enterprise databases
"oracle" "oracle",
"sqlserver" "tsql",
;; Embedded/lightweight
"sqlite" "sqlite",
;; H2 uses PostgreSQL compatibility mode in sqlglot but this causes incorrect identifier
;; quoting (H2 folds to UPPERCASE internally, postgres quoting forces lowercase), so we
;; skip validation. Vertica is not supported by sqlglot.
"h2" nil
"vertica" nil})
;; Dialects where the Python transpilation layer (`sql_tools.py`) passes `identify=True` to
;; sqlglot, quoting all identifiers. These dialects fold unquoted identifiers to a specific case
;; and need quoting to preserve the LLM's intended casing. This set is defined in Python's
;; `CASE_SENSITIVE_DIALECTS`; it is documented here so that any future pure-JVM replacement
;; knows to replicate the behavior.
;;
;; "snowflake" — folds unquoted to UPPERCASE
;; "oracle" — folds unquoted to UPPERCASE
;; "redshift" — PostgreSQL-based, folds to lowercase
;; "postgres" — folds unquoted to lowercase
(defn database-id->dialect
"Get dialect for database id."
[db-id]
(some-> db-id driver.u/database->driver name))
(defn query->dialect
"Get queries dialect."
[query]
(database-id->dialect (:database query)))
(defn- contains-template-tags?
"Predicate that checks whether sql string contains"
[sql]
(boolean (and (string? sql)
(re-find #"\{\{|\[\[" sql))))
(mr/def ::validation-result
[:map
[:valid? :boolean]
[:dialect {:optional true} [:maybe :string]]
[:error-message {:optional true} :string]
[:transpiled-sql {:optional true} :string]])
(mu/defn validate-sql :- ::validation-result
"Validate sql query.
Validation is short-circuited and query is considered valid for following cases:
- sql string is empty,
- dialect is `nil` or maps to `nil` (e.g. h2, vertica),
- dialect is not in [[dialect-mapping]],
- sql contains Metabase template tags.
When that is not the case, the query is transpiled from and into the same dialect. If that action
yields successfully, the query is considered valid.
Returns the mapped dialect name in `:dialect` (not the raw driver name) for consistency
with the Python ai-service."
[dialect :- [:maybe :string]
sql :- :string]
(let [mapped-dialect (get dialect-mapping dialect)]
(if (or (nil? dialect)
(str/blank? sql)
(not (contains? dialect-mapping dialect))
(nil? mapped-dialect)
(contains-template-tags? sql))
{:valid? true
:dialect dialect
:transpiled-sql sql}
(let [{:keys [error-message transpiled-sql status]}
(sql-tools/transpile-sql sql mapped-dialect mapped-dialect)]
(merge
{:dialect dialect}
(case status
:success {:valid? true
:transpiled-sql transpiled-sql}
:skipped {:valid? true
:transpiled-sql transpiled-sql}
:error {:valid? false
:error-message error-message}))))))

View File

@@ -0,0 +1,79 @@
(ns metabase-enterprise.metabot-v3.tools.todo
"Tools for managing todo lists during agent conversations.
Todos are stored in agent memory and streamed to the frontend via todo_list data parts."
(:require
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
(def ^:private valid-statuses
"Valid status values for todo items."
#{"pending" "in_progress" "completed" "cancelled"})
(def ^:private valid-priorities
"Valid priority values for todo items."
#{"high" "medium" "low"})
(defn- validate-todo
"Validate a single todo item. Throws if invalid."
[{:keys [id content status priority] :as todo}]
(when-not (and id (string? id) (seq id))
(throw (ex-info "Todo item missing required 'id' field"
{:agent-error? true :todo todo})))
(when-not (and content (string? content) (seq content))
(throw (ex-info "Todo item missing required 'content' field"
{:agent-error? true :todo todo})))
(when-not (contains? valid-statuses status)
(throw (ex-info (str "Invalid todo status: " status ". Valid statuses: " (pr-str valid-statuses))
{:agent-error? true :status status})))
(when-not (contains? valid-priorities priority)
(throw (ex-info (str "Invalid todo priority: " priority ". Valid priorities: " (pr-str valid-priorities))
{:agent-error? true :priority priority})))
todo)
(defn- validate-todos
"Validate all todos. Returns the validated todos vector or throws."
[todos]
(when-not (sequential? todos)
(throw (ex-info "Todos must be an array" {:agent-error? true})))
(mapv validate-todo todos))
(defn todo-write
"Write/update the todo list.
Stores todos in memory and returns a todo_list data part for streaming.
Parameters:
- todos: Vector of todo item maps with :id, :content, :status, :priority
- memory-atom: Atom containing agent memory (injected by wrapper)
Returns map with:
- :structured-output - Message for the LLM
- :data-parts - Vector containing todo_list data part for streaming
- :instructions - Instructions for the LLM on how to handle the result"
[{:keys [todos memory-atom]}]
(log/info "Writing todo list" {:todo-count (count todos)})
(let [validated-todos (validate-todos todos)]
;; Store in memory
(swap! memory-atom update :state assoc :todos validated-todos)
(log/debug "Stored todos in memory" {:count (count validated-todos)})
{:structured-output {:message "Todo list updated successfully."
:todo_count (count validated-todos)}
:data-parts [(streaming/todo-list-part validated-todos)]
:instructions "The todo list has been updated and is automatically displayed to the user. NEVER repeat or summarize the todo list contents in your response."}))
(defn todo-read
"Read the current todo list from memory.
Parameters:
- memory-atom: Atom containing agent memory (injected by wrapper)
Returns map with:
- :structured-output - The current todos
- :instructions - Instructions for the LLM"
[{:keys [memory-atom]}]
(let [todos (get-in @memory-atom [:state :todos] [])]
(log/debug "Reading todos from memory" {:count (count todos)})
{:structured-output {:todos todos
:todo_count (count todos)}
:instructions "You should call todo_write after reading this todo_list to make any updates."}))

View File

@@ -0,0 +1,241 @@
(ns metabase-enterprise.metabot-v3.tools.transforms-write
"Tools for writing/editing SQL and Python transform source code.
Supports both partial edits (targeted string replacements) and full replacement modes."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; Edit Application
(defn- count-occurrences
"Count occurrences of a literal string in text."
[text search-str]
(if (or (str/blank? text) (str/blank? search-str))
0
(let [pattern (java.util.regex.Pattern/quote search-str)]
(count (re-seq (re-pattern pattern) text)))))
(defn- apply-single-edit
"Apply a single edit to content. Validates uniqueness unless replace_all is true."
[content {:keys [old_string new_string replace_all]}]
(let [occurrence-count (count-occurrences content old_string)]
(cond
(zero? occurrence-count)
(throw (ex-info (str "Could not find text to replace: \"" old_string "\"")
{:agent-error? true
:old-string old_string}))
(and (> occurrence-count 1) (not replace_all))
(throw (ex-info (str "Found " occurrence-count " matches for \"" old_string "\". "
"Use replace_all=true to replace all occurrences, or add more context to make the match unique.")
{:agent-error? true
:old-string old_string
:occurrence-count occurrence-count}))
:else
(if replace_all
(str/replace content old_string new_string)
(str/replace-first content old_string new_string)))))
(defn- apply-edits
"Apply a sequence of partial edits to content.
Each edit is applied in order, and the result becomes input for the next edit.
All edits must succeed or the operation fails (atomic behavior)."
[content edits]
(reduce apply-single-edit content edits))
;;; Transform Templates
(def ^:private fresh-sql-template
"-- New SQL Transform
-- Write your SQL query here
SELECT 1 AS placeholder")
(def ^:private fresh-python-template
"import common
import pandas as pd
def transform():
# Your transformation logic here
return pd.DataFrame([{\"message\": \"Hello from Python transform!\"}])
")
(defn- create-fresh-transform
"Create a fresh transform structure with the given source type."
[source-type transform-name transform-description source-database source-tables]
(let [template (case source-type
:sql fresh-sql-template
:python fresh-python-template)]
{:id nil
:name (or transform-name (str "New " (name source-type) " Transform"))
:description (or transform-description "")
:source {:type (name source-type)
:query template
:source-database source-database
:source-tables (or source-tables {})}
:target {:type "table"
:name ""}}))
;;; Write Transform SQL Tool
(defn write-transform-sql
"Write or edit SQL transform source code.
Parameters:
- transform-id: Optional ID of existing transform to edit
- edit-action: Either {:mode \"edit\" :edits [...]} or {:mode \"replace\" :new-content \"...\"}
- thinking: Explanation of what changes are being made
- transform-name: Name for new transform (required if no transform-id)
- transform-description: Description for new transform
- source-database: Database ID for source tables
- source-tables: Map of table aliases to table IDs
- memory-atom: Atom containing agent memory (injected by wrapper)
- context: Request context with current transform if editing
Returns map with:
- :structured-output - The suggested transform
- :data-parts - Transform suggestion data part for streaming"
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables memory-atom context]}]
(log/info "Writing SQL transform" {:transform-id transform_id
:edit-mode (:mode edit_action)
:has-context (some? context)})
(let [;; Get current transform from context or memory, or create fresh one
current-transform (cond
;; Try to get from context first
(and transform_id context)
(get-in context [:transforms (str transform_id)])
;; Then try memory
(and transform_id memory-atom)
(get-in @memory-atom [:state :transforms (str transform_id)])
;; Create fresh transform
:else
(create-fresh-transform :sql transform_name transform_description
source_database source_tables))
_ (when (and transform_id (nil? current-transform))
(throw (ex-info (str "Transform with ID " transform_id " not found. "
"Use search to find available transforms.")
{:agent-error? true
:transform-id transform_id})))
current-sql (get-in current-transform [:source :query] "")
;; Apply edits based on mode
new-sql (case (:mode edit_action)
"edit" (apply-edits current-sql (:edits edit_action))
"replace" (:new_content edit_action)
;; Default to replace if no mode specified but new_content provided
(if (:new_content edit_action)
(:new_content edit_action)
(throw (ex-info "Invalid edit_action: must specify mode 'edit' with edits or mode 'replace' with new_content"
{:agent-error? true
:edit-action edit_action}))))
;; Build suggested transform
suggested-transform (cond-> current-transform
true (assoc-in [:source :query] new-sql)
transform_name (assoc :name transform_name)
transform_description (assoc :description transform_description)
source_database (assoc-in [:source :source-database] source_database)
source_tables (assoc-in [:source :source-tables] source_tables))]
;; Store in memory if we have an ID
(when (and transform_id memory-atom)
(swap! memory-atom assoc-in [:state :transforms (str transform_id)] suggested-transform))
(log/debug "SQL transform written" {:transform-id transform_id
:sql-length (count new-sql)})
{:structured-output {:transform suggested-transform
:thinking thinking
:message "Transform SQL updated successfully."}
:data-parts [(streaming/transform-suggestion-part suggested-transform)]
:instructions "The transform suggestion has been created and displayed to the user. Do not repeat the SQL content."}))
;;; Write Transform Python Tool
(defn write-transform-python
"Write or edit Python transform source code.
Parameters:
- transform-id: Optional ID of existing transform to edit
- edit-action: Either {:mode \"edit\" :edits [...]} or {:mode \"replace\" :new-content \"...\"}
- thinking: Explanation of what changes are being made
- transform-name: Name for new transform (required if no transform-id)
- transform-description: Description for new transform
- source-database: Database ID for source tables
- source-tables: Map of table aliases to table IDs
- memory-atom: Atom containing agent memory (injected by wrapper)
- context: Request context with current transform if editing
Returns map with:
- :structured-output - The suggested transform
- :data-parts - Transform suggestion data part for streaming"
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables memory-atom context]}]
(log/info "Writing Python transform" {:transform-id transform_id
:edit-mode (:mode edit_action)
:has-context (some? context)})
(let [;; Get current transform from context or memory, or create fresh one
current-transform (cond
;; Try to get from context first
(and transform_id context)
(get-in context [:transforms (str transform_id)])
;; Then try memory
(and transform_id memory-atom)
(get-in @memory-atom [:state :transforms (str transform_id)])
;; Create fresh transform
:else
(create-fresh-transform :python transform_name transform_description
source_database source_tables))
_ (when (and transform_id (nil? current-transform))
(throw (ex-info (str "Transform with ID " transform_id " not found. "
"Use search to find available transforms.")
{:agent-error? true
:transform-id transform_id})))
current-python (get-in current-transform [:source :query] "")
;; Apply edits based on mode
new-python (case (:mode edit_action)
"edit" (apply-edits current-python (:edits edit_action))
"replace" (:new_content edit_action)
;; Default to replace if no mode specified but new_content provided
(if (:new_content edit_action)
(:new_content edit_action)
(throw (ex-info "Invalid edit_action: must specify mode 'edit' with edits or mode 'replace' with new_content"
{:agent-error? true
:edit-action edit_action}))))
;; Build suggested transform
suggested-transform (cond-> current-transform
true (assoc-in [:source :query] new-python)
true (assoc-in [:source :type] "python")
transform_name (assoc :name transform_name)
transform_description (assoc :description transform_description)
source_database (assoc-in [:source :source-database] source_database)
source_tables (assoc-in [:source :source-tables] source_tables))]
;; Store in memory if we have an ID
(when (and transform_id memory-atom)
(swap! memory-atom assoc-in [:state :transforms (str transform_id)] suggested-transform))
(log/debug "Python transform written" {:transform-id transform_id
:python-length (count new-python)})
{:structured-output {:transform suggested-transform
:thinking thinking
:message "Transform Python code updated successfully."}
:data-parts [(streaming/transform-suggestion-part suggested-transform)]
:instructions "The transform suggestion has been created and displayed to the user. Do not repeat the Python content."}))

View File

@@ -11,6 +11,7 @@
[metabase.lib.types.isa :as lib.types.isa]
[metabase.premium-features.core :as premium-features]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(defn handle-agent-error
@@ -98,7 +99,7 @@
(parse-field-id nil) => nil
(parse-field-id \"invalid\") => nil"
[field-id]
(when (string? field-id)
(when (and field-id (string? field-id))
(when-let [[_ model-tag model-id field-index] (re-matches #"^([tcq])(.+)-(\d+)$" field-id)]
{:model-tag model-tag
;; For tables and cards, model-id should be numeric; for queries it's a nano-id string
@@ -128,6 +129,11 @@
(when-not (if (string? expected-prefix)
(str/starts-with? field-id expected-prefix)
(re-matches expected-prefix field-id))
(log/warn "Field id prefix mismatch"
{:field-id field-id
:expected-prefix expected-prefix
:model-tag model-tag
:model-id model-id})
(throw (ex-info (str "field " field-id " does not match expected prefix " expected-prefix)
{:agent-error? true
:status-code 400
@@ -169,7 +175,7 @@
api/read-check))
(defn card-query
"Return a query based on the model with ID `model-id`."
"Return a query based on the card with ID `card-id`."
[card-id]
(when-let [card (get-card card-id)]
(let [mp (lib-be/application-database-metadata-provider (:database_id card))]
@@ -178,7 +184,7 @@
(#{:question} (:type card)) (get :dataset-query))))))
(defn metric-query
"Return a query based on the model with ID `model-id`."
"Return a query based on the metric with ID `metric-id`."
[metric-id]
(when-let [card (get-card metric-id)]
(let [mp (lib-be/application-database-metadata-provider (:database_id card))]

View File

@@ -1,10 +1,14 @@
(ns metabase-enterprise.metabot-v3.util
(:require
[clojure.data.xml :as xml]
[clojure.set :as set]
[clojure.string :as str]
[clojure.walk :as walk]
[metabase.util :as u]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(defn- safe-case-updater
[f]
#(cond-> % (or (string? %) (keyword? %)) f))
@@ -73,5 +77,19 @@
:finish_reason (:finishReason v)
:usage (-> (:usage v)
(dissoc :promptTokens :completionTokens))})
block))))))
block)
;; START_STEP and FINISH_STEP are metadata, not stored as messages
(:START_STEP
:FINISH_STEP) [])))))
lines))
(defn xml
"Format hiccup-like data structure to an XML string"
[& bits]
(let [fmt (fn [v]
(let [res ^String (xml/indent-str (xml/sexp-as-element v))]
(cond-> res
;; strip preamble
(str/starts-with? res "<?xml") (subs (inc (.indexOf res "\n"))))))]
(->> (map fmt bits)
(str/join "\n"))))

View File

@@ -0,0 +1,117 @@
(ns metabase-enterprise.oauth-server.admin-api
"Admin-only CRUD endpoints for managing OAuth clients."
(:require
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.util.malli.schema :as ms]
[oidc-provider.util :as oidc-util]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(def ^:private OAuthClientResponse
"Malli schema for an OAuth client in API responses (no secrets)."
[:map
[:id ms/PositiveInt]
[:client_id ms/NonBlankString]
[:redirect_uris [:sequential ms/NonBlankString]]
[:grant_types [:sequential ms/NonBlankString]]
[:response_types [:sequential ms/NonBlankString]]
[:scopes [:sequential :string]]
[:client_name {:optional true} [:maybe :string]]
[:registration_type ms/NonBlankString]
[:created_at {:optional true} :any]
[:updated_at {:optional true} :any]])
(def ^:private OAuthClientCreateResponse
"Malli schema for the POST response — includes the plaintext secret (shown once)."
[:merge
OAuthClientResponse
[:map
[:client_secret ms/NonBlankString]]])
(def ^:private client-public-keys
"Keys safe to return in API responses (no secret hashes)."
[:id :client_id :redirect_uris :grant_types :response_types :scopes
:client_name :registration_type :created_at :updated_at])
(defn- sanitize-client
"Remove secret-related fields from a client row for API responses."
[client]
(select-keys client client-public-keys))
(api.macros/defendpoint :post "/clients" :- OAuthClientCreateResponse
"Register a new static OAuth client. Returns the client with the plaintext secret (shown only once)."
[_route-params
_query-params
{:keys [redirect_uris client_name grant_types response_types scopes]}
:- [:map
[:redirect_uris [:sequential ms/NonBlankString]]
[:client_name {:optional true} [:maybe ms/NonBlankString]]
[:grant_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:response_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:scopes {:optional true} [:maybe [:sequential ms/NonBlankString]]]]]
(let [client-id (str (java.util.UUID/randomUUID))
client-secret (oidc-util/generate-client-secret)
secret-hash (oidc-util/hash-client-secret client-secret)
grant-types (or (not-empty grant_types) ["authorization_code"])
response-types (or (not-empty response_types) ["code"])
scopes (or (not-empty scopes) ["openid" "profile"])
row {:client_id client-id
:client_type "confidential"
:client_secret_hash secret-hash
:redirect_uris redirect_uris
:grant_types grant-types
:response_types response-types
:scopes scopes
:client_name client_name
:registration_type "static"}
[inserted] (t2/insert-returning-instances! :model/OAuthClient row)]
(-> (sanitize-client inserted)
(assoc :client_secret client-secret))))
(api.macros/defendpoint :get "/clients" :- [:sequential OAuthClientResponse]
"List all OAuth clients. Supports optional `?registration_type=static|dynamic` filter."
[_route-params
{:keys [registration_type]} :- [:map
[:registration_type {:optional true} [:maybe ms/NonBlankString]]]]
(let [clients (if registration_type
(t2/select :model/OAuthClient :registration_type registration_type)
(t2/select :model/OAuthClient))]
(mapv sanitize-client clients)))
(api.macros/defendpoint :get "/clients/:id" :- OAuthClientResponse
"Get a single OAuth client by its database ID."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(-> (t2/select-one :model/OAuthClient :id id)
api/check-404
sanitize-client))
(api.macros/defendpoint :put "/clients/:id" :- OAuthClientResponse
"Update an OAuth client. Cannot change client_id or client_secret."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
body :- [:map
[:client_name {:optional true} [:maybe ms/NonBlankString]]
[:redirect_uris {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:grant_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:response_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:scopes {:optional true} [:maybe [:sequential ms/NonBlankString]]]]]
(api/check-404 (t2/select-one :model/OAuthClient :id id))
(let [updates (into {} (filter (comp some? val)) (select-keys body [:client_name :redirect_uris :grant_types :response_types :scopes]))]
(when (seq updates)
(t2/update! :model/OAuthClient id updates))
(-> (t2/select-one :model/OAuthClient :id id)
sanitize-client)))
(api.macros/defendpoint :delete "/clients/:id" :- :nil
"Delete an OAuth client."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(api/check-404 (t2/select-one :model/OAuthClient :id id))
(t2/delete! :model/OAuthClient :id id)
nil)
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/oauth-server` admin routes."
(api.macros/ns-handler *ns* api/+check-superuser +auth))

View File

@@ -0,0 +1,318 @@
(ns metabase-enterprise.oauth-server.api
"EE implementations of OAuth/OIDC endpoint handlers."
(:require
[clojure.string :as str]
[clojure.walk :as walk]
[hiccup2.core :as h]
[metabase-enterprise.oauth-server.core :as oauth-server]
[metabase.appearance.settings :as appearance.settings]
[metabase.premium-features.core :refer [defenterprise]]
[metabase.system.core :as system]
[oidc-provider.core :as oidc]
[oidc-provider.protocol :as proto]
[oidc-provider.registration :as reg]
[oidc-provider.util :as oidc-util])
(:import
(java.net URLEncoder)))
(defenterprise openid-discovery-handler
"Returns the OIDC discovery document."
:feature :metabot-v3
[_request]
(when-let [provider (oauth-server/get-provider)]
{:status 200
:headers {"Content-Type" "application/json"}
:body (oidc/discovery-metadata provider)}))
(defenterprise jwks-handler
"Returns the JWKS."
:feature :metabot-v3
[_request]
(when-let [provider (oauth-server/get-provider)]
{:status 200
:headers {"Content-Type" "application/json"}
:body (oidc/jwks provider)}))
(defn- extract-registration-body
"Extract the registration request body as a string-keyed map.
Metabase's wrap-json-body middleware parses JSON into keyword maps,
but the oidc-provider library expects string keys."
[request]
(let [body (:body request)]
(when (map? body)
(walk/stringify-keys body))))
(defn- extract-bearer-token
"Extract the bearer token from the Authorization header."
[request]
(when-let [auth (get-in request [:headers "authorization"])]
(when (str/starts-with? (str/lower-case auth) "bearer ")
(str/trim (subs auth 7)))))
(defenterprise dynamic-register-handler
"Handles dynamic client registration (RFC 7591)."
:feature :metabot-v3
[request]
(when-let [provider (oauth-server/get-provider)]
(let [body (extract-registration-body request)]
(if (nil? body)
{:status 400
:headers {"Content-Type" "application/json"}
:body {"error" "invalid_client_metadata"
"error_description" "Invalid or missing JSON body"}}
(try
(let [response (oidc/dynamic-register-client provider body)
client-id (get response "client_id")]
;; Mark as dynamically registered (the library doesn't know about registration_type)
(proto/update-client (:client-store provider) client-id {:registration-type "dynamic"})
{:status 201
:headers {"Content-Type" "application/json"}
:body response})
(catch clojure.lang.ExceptionInfo e
(reg/registration-error-response
(ex-message e)
(:error_description (ex-data e)))))))))
(defenterprise dynamic-client-read-handler
"Handles client configuration read (RFC 7592)."
:feature :metabot-v3
[request client-id]
(when-let [provider (oauth-server/get-provider)]
(let [token (extract-bearer-token request)]
(if (str/blank? token)
{:status 401
:headers {"Content-Type" "application/json"}
:body {"error" "invalid_token"}}
(let [client (proto/get-client (:client-store provider) client-id)]
(if (and client
(:registration-access-token-hash client)
(oidc-util/verify-client-secret token (:registration-access-token-hash client)))
{:status 200
:headers {"Content-Type" "application/json"}
:body {"client_id" (:client-id client)
"redirect_uris" (:redirect-uris client)
"grant_types" (:grant-types client)
"response_types" (:response-types client)
"token_endpoint_auth_method" (or (:token-endpoint-auth-method client) "none")
"scope" (when (seq (:scopes client))
(str/join " " (:scopes client)))}}
{:status 401
:headers {"Content-Type" "application/json"}
:body {"error" "invalid_token"}}))))))
(defn- build-query-string
"Build a URL query string from a map of parameters.
Handles vector values by emitting multiple key=value pairs (RFC 8707 resource parameter)."
[params]
(->> params
(remove (fn [[_k v]] (nil? v)))
(mapcat (fn [[k v]]
(let [values (if (sequential? v) v [v])]
(map (fn [val] (str (name k) "=" (URLEncoder/encode (str val) "UTF-8")))
values))))
(str/join "&")))
(defn- login-redirect-url
"Build a redirect URL to the login page that will redirect back to the given path after login."
[request]
(let [site-url (system/site-url)
uri (:uri request)
query (:query-string request)
return-path (if query (str uri "?" query) uri)
redirect (str site-url "/auth/login?redirect=" (URLEncoder/encode return-path "UTF-8"))]
redirect))
(defn- absolute-url
"Resolve a potentially relative path to an absolute URL using site-url."
[path]
(if (or (str/starts-with? path "http://")
(str/starts-with? path "https://"))
path
(str (system/site-url) "/" path)))
(defn- font-face-css
"Generate @font-face CSS rules for the given font, loading woff2 files from Metabase's
bundled font directory. Returns an empty string for custom (non-bundled) fonts since
they are loaded via application-font-files."
[font-name]
(let [dir-name (str/replace font-name " " "_")
file-stem (str/replace font-name " " "")]
(if (= font-name "Lato")
(str "@font-face { font-family: 'Lato'; font-weight: 400; font-style: normal; font-display: swap;"
" src: url('/app/fonts/Lato/lato-v16-latin-regular.woff2') format('woff2'); }\n"
"@font-face { font-family: 'Lato'; font-weight: 700; font-style: normal; font-display: swap;"
" src: url('/app/fonts/Lato/lato-v16-latin-700.woff2') format('woff2'); }\n")
(str "@font-face { font-family: '" font-name "'; font-weight: 400; font-style: normal; font-display: swap;"
" src: url('/app/fonts/" dir-name "/" file-stem "-Regular.woff2') format('woff2'); }\n"
"@font-face { font-family: '" font-name "'; font-weight: 700; font-style: normal; font-display: swap;"
" src: url('/app/fonts/" dir-name "/" file-stem "-Bold.woff2') format('woff2'); }\n"))))
(defn- appearance-settings
"Return a map of appearance settings for the consent page."
[]
(let [colors (appearance.settings/application-colors)]
{:font-family (appearance.settings/application-font)
:logo-url (absolute-url (appearance.settings/application-logo-url))
:brand-color (get colors "brand" "#509ee3")}))
(defn- render-consent-page
"Render a server-side HTML consent page for the OAuth authorization flow."
[{:keys [client-name scopes oauth-params nonce]}]
(let [{:keys [font-family logo-url brand-color]} (appearance-settings)]
(str
"<!DOCTYPE html>"
(h/html
[:html {:lang "en"}
[:head
[:meta {:charset "UTF-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1.0"}]
[:title "Authorize " client-name]
[:style {:nonce nonce} (h/raw
(str
(font-face-css font-family)
"* { box-sizing: border-box; margin: 0; padding: 0; }
html { height: 100%; }
body { font-family: '" font-family "', 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex; justify-content: center; align-items: center;
min-height: 100%; background: #f9fbfc; color: #4c5773; }
.consent { background: #fff; border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.05);
padding: 2.5rem; max-width: 440px; width: 100%; margin: 1rem; }
.logo { display: flex; justify-content: center; margin-bottom: 1.5rem; }
h1 { font-size: 1.25rem; font-weight: 700; color: #2e353b; text-align: center; margin-bottom: 0.5rem; }
.subtitle { text-align: center; font-size: 0.875rem; line-height: 1.5; color: #696e7b; margin-bottom: 1.5rem; }
.scope-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
color: #949aab; margin-bottom: 0.5rem; }
.scopes { list-style: none; background: #f9fbfc; border-radius: 6px; border: 1px solid #eeecec;
padding: 0; margin-bottom: 1.5rem; }
.scopes li { padding: 0.625rem 0.875rem; font-size: 0.875rem; color: #4c5773;
border-bottom: 1px solid #eeecec; }
.scopes li:last-child { border-bottom: none; }
.actions { display: flex; gap: 0.75rem; }
button { flex: 1; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.875rem; font-weight: 700;
font-family: inherit; cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, filter 0.15s ease; }
.allow { background: " brand-color "; color: #fff; border: 1px solid " brand-color "; }
.allow:hover { filter: brightness(0.9); }
.deny { background: #fff; color: #4c5773; border: 1px solid #ddd; }
.deny:hover { background: #f9fbfc; border-color: #ccc; }"))]]
[:body
[:div.consent
[:div.logo [:img {:src logo-url :alt "Logo" :height "32"}]]
[:h1 "Authorize " client-name]
[:p.subtitle "This application is requesting permission to access your Metabase account."]
(when (seq scopes)
[:div
[:p.scope-label "Permissions requested"]
[:ul.scopes
(for [scope scopes]
[:li scope])]])
[:form {:method "POST" :action "/oauth/authorize/decision"}
(for [[k v] oauth-params
:when (some? v)
v (if (sequential? v) v [v])]
[:input {:type "hidden" :name (name k) :value v}])
[:div.actions
[:button.deny {:type "submit" :name "approved" :value "false"} "Deny"]
[:button.allow {:type "submit" :name "approved" :value "true"} "Allow"]]]]]]))))
(defenterprise authorize-handler
"Handles the authorization endpoint (GET /oauth/authorize)."
:feature :metabot-v3
[request]
(if-not (:metabase-user-id request)
{:status 302
:headers {"Location" (login-redirect-url request)}
:body ""}
(when-let [provider (oauth-server/get-provider)]
(try
(let [parsed (oidc/parse-authorization-request provider (:query-string request))
client (proto/get-client (:client-store provider) (:client_id parsed))]
{:status 200
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (render-consent-page
{:client-name (or (:client-name client) "Unknown Application")
:scopes (or (:scopes client) [])
:nonce (:nonce request)
:oauth-params {:client_id (:client_id parsed)
:redirect_uri (:redirect_uri parsed)
:response_type (:response_type parsed)
:scope (:scope parsed)
:state (:state parsed)
:nonce (:nonce parsed)
:code_challenge (:code_challenge parsed)
:code_challenge_method (:code_challenge_method parsed)
:resource (:resource parsed)}})})
(catch clojure.lang.ExceptionInfo e
{:status 400
:headers {"Content-Type" "application/json"}
:body {:error "invalid_request"
:error_description (ex-message e)}})))))
(defenterprise authorize-decision-handler
"Handles the authorization decision (POST /oauth/authorize/decision).
Accepts form-encoded params from the consent page."
:feature :metabot-v3
[request]
(if-not (:metabase-user-id request)
{:status 401
:headers {"Content-Type" "application/json"}
:body {:error "unauthorized"}}
(when-let [provider (oauth-server/get-provider)]
(let [params (:params request)
approved (= "true" (str (:approved params)))
;; Rebuild query string from the forwarded authorization params to re-validate
auth-params (select-keys params [:client_id :redirect_uri :response_type :scope :state :nonce
:code_challenge :code_challenge_method :resource])
query-string (build-query-string auth-params)]
(try
(let [parsed (oidc/parse-authorization-request provider query-string)]
(if approved
(let [url (oidc/authorize provider parsed (str (:metabase-user-id request)))]
{:status 302
:headers {"Location" url}
:body ""})
(let [url (oidc/deny-authorization provider parsed "access_denied" "User denied the request")]
{:status 302
:headers {"Location" url}
:body ""})))
(catch clojure.lang.ExceptionInfo e
{:status 400
:headers {"Content-Type" "application/json"}
:body {:error "invalid_request"
:error_description (ex-message e)}}))))))
(defenterprise protected-resource-metadata-handler
"Returns OAuth Protected Resource Metadata (RFC 9728)."
:feature :metabot-v3
[_request]
(let [site-url (system/site-url)]
{:status 200
:headers {"Content-Type" "application/json"}
:body {:resource (str site-url "/api/mcp")
:authorization_servers [site-url]
:scopes_supported oauth-server/all-agent-scopes
:bearer_methods_supported ["header"]}}))
(defenterprise token-handler
"Handles the token endpoint (POST /oauth/token)."
:feature :metabot-v3
[request]
(when-let [provider (oauth-server/get-provider)]
(let [params (:params request)
authorization-header (get-in request [:headers "authorization"])]
(try
(let [response (oidc/token-request provider params authorization-header)]
{:status 200
:headers {"Content-Type" "application/json"
"Cache-Control" "no-store"
"Pragma" "no-cache"}
:body response})
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)]
{:status (if (= (:error data) "invalid_client") 401 400)
:headers {"Content-Type" "application/json"
"Cache-Control" "no-store"
"Pragma" "no-cache"}
:body {:error (or (:error data) "invalid_request")
:error_description (ex-message e)}}))))))

View File

@@ -0,0 +1,92 @@
(ns metabase-enterprise.oauth-server.core
(:require
[metabase-enterprise.oauth-server.settings :as oauth-settings]
[metabase-enterprise.oauth-server.store :as store]
[metabase.premium-features.core :as premium-features]
[metabase.system.core :as system]
[oidc-provider.core :as oidc]
[oidc-provider.token :as oidc-token])
(:import
(com.nimbusds.jose.jwk RSAKey)))
(set! *warn-on-reflection* true)
(defonce ^:private provider (atom nil))
(defn- serialize-key
"Serialize an RSAKey to a JSON string for storage."
[^RSAKey k]
(.toJSONString k))
(defn- deserialize-key
"Deserialize a JSON string back to an RSAKey."
^RSAKey [^String s]
(RSAKey/parse s))
(defn- get-or-generate-signing-key!
"Returns the RSA signing key. Reads from settings if persisted, otherwise generates
a new key and persists it."
[]
(if-let [stored (oauth-settings/oauth-server-signing-key)]
(deserialize-key stored)
(let [k (oidc-token/generate-rsa-key)]
(oauth-settings/oauth-server-signing-key! (serialize-key k))
k)))
;; TODO(edpaget): these should be replaced by dynamic scope discovery
(def base-scopes
"Base oidc scopes"
["openid" "profile" "email"])
(def all-agent-scopes
"All supported OAuth scopes for the MCP/agent API."
["agent:table:read"
"agent:metric:read"
"agent:search"
"agent:query:construct"
"agent:query:execute"
"agent:workspace:read"
"agent:workspace:write"
"agent:workspace:execute"])
(defn- build-provider-config
"Build the configuration map for the OIDC provider from Metabase settings."
[signing-key]
(let [base-url (system/site-url)]
{:issuer base-url
:authorization-endpoint (str base-url "/oauth/authorize")
:token-endpoint (str base-url "/oauth/token")
:jwks-uri (str base-url "/oauth/jwks")
:registration-endpoint (str base-url "/oauth/register")
:signing-key signing-key
:access-token-ttl-seconds (oauth-settings/oauth-server-access-token-ttl)
:id-token-ttl-seconds (oauth-settings/oauth-server-id-token-ttl)
:authorization-code-ttl-seconds (oauth-settings/oauth-server-authorization-code-ttl)
:client-store (store/create-client-store)
:code-store (store/create-authorization-code-store)
:token-store (store/create-token-store)
:scopes-supported (into base-scopes all-agent-scopes)
:claims-provider (store/create-claims-provider)}))
(defn create-provider!
"Create and store the OIDC provider instance. Returns nil if the :metabot-v3 feature
flag is not enabled."
[]
(when (premium-features/enable-metabot-v3?)
(let [signing-key (get-or-generate-signing-key!)
config (build-provider-config signing-key)
p (oidc/create-provider config)]
(reset! provider p)
p)))
(defn get-provider
"Returns the current provider instance, creating it lazily if needed.
Returns nil if the :metabot-v3 feature flag is not enabled."
[]
(or @provider
(create-provider!)))
(defn reset-provider!
"Reset the provider atom to nil. Useful for testing."
[]
(reset! provider nil))

View File

@@ -0,0 +1,3 @@
(ns metabase-enterprise.oauth-server.init
(:require
[metabase-enterprise.oauth-server.settings]))

View File

@@ -0,0 +1,14 @@
(ns metabase-enterprise.oauth-server.models.oauth-access-token
(:require
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/OAuthAccessToken [_model] :oauth_access_token)
(doto :model/OAuthAccessToken
(derive :metabase/model))
(t2/deftransforms :model/OAuthAccessToken
{:scope mi/transform-json
:resource mi/transform-json})

View File

@@ -0,0 +1,14 @@
(ns metabase-enterprise.oauth-server.models.oauth-authorization-code
(:require
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/OAuthAuthorizationCode [_model] :oauth_authorization_code)
(doto :model/OAuthAuthorizationCode
(derive :metabase/model))
(t2/deftransforms :model/OAuthAuthorizationCode
{:scope mi/transform-json
:resource mi/transform-json})

View File

@@ -0,0 +1,18 @@
(ns metabase-enterprise.oauth-server.models.oauth-client
(:require
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/OAuthClient [_model] :oauth_client)
(doto :model/OAuthClient
(derive :metabase/model)
(derive :hook/timestamped?))
(t2/deftransforms :model/OAuthClient
{:redirect_uris mi/transform-json
:grant_types mi/transform-json
:response_types mi/transform-json
:scopes mi/transform-json
:contacts mi/transform-json})

View File

@@ -0,0 +1,14 @@
(ns metabase-enterprise.oauth-server.models.oauth-refresh-token
(:require
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/OAuthRefreshToken [_model] :oauth_refresh_token)
(doto :model/OAuthRefreshToken
(derive :metabase/model))
(t2/deftransforms :model/OAuthRefreshToken
{:scope mi/transform-json
:resource mi/transform-json})

View File

@@ -0,0 +1,44 @@
(ns metabase-enterprise.oauth-server.settings
(:require
[metabase.settings.core :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru]]))
(defsetting oauth-server-signing-key
(deferred-tru "Serialized RSA signing key for the embedded OAuth/OIDC provider.")
:type :string
:encryption :when-encryption-key-set
:sensitive? true
:visibility :internal
:feature :metabot-v3
:doc false
:export? false)
(defsetting oauth-server-access-token-ttl
(deferred-tru "Access token time-to-live in seconds for the embedded OAuth server.")
:type :integer
:visibility :internal
:default 3600
:feature :metabot-v3
:doc false
:export? false
:audit :never)
(defsetting oauth-server-id-token-ttl
(deferred-tru "ID token time-to-live in seconds for the embedded OAuth server.")
:type :integer
:visibility :internal
:default 3600
:feature :metabot-v3
:doc false
:export? false
:audit :never)
(defsetting oauth-server-authorization-code-ttl
(deferred-tru "Authorization code time-to-live in seconds for the embedded OAuth server.")
:type :integer
:visibility :internal
:default 600
:feature :metabot-v3
:doc false
:export? false
:audit :never)

View File

@@ -0,0 +1,235 @@
(ns metabase-enterprise.oauth-server.store
"Database-backed implementations of OIDC provider storage protocols."
(:require
[metabase.util :as u]
[oidc-provider.protocol :as proto]
[oidc-provider.util :as oidc-util]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
;;; ------------------------------------------------- Helpers ----------------------------------------------------------
(defn- parse-user-id
"Parse a user-id string to an integer, returning nil if not a valid integer.
The oidc-provider library passes user-id as a string. For authorization_code and
refresh_token grants this is a stringified Metabase user ID. For client_credentials
grants the library passes the client-id string, which is not a valid integer."
[user-id]
(when user-id
(try
(Integer/parseInt (str user-id))
(catch NumberFormatException _
nil))))
(def ^:private client-db-columns
"DB columns to select/project for OAuthClient rows."
[:client_id :client_type :redirect_uris :grant_types :response_types :scopes :registration_type
:client_secret_hash :token_endpoint_auth_method :client_name :client_uri :logo_uri
:contacts :registration_access_token_hash])
(defn- select-and-kebab-keys
"Select `ks` from `row`, convert keys to kebab-case, and remove nil values."
[row ks]
(-> (select-keys row ks)
(update-keys u/->kebab-case-en)
(->> (into {} (remove (comp nil? val))))))
(defn- kebab->snake-keys
"Convert all keys in `m` from kebab-case to snake_case."
[m]
(update-keys m u/->snake_case_en))
(defn- db-row->client-config
"Convert a DB row from :model/OAuthClient to the protocol's ClientConfig shape."
[row]
(when row
(select-and-kebab-keys row client-db-columns)))
(defn- client-config->db-row
"Convert a protocol ClientConfig map to DB column format for insert/update."
[config]
(-> config
(update :registration-type #(or % "static"))
(select-keys (map u/->kebab-case-en client-db-columns))
kebab->snake-keys))
(def ^:private auth-code-db-columns
[:user_id :client_id :redirect_uri :scope :nonce :expiry
:code_challenge :code_challenge_method :resource])
(defn- db-row->auth-code
"Convert a DB row from :model/OAuthAuthorizationCode to the protocol's map shape.
Converts user-id to a string since the oidc-provider library expects string user IDs.
Ensures :resource is a vector (JSON deserialization may return a list)."
[row]
(when row
(let [m (-> (select-and-kebab-keys row auth-code-db-columns)
(update :user-id #(some-> % str)))]
(cond-> m
(:resource m) (update :resource vec)))))
(def ^:private access-token-db-columns
[:user_id :client_id :scope :expiry :resource])
(defn- db-row->access-token
"Convert a DB row from :model/OAuthAccessToken to the protocol's map shape."
[row]
(when row
(let [m (-> (select-and-kebab-keys row access-token-db-columns)
(update :user-id #(some-> % str)))]
(cond-> m
(:resource m) (update :resource vec)))))
(def ^:private refresh-token-db-columns
[:user_id :client_id :scope :expiry :resource])
(defn- db-row->refresh-token
"Convert a DB row from :model/OAuthRefreshToken to the protocol's map shape."
[row]
(when row
(let [m (-> (select-and-kebab-keys row refresh-token-db-columns)
(update :user-id #(some-> % str)))]
(cond-> m
(:resource m) (update :resource vec)))))
;;; ------------------------------------------------ ClientStore -------------------------------------------------------
(defrecord DbClientStore []
proto/ClientStore
(get-client [_ client-id]
(-> (t2/select-one :model/OAuthClient :client_id client-id)
db-row->client-config))
(register-client [_ client-config]
(let [client-id (or (:client-id client-config)
(str (java.util.UUID/randomUUID)))
secret (:client-secret client-config)
rat (:registration-access-token client-config)
secret-hash (when secret (oidc-util/hash-client-secret secret))
rat-hash (when rat (oidc-util/hash-client-secret rat))
config (-> client-config
(assoc :client-id client-id)
(cond-> secret-hash (assoc :client-secret-hash secret-hash)
rat-hash (assoc :registration-access-token-hash rat-hash))
(dissoc :client-secret :registration-access-token))
row (client-config->db-row config)]
(t2/insert! :model/OAuthClient row)
;; Return config with plaintext values preserved for response building
;; (e.g. handle-registration-request needs them for the wire response).
;; These are NOT stored in the DB — only hashes are persisted.
(cond-> config
secret (assoc :client-secret secret)
rat (assoc :registration-access-token rat))))
(update-client [_ client-id updated-config]
(let [existing (t2/select-one :model/OAuthClient :client_id client-id)]
(when existing
(let [existing-config (db-row->client-config existing)
merged (-> (merge existing-config updated-config)
(assoc :client-id client-id))
row (client-config->db-row merged)]
(t2/update! :model/OAuthClient (:id existing) row)
merged)))))
;;; ----------------------------------------- AuthorizationCodeStore ---------------------------------------------------
(defrecord DbAuthorizationCodeStore []
proto/AuthorizationCodeStore
(save-authorization-code [_ code user-id client-id redirect-uri scope nonce expiry code-challenge code-challenge-method resource]
(t2/insert! :model/OAuthAuthorizationCode
(cond-> {:code code
:user_id (parse-user-id user-id)
:client_id client-id
:redirect_uri redirect-uri
:scope (vec scope)
:nonce nonce
:expiry expiry}
code-challenge (assoc :code_challenge code-challenge)
code-challenge-method (assoc :code_challenge_method code-challenge-method)
resource (assoc :resource (vec resource))))
true)
(get-authorization-code [_ code]
(-> (t2/select-one :model/OAuthAuthorizationCode :code code)
db-row->auth-code))
(delete-authorization-code [_ code]
(t2/delete! :model/OAuthAuthorizationCode :code code)
true))
;;; ------------------------------------------------ TokenStore --------------------------------------------------------
(defrecord DbTokenStore []
proto/TokenStore
(save-access-token [_ token user-id client-id scope expiry resource]
(t2/insert! :model/OAuthAccessToken
(cond-> {:token token
:user_id (parse-user-id user-id)
:client_id client-id
:scope (vec scope)
:expiry expiry}
resource (assoc :resource (vec resource))))
true)
(get-access-token [_ token]
(-> (t2/select-one :model/OAuthAccessToken :token token :revoked_at nil)
db-row->access-token))
(save-refresh-token [_ token user-id client-id scope expiry resource]
(t2/insert! :model/OAuthRefreshToken
(cond-> {:token token
:user_id (parse-user-id user-id)
:client_id client-id
:scope (vec scope)}
expiry (assoc :expiry expiry)
resource (assoc :resource (vec resource))))
true)
(get-refresh-token [_ token]
(-> (t2/select-one :model/OAuthRefreshToken :token token :revoked_at nil)
db-row->refresh-token))
(revoke-token [_ token]
(let [now (java.time.OffsetDateTime/now)]
(t2/update! :model/OAuthAccessToken {:token token} {:revoked_at now})
(t2/update! :model/OAuthRefreshToken {:token token} {:revoked_at now}))
true))
;;; ---------------------------------------------- ClaimsProvider ------------------------------------------------------
(defrecord MetabaseClaimsProvider []
proto/ClaimsProvider
(get-claims [_ user-id scope]
(let [scope-set (set scope)
user (t2/select-one [:model/User :id :first_name :last_name :email] :id (parse-user-id user-id))]
(when user
(cond-> {:sub (str user-id)}
(scope-set "profile")
(assoc :name (str (:first_name user) " " (:last_name user))
:preferred_username (:email user))
(scope-set "email")
(assoc :email (:email user)
:email_verified true))))))
;;; ------------------------------------------------ Constructors ------------------------------------------------------
(defn create-client-store
"Creates a database-backed client store."
[]
(->DbClientStore))
(defn create-authorization-code-store
"Creates a database-backed authorization code store."
[]
(->DbAuthorizationCodeStore))
(defn create-token-store
"Creates a database-backed token store."
[]
(->DbTokenStore))
(defn create-claims-provider
"Creates a Metabase-backed claims provider."
[]
(->MetabaseClaimsProvider))

View File

@@ -2,7 +2,6 @@
(:require
[clj-http.client :as http]
[clojure.string :as str]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.semantic-search.models.token-tracking :as semantic.models.token-tracking]
[metabase-enterprise.semantic-search.settings :as semantic-settings]
[metabase.analytics.core :as analytics]
@@ -12,7 +11,8 @@
[metabase.util.log :as log])
(:import
[com.knuddels.jtokkit Encodings]
[com.knuddels.jtokkit.api Encoding EncodingType]))
[com.knuddels.jtokkit.api Encoding EncodingType]
[java.net ConnectException]))
(set! *warn-on-reflection* true)
@@ -44,8 +44,8 @@
(defn abbrev-provider-name
"Abbreviate long provider names for use in index names."
[provider-name]
(if (= provider-name "ai-service")
"ais"
(case provider-name
"ai-service" "ais"
(clean-provider-name provider-name)))
;;; Token Counting for OpenAI Models
@@ -66,22 +66,20 @@
[texts]
(reduce + 0 (map count-tokens texts)))
;;; Decode OpenAI base64 response
(defn- extract-base64-response-embeddings
"Decode and extract the base64 encoded embedding from an /v1/embeddings OpenAI response"
[response]
(->> response
:data
(map (comp vec
(fn [^String base64-str]
(let [bytes (.decode (java.util.Base64/getDecoder) base64-str)
buffer (java.nio.ByteBuffer/wrap bytes)
_ (.order buffer java.nio.ByteOrder/LITTLE_ENDIAN)
float-count (/ (count bytes) 4)]
(repeatedly float-count #(.getFloat buffer))))
:embedding))
vec))
(defn- decode-embeddings
"Decode OpenAI base64 response"
[data]
(vec
(for [{:keys [embedding]} data]
(let [bytes (u/decode-base64-to-bytes ^String embedding)
buffer (doto (java.nio.ByteBuffer/wrap bytes)
(.order java.nio.ByteOrder/LITTLE_ENDIAN))
length (/ (alength bytes) 4)
_ (when-not (int? length)
(throw (ex-info "Invalid base64 length, not divisible by 4" {:length (alength bytes)})))
result (float-array length)]
(.get (.asFloatBuffer buffer) result)
result))))
;;; Batching Logic
@@ -178,34 +176,7 @@
(defmethod get-embeddings-batch "ollama" [{:keys [model-name]} texts & {:as _opts}] (ollama-get-embeddings-batch model-name texts))
(defmethod pull-model "ollama" [{:keys [model-name]}] (ollama-pull-model model-name))
;;;; AI Service impl
(defn- ai-service-get-embeddings-batch [model-name texts & {:as opts}]
(try
(log/debug "Calling AI Service embeddings API" {:documents (count texts) :tokens (count-tokens-batch texts)})
(let [response (metabot-v3.client/generate-embeddings model-name texts)
total-tokens (->> response :usage :total_tokens)]
(analytics/inc! :metabase-search/semantic-embedding-tokens
{:provider "ai-service", :model model-name}
total-tokens)
(semantic.models.token-tracking/record-tokens model-name (:type opts) total-tokens)
(extract-base64-response-embeddings response))
(catch Exception e
(log/error e "AI Service embeddings API call failed" {:documents (count texts) :tokens (count-tokens-batch texts)})
(throw e))))
(defn- ai-service-get-embedding [model-name text & {:as opts}]
(try
(log/debug "Generating AI Service embedding for text of length:" (count text))
(first (ai-service-get-embeddings-batch model-name [text] opts))
(catch Exception e
(log/error e "Failed to generate AI Service embedding for text of length:" (count text))
(throw e))))
(defmethod get-embedding "ai-service" [{:keys [model-name]} text & {:as opts}] (ai-service-get-embedding model-name text opts))
(defmethod get-embeddings-batch "ai-service" [{:keys [model-name]} texts & {:as opts}] (ai-service-get-embeddings-batch model-name texts opts))
;;;; OpenAI impl
;;;; OpenAI-compatible embedding service impl (shared by "ai-service" and "openai" providers)
(defn- supports-dimensions?
"Check whether the model's API supports dimensions in request's body. At the time of writing supported on OpenAI's
@@ -215,45 +186,118 @@
(when (string? model-name)
(str/starts-with? model-name "text-embedding-3"))))
(defn- openai-get-embeddings-batch
[{:keys [model-name vector-dimensions] :as embedding-model} texts & {:as opts}]
(let [api-key (semantic-settings/openai-api-key)
endpoint (str (semantic-settings/openai-api-base-url) "/v1/embeddings")]
(when-not api-key
(throw (ex-info "OpenAI API key not configured" {:setting "ee-openai-api-key"})))
(try
(log/debug "Calling OpenAI embeddings API" {:documents (count texts) :tokens (count-tokens-batch texts)})
(let [response (-> (http/post endpoint
{:headers {"Content-Type" "application/json"
"Authorization" (str "Bearer " api-key)}
:body (json/encode (merge {:model model-name
:input texts
:encoding_format "base64"}
(when (supports-dimensions? embedding-model)
{:dimensions vector-dimensions})))})
:body
(json/decode true))
total-tokens (->> response :usage :total_tokens)]
(analytics/inc! :metabase-search/semantic-embedding-tokens
{:provider "openai", :model model-name}
total-tokens)
(semantic.models.token-tracking/record-tokens model-name (:type opts) total-tokens)
(extract-base64-response-embeddings response))
(catch Exception e
(log/error e "OpenAI embeddings API call failed" {:documents (count texts) :tokens (count-tokens-batch texts)})
(throw e)))))
(defn- openai-compatible-get-embeddings-batch
"Call an OpenAI-compatible /v1/embeddings endpoint. Shared implementation for both
the `ai-service` and `openai` providers.
(defn- openai-get-embedding [embedding-model text & {:as opts}]
`provider` — label for analytics (e.g. \"ai-service\", \"openai\")
`endpoint` — full URL including /v1/embeddings
`api-key` — Bearer token
`model-name` — model identifier sent in the request body
`texts` — collection of input strings
`opts` — keyword opts; `:type` is forwarded to token tracking,
`:extra-body` is merged into the request body (e.g. {:dimensions 1024}),
`:snowplow?` when true fires a Snowplow token_usage event"
[provider endpoint api-key model-name texts
& {:keys [extra-body snowplow?] :as opts}]
(try
(log/debug "Generating OpenAI embedding for text of length:" (count text))
(first (openai-get-embeddings-batch embedding-model [text] opts))
(log/debug (str "Calling " provider " embeddings API")
{:endpoint endpoint :documents (count texts) :tokens (count-tokens-batch texts)})
(let [start-ms (u/start-timer)
{:keys [usage data]} (-> (http/post endpoint
{:headers {"Content-Type" "application/json"
"Authorization" (str "Bearer " api-key)}
:body (json/encode (merge {:model model-name
:input texts
:encoding_format "base64"}
extra-body))})
:body
(json/decode true))
total-tokens (:total_tokens usage 0)
prompt-tokens (:prompt_tokens usage total-tokens)]
(analytics/inc! :metabase-search/semantic-embedding-tokens
{:provider provider :model model-name}
total-tokens)
(when snowplow?
(analytics/track-token-usage!
{:snowplow true
:prometheus false ; already tracked via inc! above
:request-id (analytics/uuid->ai-service-hex-uuid (random-uuid))
:model-id model-name
:total-tokens total-tokens
:prompt-tokens prompt-tokens
:completion-tokens 0 ; embedding models don't produce completion tokens
:estimated-costs-usd 0.0
:duration-ms (long (u/since-ms start-ms))
:tag "embedding_generation"}))
(semantic.models.token-tracking/record-tokens model-name (:type opts) total-tokens)
(decode-embeddings data))
(catch ConnectException e
(log/error e (str "Failed to connect to " provider) {:endpoint endpoint})
(throw (ex-info (str provider " unavailable (connection refused)")
{:status 502 :endpoint endpoint}
e)))
(catch Exception e
(log/error e "Failed to generate OpenAI embedding for text of length:" (count text))
(log/error e (str provider " embeddings API call failed")
{:documents (count texts) :tokens (count-tokens-batch texts)})
(throw e))))
(defmethod get-embedding "openai" [embedding-model text & {:as opts}] (openai-get-embedding embedding-model text opts))
(defmethod get-embeddings-batch "openai" [embedding-model texts & {:as opts}] (openai-get-embeddings-batch embedding-model texts opts))
(defmethod pull-model "openai" [_] (log/debug "OpenAI provider does not require pulling a model"))
;;;; Embedding-service provider
(defn- embedding-service-resolve-config!
"Returns [endpoint api-key] or throws if not configured."
[]
(let [base-url (semantic-settings/ee-embedding-service-base-url)
api-key (semantic-settings/ee-embedding-service-api-key)]
(when-not base-url
(throw (ex-info "Embedding service base URL not configured"
{:setting "ee-embedding-service-base-url"})))
(when-not api-key
(throw (ex-info "Embedding service API key not configured"
{:setting "ee-embedding-service-api-key"})))
[(str base-url "/v1/embeddings") api-key]))
(defmethod get-embedding "ai-service" [{:keys [model-name]} text & {:as opts}]
(let [[endpoint api-key] (embedding-service-resolve-config!)]
(first (openai-compatible-get-embeddings-batch
"ai-service" endpoint api-key model-name [text]
(assoc opts :snowplow? true)))))
(defmethod get-embeddings-batch "ai-service" [{:keys [model-name]} texts & {:as opts}]
(let [[endpoint api-key] (embedding-service-resolve-config!)]
(openai-compatible-get-embeddings-batch
"ai-service" endpoint api-key model-name texts
(assoc opts :snowplow? true))))
(defmethod pull-model "ai-service" [_]
(log/debug "ai-service provider does not require pulling a model"))
;;;; OpenAI provider
(defn- openai-resolve-config!
"Returns [endpoint api-key] or throws if not configured."
[]
(let [api-key (semantic-settings/openai-api-key)]
(when-not api-key
(throw (ex-info "OpenAI API key not configured" {:setting "ee-openai-api-key"})))
[(str (semantic-settings/openai-api-base-url) "/v1/embeddings") api-key]))
(defmethod get-embedding "openai" [embedding-model text & {:as opts}]
(let [[endpoint api-key] (openai-resolve-config!)]
(first (openai-compatible-get-embeddings-batch
"openai" endpoint api-key (:model-name embedding-model) [text]
(assoc opts :extra-body (when (supports-dimensions? embedding-model)
{:dimensions (:vector-dimensions embedding-model)}))))))
(defmethod get-embeddings-batch "openai" [embedding-model texts & {:as opts}]
(let [[endpoint api-key] (openai-resolve-config!)]
(openai-compatible-get-embeddings-batch
"openai" endpoint api-key (:model-name embedding-model) texts
(assoc opts :extra-body (when (supports-dimensions? embedding-model)
{:dimensions (:vector-dimensions embedding-model)})))))
(defmethod pull-model "openai" [_]
(log/debug "OpenAI provider does not require pulling a model"))
;;;; Global embedding model
@@ -295,7 +339,7 @@
(fn [batch-idx batch-texts]
(let [embeddings (u/profile (format "Embedding batch %d/%d %s"
(inc batch-idx) (count batches) (str (calc-token-metrics batch-texts)))
(openai-get-embeddings-batch embedding-model batch-texts opts))
(get-embeddings-batch embedding-model batch-texts opts))
text-embedding-map (zipmap batch-texts embeddings)]
(process-fn text-embedding-map)))]
@@ -307,11 +351,13 @@
(comment
;; Configuration:
;; MB_EE_EMBEDDING_PROVIDER: "openai" or "ollama" (default)
;; MB_EE_EMBEDDING_PROVIDER: "ai-service" (default), "openai", or "ollama"
;; MB_EE_EMBEDDING_MODEL: optional override (leave empty for provider defaults)
;; - OpenAI default: "text-embedding-3-small"
;; - Ollama default: "mxbai-embed-large"
;; MB_EE_OPENAI_API_KEY your OpenAI API key
;; MB_EE_EMBEDDING_SERVICE_BASE_URL: URL of the embedding service (for ai-service provider)
;; MB_EE_EMBEDDING_SERVICE_API_KEY: API key for the embedding service
;; MB_EE_OPENAI_API_KEY: your OpenAI API key (for openai provider)
;; MB_EE_EMBEDDING_MODEL_DIMENSIONS: defaults to 1024.
(def embedding-model (get-configured-model))

View File

@@ -5,14 +5,25 @@
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru]]))
(def ^:private valid-embedding-providers
"The set of valid embedding provider names."
#{"ai-service" "openai" "ollama"})
(defsetting ee-embedding-provider
(deferred-tru "The embedding provider to use (:openai, :ollama, or :ai-service)")
(deferred-tru "The embedding provider to use (`openai`, `ollama`, or `ai-service`)")
:encryption :no
:visibility :settings-manager
:default "ai-service"
:type :string
:export? false
:doc false)
:doc false
:setter (fn [new-value]
(when (and new-value (not (contains? valid-embedding-providers new-value)))
(throw (ex-info (str "Invalid embedding provider: " (pr-str new-value)
". Valid providers are: " (pr-str valid-embedding-providers))
{:invalid-value new-value
:valid-values valid-embedding-providers})))
(setting/set-value-of-type! :string :ee-embedding-provider new-value)))
(defsetting ee-embedding-model
(deferred-tru "Set the embedding model for the selected provider")
@@ -41,6 +52,21 @@
[]
(llm-settings/ee-openai-api-key))
(defsetting ee-embedding-service-base-url
(deferred-tru "URL of the OpenAI-compatible embedding service (e.g. a LiteLLM proxy).")
:encryption :no
:visibility :settings-manager
:default nil
:export? false
:doc false)
(defsetting ee-embedding-service-api-key
(deferred-tru "API key for authenticating with the embedding service.")
:sensitive? true
:visibility :settings-manager
:export? false
:doc false)
(defsetting semantic-search-enabled
(deferred-tru "Enable the semantic search engine? Intended as a kill switch for the semantic search feature while dogfooding.")
:visibility :internal

View File

@@ -278,17 +278,17 @@
(is (some? field-id) "Should find the State field")
(let [result (agent-client :crowberto :get 200
(format "agent/v1/table/%d/field/%s/values" table-id field-id))]
(is (=? {:statistics {:distinct_count 49}
:values sequential?}
(is (=? {:value_metadata {:statistics {:distinct-count 49}
:field_values sequential?}}
result))
(is (<= (count (:values result)) 30) "Should apply default limit of 30"))))
(testing "Respects explicit limit parameter"
(let [table-id (mt/id :people)
field-id (visible-field-id table-id "State")
result (agent-client :crowberto :get 200
(format "agent/v1/table/%d/field/%s/values?limit=5" table-id field-id))]
(is (= 5 (count (:values result))) "Should respect limit parameter")))
field-id (visible-field-id table-id "State")]
(is (=? {:value_metadata {:field_values #(= 5 (count %))}}
(agent-client :crowberto :get 200
(format "agent/v1/table/%d/field/%s/values?limit=5" table-id field-id))))))
(testing "Returns 404 for non-existent table"
(is (= "Not found."
@@ -399,8 +399,8 @@
quantity-field (m/find-first #(= (:name %) "QUANTITY") (:queryable_dimensions metric-details))]
(is (some? quantity-field) "Quantity field should be in queryable_dimensions")
(when-let [field-id (:field_id quantity-field)]
(is (=? {:statistics map?
:values sequential?}
(is (=? {:value_metadata {:statistics map?
:field_values sequential?}}
(agent-client :rasta :get 200
(format "agent/v1/metric/%d/field/%s/values" (:id metric) field-id)))))))

View File

@@ -0,0 +1,503 @@
(ns metabase-enterprise.mcp.api-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.mcp.api :as mcp.api]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.oauth-server.core :as oauth-server]
[metabase.api.macros.scope :as scope]
[metabase.test :as mt]
[metabase.test.data.users :as test.users]
[metabase.test.fixtures :as fixtures]
[metabase.test.http-client :as client]
[metabase.util.json :as json]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db :test-users))
;;; --------------------------------------------------- Helpers ----------------------------------------------------
(defn- mcp-request
"Make a POST request to /api/mcp with the given JSON-RPC body and optional extra headers.
Authenticates as :crowberto (superuser) by default."
([body]
(mcp-request body {}))
([body extra-headers]
(mt/with-additional-premium-features #{:agent-api}
(client/client-full-response (test.users/username->token :crowberto)
:post "mcp"
{:request-options {:headers extra-headers}}
body))))
(defn- mcp-request-unauthenticated
"Make an unauthenticated POST request to /api/mcp."
([body]
(mcp-request-unauthenticated body {}))
([body extra-headers]
(mt/with-additional-premium-features #{:agent-api}
(client/client-full-response :post 401 "mcp"
{:request-options {:headers extra-headers}}
body))))
(defn- mcp-request-with-bearer
"Make a POST request to /api/mcp with a bearer token and optional extra headers."
[bearer-token expected-status body extra-headers]
(mt/with-additional-premium-features #{:agent-api}
(client/client-full-response :post expected-status "mcp"
{:request-options {:headers (merge {"authorization" (str "Bearer " bearer-token)}
extra-headers)}}
body)))
(defn- mcp-delete
"Make a DELETE request to /api/mcp with optional headers.
Authenticates as :crowberto (superuser) by default."
[extra-headers]
(mt/with-additional-premium-features #{:agent-api}
(client/client-full-response (test.users/username->token :crowberto)
:delete "mcp"
{:request-options {:headers extra-headers}})))
(defn- jsonrpc-request
"Build a JSON-RPC 2.0 request map."
([method]
(jsonrpc-request method {} 1))
([method params]
(jsonrpc-request method params 1))
([method params id]
{:jsonrpc "2.0" :method method :params params :id id}))
(defn- jsonrpc-notification
"Build a JSON-RPC 2.0 notification (no id)."
([method]
(jsonrpc-notification method {}))
([method params]
{:jsonrpc "2.0" :method method :params params}))
(defn- initialize!
"Perform the full MCP initialize handshake (initialize + notifications/initialized).
Returns [session-id init-response]."
[]
(let [response (mcp-request (jsonrpc-request "initialize"))
session-id (get-in response [:headers "Mcp-Session-Id"])]
;; Complete the handshake so the session is marked initialized
(mcp-request (jsonrpc-notification "notifications/initialized")
{"mcp-session-id" session-id})
[session-id response]))
(defn- call-tool
"Call an MCP tool within an initialized session. Returns the parsed MCP result
content (the JSON-decoded text from the first content block).
Records test failures if the response status is not 200 or the tool returns an error."
[session-id tool-name arguments]
(let [response (mcp-request (jsonrpc-request "tools/call"
{:name tool-name :arguments arguments})
{"mcp-session-id" session-id})
result (get-in response [:body :result])]
(is (= 200 (:status response))
(str "Expected 200 from tools/call " tool-name))
(is (not (:isError result))
(str "Tool " tool-name " error: " (some-> result :content first :text)))
(when-not (:isError result)
(json/decode+kw (:text (first (:content result)))))))
(defn- mcp-request-as
"Make a POST request to /api/mcp authenticated as the given test user."
[username body extra-headers]
(mt/with-additional-premium-features #{:agent-api}
(client/client-full-response (test.users/username->token username)
:post "mcp"
{:request-options {:headers extra-headers}}
body)))
;;; ---------------------------------------------------- Tests -----------------------------------------------------
(deftest authentication-required-test
(testing "unauthenticated requests return 401"
(let [response (mt/with-additional-premium-features #{:agent-api}
(client/client-full-response :post 401 "mcp"
(jsonrpc-request "initialize")))]
(is (= 401 (:status response)))
(is (= -32603 (get-in response [:body :error :code]))))))
(deftest initialize-test
(testing "initialize returns protocol version, capabilities, and server info"
(let [[session-id response] (initialize!)]
(is (= 200 (:status response)))
(is (some? session-id))
(is (= "2.0" (get-in response [:body :jsonrpc])))
(is (= 1 (get-in response [:body :id])))
(let [result (get-in response [:body :result])]
(is (= "2025-03-26" (:protocolVersion result)))
(is (= {:tools {}} (:capabilities result)))
(is (= {:name "metabase" :version "0.1.0"} (:serverInfo result)))))))
(deftest notifications-initialized-test
(testing "notifications/initialized returns 202"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-notification "notifications/initialized")
{"mcp-session-id" session-id})]
(is (= 202 (:status response))))))
(deftest session-validation-test
(testing "requests without a session ID return 400"
(let [response (mcp-request (jsonrpc-request "tools/list"))]
(is (= 400 (:status response)))
(is (= -32600 (get-in response [:body :error :code])))))
(testing "requests with an invalid session ID return 404"
(let [response (mcp-request (jsonrpc-request "tools/list")
{"mcp-session-id" "bogus-session-id"})]
(is (= 404 (:status response)))
(is (= -32600 (get-in response [:body :error :code]))))))
(deftest session-user-binding-test
(testing "sessions cannot be reused by a different authenticated user"
(let [;; Initialize a session as crowberto
[session-id _] (initialize!)
;; Try to use that session as rasta — returns 404 because the session
;; doesn't match the requesting user
response (mcp-request-as :rasta
(jsonrpc-request "tools/list")
{"mcp-session-id" session-id})]
(is (= 404 (:status response)))
(is (= -32600 (get-in response [:body :error :code]))))))
(deftest session-delete-test
(testing "DELETE removes the session"
(let [[session-id _] (initialize!)
delete-response (mcp-delete {"mcp-session-id" session-id})
;; Now try to use the deleted session — returns 404
post-response (mcp-request (jsonrpc-request "tools/list")
{"mcp-session-id" session-id})]
(is (= 200 (:status delete-response)))
(is (= 404 (:status post-response))))))
(deftest session-ttl-test
(testing "expired sessions return 404"
(let [[session-id _] (initialize!)]
;; Manually expire the session by backdating the timer (nanos) to 2 hours ago
(swap! @#'mcp.api/sessions update session-id assoc
:timer (- (System/nanoTime) (long (* 2 60 60 1000 1e6))))
(let [response (mcp-request (jsonrpc-request "tools/list")
{"mcp-session-id" session-id})]
(is (= 404 (:status response)))
(is (= -32600 (get-in response [:body :error :code])))))))
(deftest tools-list-test
(testing "tools/list returns the 7 agent tools"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "tools/list")
{"mcp-session-id" session-id})
tools (get-in response [:body :result :tools])]
(is (= 200 (:status response)))
(is (pos? (count tools)))
(is (= #{"search" "get_table" "get_metric" "get_table_field_values"
"get_metric_field_values" "construct_query" "execute_query"}
(set (map :name tools))))
(testing "each tool has a description and inputSchema"
(doseq [tool tools]
(is (string? (:description tool)))
(is (map? (:inputSchema tool))))))))
(deftest ping-test
(testing "ping returns empty result"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "ping")
{"mcp-session-id" session-id})]
(is (= 200 (:status response)))
(is (= {} (get-in response [:body :result]))))))
(deftest method-not-found-test
(testing "unknown method returns -32601 error"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "nonexistent/method")
{"mcp-session-id" session-id})]
(is (= 200 (:status response)))
(is (= -32601 (get-in response [:body :error :code]))))))
(deftest batch-request-test
(testing "batch requests are processed together"
(let [[session-id _] (initialize!)
response (mcp-request [(jsonrpc-request "ping" {} 1)
(jsonrpc-request "tools/list" {} 2)]
{"mcp-session-id" session-id})]
(is (= 200 (:status response)))
(is (sequential? (:body response)))
(is (= 2 (count (:body response))))
(is (= #{1 2} (set (map :id (:body response))))))))
(deftest tools-call-unknown-tool-test
(testing "calling an unknown tool returns isError content"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "tools/call"
{:name "nonexistent_tool" :arguments {}})
{"mcp-session-id" session-id})
result (get-in response [:body :result])]
(is (= 200 (:status response)))
(is (true? (:isError result)))
(is (= "text" (:type (first (:content result))))))))
(deftest tools-call-search-test
(testing "search tool invocation works and returns parseable results"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "tools/call"
{:name "search"
:arguments {:term_queries ["orders"]}})
{"mcp-session-id" session-id})
result (get-in response [:body :result])]
(is (= 200 (:status response)))
(is (nil? (:isError result)))
(is (= "text" (:type (first (:content result)))))
(let [search-data (json/decode+kw (:text (first (:content result))))]
(is (contains? search-data :data))
(is (contains? search-data :total_count))))))
;;; ------------------------------------------------ SSE Transport -------------------------------------------------
(deftest sse-post-response-test
(testing "POST with Accept: text/event-stream returns SSE format"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "ping")
{"mcp-session-id" session-id
"accept" "text/event-stream"})]
(is (= 200 (:status response)))
(is (= "text/event-stream" (get-in response [:headers "Content-Type"])))
;; Body should be SSE events (raw string since it's not JSON)
(is (string? (:body response)))
(is (str/includes? (:body response) "event: message"))
(is (str/includes? (:body response) "data: "))))
(testing "POST without Accept: text/event-stream returns JSON (backward-compatible)"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "ping")
{"mcp-session-id" session-id})]
(is (= 200 (:status response)))
(is (= "application/json" (get-in response [:headers "Content-Type"])))
;; Body should be parsed JSON
(is (map? (:body response))))))
(deftest sse-post-initialize-test
(testing "initialize via SSE returns session header and SSE body"
(let [response (mcp-request (jsonrpc-request "initialize")
{"accept" "text/event-stream"})]
(is (= 200 (:status response)))
(is (some? (get-in response [:headers "Mcp-Session-Id"])))
(is (= "text/event-stream" (get-in response [:headers "Content-Type"])))
(is (string? (:body response)))
;; Parse the SSE data line to verify JSON-RPC structure
(let [data-line (->> (str/split-lines (:body response))
(filter #(str/starts-with? % "data: "))
first)
json-data (json/decode+kw (subs data-line 6))]
(is (= "2.0" (:jsonrpc json-data)))
(is (= 1 (:id json-data)))
(is (some? (get-in json-data [:result :protocolVersion])))))))
(deftest get-without-session-test
(testing "GET without auth returns 401"
(let [response (mt/with-additional-premium-features #{:agent-api}
(client/client-full-response (test.users/username->token :crowberto)
:get "mcp"))]
(is (= 400 (:status response)))
(is (= -32600 (get-in response [:body :error :code]))))))
;;; --------------------------------------------- Type-Safe Responses -----------------------------------------------
(deftest type-safe-get-table-test
(testing "get_table response has properly encoded keys from Agent API"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "tools/call"
{:name "get_table"
:arguments {:id (mt/id :orders)}})
{"mcp-session-id" session-id})
result (get-in response [:body :result])
;; Use first+:text since :content is a lazy seq (not indexable by get-in)
content-text (:text (first (:content result)))]
(is (= 200 (:status response)))
(is (nil? (:isError result)))
(is (string? content-text))
;; Parse the content text as JSON and verify snake_case keys from Malli encoding
(let [table-data (json/decode+kw content-text)]
(is (some? (:name table-data)))
(is (some? (:display_name table-data)))
(is (some? (:database_id table-data)))
(is (= "table" (:type table-data)))
(is (seq (:fields table-data)))))))
(deftest initialized-enforcement-test
(testing "requests before notifications/initialized are rejected"
(let [;; Only do initialize, not the full handshake
response (mcp-request (jsonrpc-request "initialize"))
session-id (get-in response [:headers "Mcp-Session-Id"])
;; Try tools/list without sending notifications/initialized
list-response (mcp-request (jsonrpc-request "tools/list")
{"mcp-session-id" session-id})]
(is (= 200 (:status list-response)))
(is (= -32600 (get-in list-response [:body :error :code])))
(is (str/includes? (get-in list-response [:body :error :message]) "not initialized"))))
(testing "requests after notifications/initialized succeed"
(let [response (mcp-request (jsonrpc-request "initialize"))
session-id (get-in response [:headers "Mcp-Session-Id"])]
;; Send notifications/initialized
(mcp-request (jsonrpc-notification "notifications/initialized")
{"mcp-session-id" session-id})
;; Now tools/list should work
(let [list-response (mcp-request (jsonrpc-request "tools/list")
{"mcp-session-id" session-id})]
(is (= 200 (:status list-response)))
(is (some? (get-in list-response [:body :result :tools])))))))
(deftest full-handshake-test
(testing "complete MCP handshake flow"
(let [[session-id init-response] (initialize!)
_ (is (= 200 (:status init-response)))
_ (is (some? session-id))
;; tools/list should work after full handshake
list-response (mcp-request (jsonrpc-request "tools/list")
{"mcp-session-id" session-id})]
(is (= 200 (:status list-response)))
(is (pos? (count (get-in list-response [:body :result :tools])))))))
(deftest batch-with-notifications-test
(testing "batch with mix of notifications and requests returns only request responses"
(let [[session-id _] (initialize!)
response (mcp-request [(jsonrpc-notification "notifications/initialized")
(jsonrpc-request "ping" {} 1)]
{"mcp-session-id" session-id})]
(is (= 200 (:status response)))
(is (sequential? (:body response)))
(is (= 1 (count (:body response))))
(is (= 1 (:id (first (:body response))))))))
(deftest tools-call-missing-params-test
(testing "tools/call without name returns an error"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "tools/call"
{:arguments {}})
{"mcp-session-id" session-id})
result (get-in response [:body :result])]
(is (= 200 (:status response)))
(is (true? (:isError result)))))
(testing "tools/call with missing path params returns an error"
(let [[session-id _] (initialize!)
response (mcp-request (jsonrpc-request "tools/call"
{:name "get_table" :arguments {}})
{"mcp-session-id" session-id})
result (get-in response [:body :result])]
(is (= 200 (:status response)))
(is (true? (:isError result)))
(is (str/includes? (:text (first (:content result))) "Missing required path parameter")))))
(deftest tools-call-execute-query-test
(testing "execute_query returns a streaming response captured as MCP text content"
(let [streamed? (atom false)
original-fn (mt/original-fn #'mcp.tools/capture-streaming-response)]
(mt/with-dynamic-fn-redefs [mcp.tools/capture-streaming-response
(fn [response]
(reset! streamed? true)
(original-fn response))]
(let [[session-id _] (initialize!)
construct-data (call-tool session-id "construct_query" {:table_id (mt/id :orders) :limit 5})
execute-data (call-tool session-id "execute_query" {:query (:query construct-data)})]
(is (true? @streamed?) "execute_query should use the streaming response path")
(is (=? {:status "completed"
:row_count 5
:data {:cols sequential?
:rows (fn [rows] (= 5 (count rows)))}}
execute-data)))))))
;;; --------------------------------------------- OAuth Bearer Auth -------------------------------------------------
(deftest unauthenticated-returns-401-test
(testing "POST without any auth returns 401 with WWW-Authenticate discovery header"
(mt/with-temporary-setting-values [site-url "http://localhost:3000"]
(let [response (mcp-request-unauthenticated (jsonrpc-request "initialize"))]
(is (=? {:status 401
:headers {"WWW-Authenticate" #(str/includes? % "oauth-protected-resource")}}
response))))))
(deftest invalid-bearer-token-returns-401-test
(testing "POST with invalid bearer token returns 401 with invalid_token error"
(mt/with-premium-features #{:metabot-v3}
(mt/with-temporary-setting-values [site-url "http://localhost:3000"]
(oauth-server/reset-provider!)
(try
(let [response (mcp-request-with-bearer "totally-bogus-token" 401
(jsonrpc-request "initialize")
{})]
(is (=? {:status 401
:headers {"WWW-Authenticate" #(str/includes? % "invalid_token")}}
response)))
(finally
(oauth-server/reset-provider!)))))))
;;; --------------------------------------------- Scope Filtering ---------------------------------------------------
(deftest tools-list-scope-filtering-test
(testing "tools/list with unrestricted scopes returns all tools"
(let [tools (mcp.tools/list-tools #{::scope/unrestricted})]
(is (= 7 (count tools)))))
(testing "tools/list with specific scope only returns matching tools"
(let [tools (mcp.tools/list-tools #{"agent:search"})]
;; Should include: search (matches scope) + get_field_values (no scope field)
(is (contains? (set (map :name tools)) "search"))
(is (contains? (set (map :name tools)) "get_field_values"))
;; Should NOT include tools with other scopes
(is (not (contains? (set (map :name tools)) "get_table")))
(is (not (contains? (set (map :name tools)) "construct_query")))))
(testing "tools/list with wildcard scope matches all agent tools"
(let [tools (mcp.tools/list-tools #{"agent:*"})]
(is (= 7 (count tools)))))
(testing "tools/list with nil scopes returns all tools"
(let [tools (mcp.tools/list-tools nil)]
(is (= 7 (count tools))))))
(defn- insert-expired-oauth-token!
"Insert an OAuth access token into the DB with an expiry in the past.
Returns the token string."
[user-id client-id]
(let [token (str (random-uuid))
expired (- (quot (System/currentTimeMillis) 1000) 3600)]
(t2/insert! :model/OAuthAccessToken
{:token token
:user_id user-id
:client_id client-id
:scope ["openid"]
:expiry expired})
token))
(deftest expired-oauth-bearer-token-returns-401-test
(testing "POST with expired OAuth bearer token returns 401"
(mt/with-premium-features #{:metabot-v3}
(mt/with-temporary-setting-values [site-url "http://localhost:3000"]
(t2/with-transaction [_conn nil {:rollback-only true}]
(oauth-server/reset-provider!)
(let [user-id (mt/user->id :crowberto)
token (insert-expired-oauth-token! user-id (str (random-uuid)))
response (mcp-request-with-bearer token 401
(jsonrpc-request "initialize")
{})]
(is (=? {:status 401
:headers {"WWW-Authenticate" #(str/includes? % "invalid_token")}}
response))))))))
(deftest tools-call-scope-enforcement-test
(testing "tool call is rejected when token scopes don't include the required scope"
(mt/with-premium-features #{:agent-api}
(let [result (mt/with-current-user (mt/user->id :crowberto)
(mcp.tools/call-tool #{"agent:search"} "get_table" {:id (mt/id :orders)}))]
(is (=? {:isError true} result))
(is (str/includes? (-> result :content first :text) "scope")
"Error should mention scope"))))
(testing "tool call with matching scope is not rejected by scope enforcement"
(mt/with-premium-features #{:agent-api}
(let [result (mt/with-current-user (mt/user->id :crowberto)
(mcp.tools/call-tool #{"agent:table:read"} "get_table" {:id (mt/id :orders)}))]
(is (not (:isError result)))))))

View File

@@ -0,0 +1,747 @@
(ns metabase-enterprise.metabot-v3.agent.core-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.agent.analytics :as agent-analytics]
[metabase-enterprise.metabot-v3.agent.core :as agent]
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.api :as api]
[metabase-enterprise.metabot-v3.self :as self]
[metabase-enterprise.metabot-v3.self.openrouter :as openrouter]
[metabase-enterprise.metabot-v3.test-util :as mut]
[metabase-enterprise.metabot-v3.tools.search :as metabot-search]
[metabase.analytics.prometheus :as prometheus]
[metabase.analytics.snowplow-test :as snowplow-test]
[metabase.test :as mt]
[metabase.test.util :as tu]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
;; Mock tool for testing
(mu/defn test-search-tool
"Mock search tool that returns test data."
[{:keys [_query]} :- [:map {:closed true}
[:query :string]]]
{:structured-output {:data [{:id 1 :name "Test Result"}]}})
(def test-tools
{"search" #'test-search-tool})
(defn- run-agent-loop!
"run-agent-loop for side effects, discarding results"
[opts]
(reduce (fn [_ _]) nil (agent/run-agent-loop opts)))
(deftest has-tool-calls-test
(testing "detects tool calls in parts"
(is (#'agent/has-tool-calls? [{:type :tool-input :id "t1"}]))
(is (not (#'agent/has-tool-calls? [{:type :text :text "hello"}])))
(is (#'agent/has-tool-calls? [{:type :text :text "hi"}
{:type :tool-input :id "t1"}]))))
(deftest should-continue-test
(let [max-iter 3]
(testing "continues when iteration < max and has tool calls"
(is (#'agent/should-continue? 0 max-iter [{:type :tool-input}]))
(is (#'agent/should-continue? 1 max-iter [{:type :tool-input}])))
(testing "continues when text AND tool calls present (LLM thinking aloud)"
(is (#'agent/should-continue? 0 max-iter [{:type :tool-input}
{:type :text}]))
(is (#'agent/should-continue? 0 max-iter [{:type :text}
{:type :tool-input}])))
(testing "stops at max iterations (1-based: iteration >= max means done)"
(is (not (#'agent/should-continue? 3 max-iter [{:type :tool-input}])))
(is (not (#'agent/should-continue? 4 max-iter [{:type :tool-input}]))))
(testing "stops when no tool calls (text-only is final answer)"
(is (not (#'agent/should-continue? 0 max-iter [{:type :text}])))
(is (not (#'agent/should-continue? 0 max-iter [{:type :usage}])))
(is (not (#'agent/should-continue? 0 max-iter []))))))
(deftest run-agent-loop-with-mock-test
(testing "runs agent loop with mocked LLM returning text"
(with-redefs [openrouter/openrouter (fn [_]
(mut/mock-llm-response
[{:type :text :text "Hello"}]))]
(let [result (into [] (agent/run-agent-loop
{:messages [{:role :user :content "Hi"}]
:state {}
:profile-id :embedding_next
:context {}}))]
;; Should get parts + state data
;; Note: :finish is not emitted as a part; it's handled by aisdk-line-xf completion
(is (pos? (count result)))
;; Should have state data (final part)
(is (some #(= :data (:type %)) result)))))
(testing "sql profile requests required tool choice"
(let [captured (atom nil)]
(with-redefs [self/call-llm (fn [_model _system _parts _tools _tracking-opts llm-opts]
(reset! captured llm-opts)
(mut/mock-llm-response
[{:type :text :text "Hello"}]))]
(into [] (agent/run-agent-loop
{:messages [{:role :user :content "Hi"}]
:state {}
:profile-id :sql
:context {}}))
(is (= {:tool-choice "required"} @captured)))))
(testing "runs agent loop with tool execution"
(let [call-count (atom 0)]
(with-redefs [openrouter/openrouter (fn [_]
;; First call returns tool-input, second returns text
(let [n (swap! call-count inc)]
(if (= 1 n)
(mut/mock-llm-response
[{:type :tool-input
:id "t1"
:function "search"
:arguments {:query "test"}}])
(mut/mock-llm-response
[{:type :text :text "Found results"}]))))]
(let [result (into [] (agent/run-agent-loop
{:messages [{:role :user :content "Search for test"}]
:state {}
:profile-id :embedding_next
:context {}}))]
;; Should complete successfully
(is (pos? (count result)))
;; Should have state data (final part)
(is (some #(= :data (:type %)) result))
;; Should have tool-related parts
(is (some #(= :tool-input (:type %)) result))))))
(testing "handles errors gracefully"
(with-redefs [openrouter/openrouter (fn [_]
(throw (ex-info "Mock error" {})))]
(let [result (mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :fatal]
(into [] (agent/run-agent-loop
{:messages [{:role :user :content "Hi"}]
:state {}
:profile-id :embedding_next
:context {}})))]
;; Should get error message
(is (some #(= :error (:type %)) result))))))
;; Note: build-messages-for-llm is now internal to call-llm
;; Message building is tested via messages_test.clj
(deftest seed-state-test
(testing "seeds queries from user_is_viewing context"
(let [context {:user_is_viewing [{:type "native"
:id "query-123"
:query {:database 1 :type :query :query {:source-table 1}}}]}
seeded (#'agent/seed-state {} context)]
(is (contains? (get seeded :queries) "query-123"))))
(testing "does not seed native SQL string queries"
(let [context {:user_is_viewing [{:type "native"
:id "query-456"
:query "SELECT * FROM users"}]}
seeded (#'agent/seed-state {} context)]
(is (empty? (get seeded :queries)))))
(testing "ignores viewing items without ids or queries"
(let [context {:user_is_viewing [{:type "native" :query {:database 1}}
{:type "adhoc" :id "no-query"}]}
seeded (#'agent/seed-state {} context)]
(is (empty? (get seeded :queries))))))
;; Note: stream-parts! and finalize-stream! are now internal to run-agent-loop.
;; Link resolution is tested via streaming/post-process-xf in streaming_test.clj.
;; Here we test the full agent loop behavior.
(deftest integration-run-agent-loop-test
(testing "runs full agent loop without external calls"
(with-redefs [openrouter/openrouter (fn [_]
(mut/mock-llm-response
[{:type :text :text "Test response"}]))]
(let [result (into [] (agent/run-agent-loop
{:messages [{:role :user :content "Hello"}]
:state {}
:profile-id :embedding_next
:context {}}))]
;; Verify basic structure
(is (pos? (count result)))
;; Should have text part
(is (some #(= :text (:type %)) result))
;; Should have state data part (finish is handled by aisdk-line-xf, not emitted as part)
(is (some #(and (= :data (:type %))
(map? (:data %)))
result))))))
;;; Query and Chart extraction tests
(deftest extract-queries-test
(testing "extracts queries from tool output parts"
(let [query {:database 1 :type :query :query {:source-table 1}}
parts [{:type :tool-output
:id "t1"
:function "query_model"
:result {:structured-output {:query-id "q-123"
:query query
:result-columns []}}}]
memory {:state {:queries {} :charts {}}}
updated (#'agent/extract-queries memory parts)]
(is (= query (get-in (memory/get-state updated) [:queries "q-123"])))))
(testing "ignores parts without structured-output"
(let [parts [{:type :tool-output
:id "t1"
:function "search"
:result {:output "no results"}}]
memory {:state {:queries {} :charts {}}}
updated (#'agent/extract-queries memory parts)]
(is (empty? (:queries (memory/get-state updated))))))
(testing "ignores non-tool-output parts"
(let [parts [{:type :text :text "hello"}
{:type :tool-input :id "t1" :function "search"}]
memory {:state {:queries {} :charts {}}}
updated (#'agent/extract-queries memory parts)]
(is (empty? (:queries (memory/get-state updated)))))))
(deftest extract-charts-test
(testing "extracts charts from tool output parts"
(let [chart-data {:chart-id "c-456"
:query-id "q-123"
:chart-type :bar}
parts [{:type :tool-output
:id "t1"
:function "create_chart"
:result {:structured-output chart-data}}]
memory {:state {:queries {} :charts {}}}
updated (#'agent/extract-charts memory parts)]
(is (= chart-data (get-in (memory/get-state updated) [:charts "c-456"])))))
(testing "stores charts even when query details are included"
(let [query-data {:chart-id "c-789"
:query-id "q-789"
:query {:database 1}
:result-columns []}
parts [{:type :tool-output
:id "t1"
:function "query_model"
:result {:structured-output query-data}}]
memory {:state {:queries {} :charts {}}}
updated (#'agent/extract-charts memory parts)]
(is (= query-data (get-in (memory/get-state updated) [:charts "c-789"])))))
(testing "ignores parts without chart-id"
(let [parts [{:type :tool-output
:id "t1"
:function "search"
:result {:structured-output {:data []}}}]
memory {:state {:queries {} :charts {}}}
updated (#'agent/extract-charts memory parts)]
(is (empty? (:charts (memory/get-state updated)))))))
;;; ===================== Integration Tests =====================
;;;
;;; These tests exercise the full agent loop across multiple iterations
;;; with tool calls, state management, and realistic scenarios.
(defn scripted-claude
"Create a mock claude fn that returns responses in sequence.
Each response is a vector of parts (e.g., [{:type :text :text \"Hi\"}]).
Usage:
(with-redefs [openrouter/openrouter (scripted-claude
[[{:type :tool-input :function \"search\" ...}]
[{:type :text :text \"Found it\"}]])]
...)"
[responses]
(let [idx (atom 0)]
(fn [_opts]
(let [i @idx
response (get responses i)]
(swap! idx inc)
(if response
(mut/mock-llm-response response)
;; Fallback: return empty text to terminate loop
(mut/mock-llm-response [{:type :text :text ""}]))))))
(deftest integration-search-query-chart-flow-test
(testing "Scenario 1: Search → Query → Chart (multi-turn happy path)"
;; User asks: "Show me the first 10 orders"
;; - Iteration 1: LLM calls search tool to find orders table
;; - Iteration 2: LLM calls construct_notebook_query to create a raw query
;; - Iteration 3: LLM returns text with chart link
;;
;; We use real tools with only the search backend and LLM mocked.
;; The construct_notebook_query tool runs real query construction against test DB.
;; We use a simple "raw" query type that doesn't require field IDs.
(mt/with-current-user (mt/user->id :crowberto)
(let [orders-table-id (mt/id :orders)
;; Track LLM calls
llm-call-count (atom 0)
;; Scripted LLM responses - uses real table ID from test DB
llm-responses
[ ;; Iteration 1: Search for orders table
[{:type :start :id "msg-1"}
{:type :tool-input
:id "call-search-1"
:function "search"
:arguments {:semantic_queries ["orders table"]
:keyword_queries ["orders"]
:entity_types ["table"]}}
{:type :usage :usage {:promptTokens 100 :completionTokens 20} :model "test" :id "msg-1"}]
;; Iteration 2: Construct a simple raw query (no fields/aggregations = select all)
[{:type :start :id "msg-2"}
{:type :tool-input
:id "call-construct-1"
:function "construct_notebook_query"
:arguments {:reasoning "User wants to see orders"
:query {:query_type "raw"
:source {:table_id orders-table-id}
:filters []
:fields []
:order_by []
:limit 10}
:visualization {:chart_type "table"}}}
{:type :usage :usage {:promptTokens 200 :completionTokens 30} :model "test" :id "msg-2"}]
;; Iteration 3: Final text response
[{:type :start :id "msg-3"}
{:type :text
:text "Here are the first 10 orders from the orders table."}
{:type :usage :usage {:promptTokens 300 :completionTokens 10} :model "test" :id "msg-3"}]]]
;; Mock only openrouter/openrouter (LLM) and metabot-search/search (search backend)
;; Everything else runs real code
(with-redefs [openrouter/openrouter (fn [_opts]
(let [n (swap! llm-call-count inc)]
(mut/mock-llm-response (get llm-responses (dec n) []))))
metabot-search/search (fn [_args]
[{:id orders-table-id
:type "table"
:name "orders"
:display_name "Orders"
:description "This is a confirmed order for a product from a user."
:database_id (mt/id)}])]
(testing "Should successfully go through 3 iterations"
(is (=? [{:type :start}
{:type :tool-input :function "search"}
;; Cumulative usage after iteration 1: 100 prompt, 20 completion
{:type :usage :usage {:promptTokens 100 :completionTokens 20}}
{:type :tool-output
:function "search"
:result {:structured-output {:total_count 1}}}
{:type :start}
{:type :tool-input :function "construct_notebook_query"}
;; Cumulative usage after iteration 2: 100+200=300 prompt, 20+30=50 completion
{:type :usage :usage {:promptTokens 300 :completionTokens 50}}
;; references real db id
{:type :tool-output
:function "construct_notebook_query"
:result {:structured-output {:query {:database (mt/id)}}}}
{:type :data :data-type "navigate_to"}
{:type :start}
;; has final text part
{:type :text}
;; Cumulative usage after iteration 3: 300+300=600 prompt, 50+10=60 completion
{:type :usage :usage {:promptTokens 600 :completionTokens 60}}
{:type :data
:data-type "state"
:data {:queries map?
:charts map?}}]
(mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :warn]
(into [] (#'api/combine-text-parts-xf)
(agent/run-agent-loop
{:messages [{:role :user
:content "Show me the first 10 orders"}]
:state {}
:profile-id :internal
:context {}}))))))
(testing "should complete 3 LLM iterations"
(is (= 3 @llm-call-count)
"Should have exactly 3 LLM calls (search, construct, final text)")))))))
(deftest cumulative-usage-test
(testing "usage parts are cumulative across agent loop iterations"
(let [call-count (atom 0)]
(with-redefs [openrouter/openrouter
(fn [_]
(let [n (swap! call-count inc)]
(case (int n)
;; Iteration 1: tool call with usage
1 (mut/mock-llm-response
[{:type :start :id "msg-1"}
{:type :tool-input
:id "t1"
:function "search"
:arguments {:query "test"}}
{:type :usage :usage {:promptTokens 100 :completionTokens 20}
:model "gpt-4" :id "msg-1"}])
;; Iteration 2: text response with usage
(mut/mock-llm-response
[{:type :start :id "msg-2"}
{:type :text :text "Done"}
{:type :usage :usage {:promptTokens 150 :completionTokens 30}
:model "gpt-4" :id "msg-2"}]))))]
(let [result (mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :warn]
(into [] (agent/run-agent-loop
{:messages [{:role :user :content "test"}]
:state {}
:profile-id :embedding_next
:context {}})))
usages (filterv #(= :usage (:type %)) result)]
(testing "should have two usage parts (one per iteration)"
(is (= 2 (count usages))))
(testing "first usage is from iteration 1 only"
(is (= {:promptTokens 100 :completionTokens 20}
(:usage (first usages)))))
(testing "second usage is cumulative (iteration 1 + 2)"
(is (= {:promptTokens 250 :completionTokens 50}
(:usage (second usages)))))))))
(testing "cumulative usage works across multiple models"
(let [call-count (atom 0)]
(with-redefs [openrouter/openrouter
(fn [_]
(let [n (swap! call-count inc)]
(case (int n)
1 (mut/mock-llm-response
[{:type :start :id "msg-1"}
{:type :tool-input
:id "t1"
:function "search"
:arguments {:query "test"}}
{:type :usage :usage {:promptTokens 100 :completionTokens 20}
:model "model-a" :id "msg-1"}])
(mut/mock-llm-response
[{:type :start :id "msg-2"}
{:type :text :text "Done"}
{:type :usage :usage {:promptTokens 200 :completionTokens 40}
:model "model-b" :id "msg-2"}]))))]
(let [result (mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :warn]
(into [] (agent/run-agent-loop
{:messages [{:role :user :content "test"}]
:state {}
:profile-id :embedding_next
:context {}})))
usages (filterv #(= :usage (:type %)) result)]
(testing "different models accumulate independently"
(is (= "model-a" (:model (first usages))))
(is (= {:promptTokens 100 :completionTokens 20}
(:usage (first usages))))
(is (= "model-b" (:model (second usages))))
(is (= {:promptTokens 200 :completionTokens 40}
(:usage (second usages))))))))))
(deftest run-agent-loop-retries-on-rate-limit-test
(testing "agent loop retries when LLM returns 429 and then succeeds"
(let [call-count (atom 0)]
(with-redefs [self/retry-delay-ms (constantly 0)
openrouter/openrouter (fn [_]
(if (< (swap! call-count inc) 2)
(throw (ex-info "Anthropic API has rate limited us"
{:status 429 :api-error true}))
(mut/mock-llm-response
[{:type :text :text "Hello after retry"}])))]
(is (=? [{:type :text :text "Hello after retry"}
{:type :data :data-type "state"}]
(mt/with-log-level [metabase-enterprise.metabot-v3.self :fatal]
(into [] (#'api/combine-text-parts-xf)
(agent/run-agent-loop
{:messages [{:role :user :content "Hi"}]
:state {}
:profile-id :embedding_next
:context {}}))))
"Should get the response from the successful retry")
(is (= 2 @call-count)
"Should have called LLM twice (1 failure + 1 success)")))))
;;; ===================== Prometheus Metrics Tests =====================
(deftest run-agent-loop-prometheus-test
(mt/with-prometheus-system! [_ system]
(testing "records agent-requests, agent-iterations, and llm-requests metrics"
(let [call-count (atom 0)]
(with-redefs [openrouter/openrouter
(fn [_]
(let [n (swap! call-count inc)]
(if (= 1 n)
(mut/mock-llm-response
[{:type :tool-input
:id "t1"
:function "search"
:arguments {:query "test"}}])
(mut/mock-llm-response
[{:type :text :text "Done"}]))))]
(mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :warn]
(run-agent-loop! {:messages [{:role :user :content "test"}]
:state {}
:profile-id :internal
:context {}}))))
(is (== 1 (mt/metric-value system :metabase-metabot/agent-requests
{:profile-id "internal"})))
(is (== 2 (:sum (mt/metric-value system :metabase-metabot/agent-iterations
{:profile-id "internal"}))))
(is (== 0 (mt/metric-value system :metabase-metabot/agent-errors
{:profile-id "internal"})))
(is (== 2 (mt/metric-value system :metabase-metabot/llm-requests
{:model "anthropic/claude-haiku-4-5"
:source "agent"})))
(is (== 1 (:count (mt/metric-value system :metabase-metabot/agent-duration-ms
{:profile-id "internal"}))))
(is (pos? (:sum (mt/metric-value system :metabase-metabot/agent-duration-ms
{:profile-id "internal"})))))
;; clear! is much faster than a new mt/with-prometheus-system!
(prometheus/clear! :metabase-metabot/agent-requests)
(prometheus/clear! :metabase-metabot/agent-iterations)
(prometheus/clear! :metabase-metabot/agent-errors)
(prometheus/clear! :metabase-metabot/agent-duration-ms)
(prometheus/clear! :metabase-metabot/llm-requests)
(testing "records agent-errors on failure"
(with-redefs [openrouter/openrouter (fn [_] (throw (ex-info "boom" {})))]
(mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :fatal]
(run-agent-loop! {:messages [{:role :user :content "test"}]
:state {}
:profile-id :internal
:context {}})))
(is (== 1 (mt/metric-value system :metabase-metabot/agent-requests
{:profile-id "internal"})))
(is (== 0 (:sum (mt/metric-value system :metabase-metabot/agent-iterations
{:profile-id "internal"}))))
(is (== 1 (mt/metric-value system :metabase-metabot/agent-errors
{:profile-id "internal"})))
(is (== 1 (mt/metric-value system :metabase-metabot/llm-requests
{:model "anthropic/claude-haiku-4-5"
:source "agent"})))
(is (== 1 (:count (mt/metric-value system :metabase-metabot/agent-duration-ms
{:profile-id "internal"}))))
(is (pos? (:sum (mt/metric-value system :metabase-metabot/agent-duration-ms
{:profile-id "internal"})))))))
;;; ===================== Snowplow Analytics Tests =====================
(deftest agent-used-tool-snowplow-test
(testing "fires :snowplow/ai_service_event 'agent_used_tool' per tool call"
(let [call-count (atom 0)
rasta-id (mt/user->id :rasta)]
(with-redefs [openrouter/openrouter
(fn [_]
(let [n (swap! call-count inc)]
(if (= 1 n)
(mut/mock-llm-response
[{:type :start :id "msg-1"}
{:type :tool-input
:id "t1"
:function "search"
:arguments {:semantic_queries ["test"]
:keyword_queries ["test"]
:entity_types ["table"]}}
{:type :usage :usage {:promptTokens 100 :completionTokens 20}
:model "test-model" :id "msg-1"}])
(mut/mock-llm-response
[{:type :start :id "msg-2"}
{:type :text :text "Done"}
{:type :usage :usage {:promptTokens 150 :completionTokens 30}
:model "test-model" :id "msg-2"}]))))
metabot-search/search
(fn [_args] [{:id 1 :type "table" :name "test" :display_name "Test" :database_id 1}])]
(mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :warn]
(mt/with-current-user rasta-id
(snowplow-test/with-fake-snowplow-collector
(run-agent-loop! {:messages [{:role :user :content "test"}]
:state {}
:context {}
:profile-id :internal
:tracking-opts {:session-id "00000000-0000-0000-0000-000000000001"}})
;; The collector also contains token_usage events; filter for just ai_service_events.
(let [events (snowplow-test/pop-event-data-and-user-id!)
tool-events (filter #(= "agent_used_tool" (get-in % [:data "event"])) events)]
(is (=? [{:user-id (str rasta-id)
:data {"event" "agent_used_tool"
"source" "metabot_agent"
"profile" "internal"
"session_id" "00000000-0000-0000-0000-000000000001"
"result" "success"
"duration_ms" nat-int?
"event_details" {"tool_name" "search"
"step" 1}}}]
tool-events)))))))))
(testing "fires 'agent_used_tool' with result=error when tool fails"
(let [call-count (atom 0)
rasta-id (mt/user->id :rasta)]
(with-redefs [openrouter/openrouter
(fn [_]
(let [n (swap! call-count inc)]
(if (= 1 n)
(mut/mock-llm-response
[{:type :start :id "msg-1"}
{:type :tool-input
:id "t1"
:function "search"
:arguments {:bad-arg "wrong"}}
{:type :usage :usage {:promptTokens 100 :completionTokens 20}
:model "test-model" :id "msg-1"}])
(mut/mock-llm-response
[{:type :start :id "msg-2"}
{:type :text :text "Done"}
{:type :usage :usage {:promptTokens 150 :completionTokens 30}
:model "test-model" :id "msg-2"}]))))
metabot-search/search
(fn [_args] (throw (ex-info "should not be called" {})))]
(mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :warn]
(mt/with-current-user rasta-id
(snowplow-test/with-fake-snowplow-collector
(run-agent-loop! {:messages [{:role :user :content "test"}]
:state {}
:context {}
:profile-id :internal
:tracking-opts {:session-id "00000000-0000-0000-0000-000000000002"}})
(let [events (snowplow-test/pop-event-data-and-user-id!)
tool-events (filter #(= "agent_used_tool" (get-in % [:data "event"])) events)]
(is (=? [{:data {"event" "agent_used_tool"
"result" "error"}}]
tool-events))))))))))
(deftest token-usage-snowplow-test
(testing "fires :snowplow/token_usage event per LLM call"
(let [call-count (atom 0)
rasta-id (mt/user->id :rasta)]
(with-redefs [openrouter/openrouter
(fn [_]
(let [n (swap! call-count inc)]
(if (= 1 n)
(mut/mock-llm-response
[{:type :start :id "msg-1"}
{:type :tool-input
:id "t1"
:function "search"
:arguments {:query "test"}}
{:type :usage :usage {:promptTokens 100 :completionTokens 20}
:model "test-model" :id "msg-1"}])
(mut/mock-llm-response
[{:type :start :id "msg-2"}
{:type :text :text "Done"}
{:type :usage :usage {:promptTokens 150 :completionTokens 30}
:model "test-model" :id "msg-2"}]))))]
(mt/with-log-level [metabase-enterprise.metabot-v3.agent.core :warn]
(mt/with-current-user rasta-id
(snowplow-test/with-fake-snowplow-collector
(run-agent-loop! {:messages [{:role :user :content "test"}]
:state {}
:context {}
:profile-id :internal
:tracking-opts {:session-id "00000000-0000-0000-0000-000000000001"}})
;; Filter for just token_usage events (other events may also be present)
(let [events (snowplow-test/pop-event-data-and-user-id!)
token-events (filter #(contains? (:data %) "total_tokens") events)]
(is (=? [{:user-id (str rasta-id)
:data {"model_id" "test-model"
"total_tokens" 120
"prompt_tokens" 100
"completion_tokens" 20
"estimated_costs_usd" 0.0
"duration_ms" nat-int?
"profile" "internal"
"source" "metabot_agent"
"tag" "agent"
"session_id" "00000000-0000-0000-0000-000000000001"}}
{:user-id (str rasta-id)
:data {"model_id" "test-model"
"total_tokens" 180
"prompt_tokens" 150
"completion_tokens" 30
"estimated_costs_usd" 0.0
"duration_ms" nat-int?
"profile" "internal"
"source" "metabot_agent"
"tag" "agent"
"session_id" "00000000-0000-0000-0000-000000000001"}}]
token-events))))))))))
;;; ===================== User Intent Classification Tests =====================
(def ^:private mock-llm-response-for-intent
[{:type :start :id "msg-1"}
{:type :text :text "Done"}
{:type :usage :usage {:promptTokens 10 :completionTokens 5}
:model "test-model" :id "msg-1"}])
(deftest user-intent-snowplow-fires-event-test
(testing "fires :snowplow/ai_service_event 'user_intent' when :track-user-intent? is true"
(let [rasta-id (mt/user->id :rasta)]
(with-redefs [openrouter/openrouter (fn [_] (mut/mock-llm-response mock-llm-response-for-intent))
self/call-llm (fn [& _] [{:type :text :text "<category>query_data</category>"}])]
(mt/with-current-user rasta-id
(snowplow-test/with-fake-snowplow-collector
(run-agent-loop! {:messages [{:role :user :content "Show sales by region"}]
:state {}
:context {}
:profile-id :internal
:tracking-opts {:session-id "00000000-0000-0000-0000-000000000002"
:track-user-intent? true}})
(tu/poll-until 5000
(some #(= "user_intent" (get-in % [:properties "data" "data" "event"]))
@snowplow-test/*snowplow-collector*))
(let [events (snowplow-test/pop-event-data-and-user-id!)
intent-events (filter #(= "user_intent" (get-in % [:data "event"])) events)]
(is (=? [{:user-id (str rasta-id)
:data {"event" "user_intent"
"source" "metabot_agent"
"profile" "internal"
"session_id" "00000000-0000-0000-0000-000000000002"
"event_details" {"intent" "query_data"}}}]
intent-events)))))))))
(deftest user-intent-not-tracked-when-disabled-test
(testing "does not fire user_intent event when :track-user-intent? is false"
(let [classify-called (atom false)]
(with-redefs [openrouter/openrouter
(fn [_] (mut/mock-llm-response mock-llm-response-for-intent))
agent-analytics/classify-and-track-user-intent-async!
(fn [_ _] (reset! classify-called true))]
(run-agent-loop! {:messages [{:role :user :content "test"}]
:state {}
:context {}
:profile-id :internal
:tracking-opts {:session-id "00000000-0000-0000-0000-000000000003"}})
(is (false? @classify-called))))))
(deftest user-intent-not-tracked-when-internal-tasks-disabled-test
(let [opts {:messages [{:role :user :content "Show sales by region"}]
:state {}
:context {}
:profile-id :internal
:tracking-opts {:session-id "00000000-0000-0000-0000-000000000005"
:track-user-intent? true}}
classify-called (atom 0)]
(with-redefs [openrouter/openrouter
(fn [_] (mut/mock-llm-response mock-llm-response-for-intent))
agent-analytics/classify-and-track-user-intent-async!
(fn [_ _] (swap! classify-called inc))]
(testing "does not call classify-and-track-user-intent-async! when ee-ai-metabot-internal-tasks-enabled? is false"
(mt/with-temporary-setting-values [ee-ai-metabot-internal-tasks-enabled? false]
(run-agent-loop! opts)
(is (zero? @classify-called))))
(testing "calls classify-and-track-user-intent-async! when ee-ai-metabot-internal-tasks-enabled? is true"
(mt/with-temporary-setting-values [ee-ai-metabot-internal-tasks-enabled? true]
(run-agent-loop! opts)
(is (= 1 @classify-called)))))))
(deftest user-intent-classifier-exception-swallowed-test
(testing "does not propagate exception if classify-user-intent throws"
(let [classify-called (atom false)]
(with-redefs [openrouter/openrouter (fn [_] (mut/mock-llm-response mock-llm-response-for-intent))
self/call-llm (fn [& _]
(reset! classify-called true)
(throw (ex-info "LLM error" {})))]
;; Should not throw — exception is caught inside the background future
(run-agent-loop! {:messages [{:role :user :content "test"}]
:state {}
:context {}
:profile-id :internal
:tracking-opts {:session-id "00000000-0000-0000-0000-000000000004"
:track-user-intent? true}})
(tu/poll-until 5000 @classify-called)
(is (true? @classify-called))))))

View File

@@ -0,0 +1,302 @@
(ns metabase-enterprise.metabot-v3.agent.links-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.agent.links :as links]))
(deftest resolve-metabase-uri-test
(testing "resolves query links"
(let [query-id "abc-123"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
charts-state {}
result (links/resolve-metabase-uri "metabase://query/abc-123" queries-state charts-state)]
(is (string? result))
(is (str/starts-with? result "/question#"))))
(testing "returns nil for missing query"
(let [result (links/resolve-metabase-uri "metabase://query/missing" {} {})]
(is (nil? result))))
(testing "resolves entity links"
(is (= "/model/123" (links/resolve-metabase-uri "metabase://model/123" {} {})))
(is (= "/metric/456" (links/resolve-metabase-uri "metabase://metric/456" {} {})))
(is (= "/dashboard/789" (links/resolve-metabase-uri "metabase://dashboard/789" {} {})))
(is (= "/question/101" (links/resolve-metabase-uri "metabase://question/101" {} {})))
(is (= "/admin/transforms/202" (links/resolve-metabase-uri "metabase://transform/202" {} {})))
(is (= "/table/123" (links/resolve-metabase-uri "metabase://table/123" {} {}))))
(testing "returns nil for unknown entity types"
(is (nil? (links/resolve-metabase-uri "metabase://unknown/123" {} {}))))
(testing "returns nil for non-metabase URIs"
(is (nil? (links/resolve-metabase-uri "https://example.com" {} {})))
(is (nil? (links/resolve-metabase-uri "/question/123" {} {}))))
(testing "returns nil for missing entity IDs"
(is (nil? (links/resolve-metabase-uri "metabase://model/" {} {})))
(is (nil? (links/resolve-metabase-uri "metabase://metric/" {} {})))
(is (nil? (links/resolve-metabase-uri "metabase://query/" {} {}))))
(testing "resolves chart links using chart state"
(let [query-id "query-abc"
chart-id "chart-123"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
charts-state {chart-id {:query-id query-id :chart-type :bar}}
result (links/resolve-metabase-uri "metabase://chart/chart-123" queries-state charts-state)]
(is (string? result))
(is (str/starts-with? result "/question#"))))
(testing "falls back to query when chart link uses query ID"
;; LLM sometimes uses metabase://chart/ when it should use metabase://query/
(let [query-id "qp_6a8c1d99-6f46-4ebb-9b7e-2fcad97a7f1c"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
charts-state {}
result (links/resolve-metabase-uri (str "metabase://chart/" query-id) queries-state charts-state)]
(is (string? result))
(is (str/starts-with? result "/question#")))))
(deftest resolve-links-test
(testing "resolves metabase:// links in markdown"
(let [query-id "test-query"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
charts-state {}
text "Check out [My Results](metabase://query/test-query) for details."
result (links/resolve-links text queries-state charts-state)]
(is (str/includes? result "[My Results](/question#"))
(is (not (str/includes? result "metabase://")))))
(testing "preserves non-metabase links"
(let [text "Visit [Google](https://google.com) for more."
result (links/resolve-links text {} {})]
(is (= text result))))
(testing "handles multiple links"
(let [text "[Model](metabase://model/1) and [Metric](metabase://metric/2)"
result (links/resolve-links text {} {})]
(is (str/includes? result "[Model](/model/1)"))
(is (str/includes? result "[Metric](/metric/2)"))))
(testing "handles text without links"
(let [text "Just some plain text"
result (links/resolve-links text {} {})]
(is (= text result)))))
(deftest process-part-links-test
(testing "processes text parts"
(let [part {:type :text :text "[Link](metabase://model/123)"}
result (links/process-part-links part {} {})]
(is (= :text (:type result)))
(is (= "[Link](/model/123)" (:text result)))))
(testing "leaves non-text parts unchanged"
(let [part {:type :tool-input :function "search" :arguments {:query "test"}}
result (links/process-part-links part {} {})]
(is (= part result)))))
(deftest process-parts-links-test
(testing "processes all parts"
(let [parts [{:type :text :text "[A](metabase://model/1)"}
{:type :tool-input :function "search"}
{:type :text :text "[B](metabase://metric/2)"}]
result (links/process-parts-links parts {} {})]
(is (= 3 (count result)))
(is (= "[A](/model/1)" (-> result first :text)))
(is (= {:type :tool-input :function "search"} (second result)))
(is (= "[B](/metric/2)" (-> result (nth 2) :text))))))
(deftest resolve-links-xf-test
(testing "transducer processes text parts"
(let [parts [{:type :text :text "[A](metabase://model/1)"}
{:type :tool-input :function "search"}
{:type :text :text "[B](metabase://metric/2)"}]
result (into [] (links/resolve-links-xf {} {}) parts)]
(is (= 3 (count result)))
(is (= "[A](/model/1)" (-> result first :text)))
(is (= {:type :tool-input :function "search"} (second result)))
(is (= "[B](/metric/2)" (-> result (nth 2) :text)))))
(testing "transducer works with transduce"
(let [query-id "q1"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
parts [{:type :text :text "[Query](metabase://query/q1)"}]
result (transduce (links/resolve-links-xf queries-state {})
conj
[]
parts)]
(is (= 1 (count result)))
(is (str/starts-with? (-> result first :text) "[Query](/question#"))))
(testing "transducer composes with other transducers"
(let [parts [{:type :text :text "[A](metabase://model/1)"}
{:type :text :text "[B](metabase://model/2)"}
{:type :tool-input :function "search"}]
result (into []
(comp (links/resolve-links-xf {} {})
(filter #(= :text (:type %))))
parts)]
(is (= 2 (count result)))
(is (= "[A](/model/1)" (-> result first :text)))
(is (= "[B](/model/2)" (-> result second :text)))))
(testing "transducer handles nil state maps"
(let [parts [{:type :text :text "[Link](metabase://model/123)"}]
result (into [] (links/resolve-links-xf nil nil) parts)]
(is (= "[Link](/model/123)" (-> result first :text))))))
;;; Nil handling tests - important for robustness against LLM edge cases
(deftest resolve-metabase-uri-nil-handling-test
(testing "handles nil URI gracefully"
(is (nil? (links/resolve-metabase-uri nil {} {}))))
(testing "handles empty string URI"
(is (nil? (links/resolve-metabase-uri "" {} {}))))
(testing "handles nil queries-state and charts-state"
(is (= "/model/123" (links/resolve-metabase-uri "metabase://model/123" nil nil)))
(is (= "/metric/456" (links/resolve-metabase-uri "metabase://metric/456" nil nil)))))
(deftest resolve-links-nil-handling-test
(testing "handles nil text gracefully"
;; Note: This test verifies the function doesn't throw on edge cases
;; The actual behavior may need adjustment based on requirements
(let [result (links/resolve-links nil {} {})]
(is (nil? result))))
(testing "handles text with nil URL in regex capture groups"
;; This tests the case where markdown regex captures nil
(let [text "Some text without links"
result (links/resolve-links text {} {})]
(is (= text result))))
(testing "handles empty text"
(let [result (links/resolve-links "" {} {})]
(is (= "" result))))
(testing "handles nil state maps"
(let [text "[Link](metabase://model/123)"
result (links/resolve-links text nil nil)]
(is (str/includes? result "[Link](/model/123)")))))
(deftest process-part-links-edge-cases-test
(testing "handles part with nil text"
(let [part {:type :text :text nil}
result (links/process-part-links part {} {})]
(is (= :text (:type result)))
(is (nil? (:text result)))))
(testing "handles part with empty text"
(let [part {:type :text :text ""}
result (links/process-part-links part {} {})]
(is (= :text (:type result)))
(is (= "" (:text result)))))
(testing "handles part without :type key"
(let [part {:text "[Link](metabase://model/1)"}
result (links/process-part-links part {} {})]
;; Should return unchanged since :type is not :text
(is (= part result))))
(testing "handles tool-output parts (should not process links)"
(let [part {:type :tool-output :id "test-123" :result {:data "test"}}
result (links/process-part-links part {} {})]
(is (= part result)))))
(deftest chart-link-resolution-test
(testing "resolves chart links correctly with state"
(let [query-id "q-abc-123"
chart-id "chart-xyz-789"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
charts-state {chart-id {:query-id query-id :chart-type :bar}}
result (links/resolve-metabase-uri (str "metabase://chart/" chart-id) queries-state charts-state)]
(is (string? result))
(is (str/starts-with? result "/question#"))))
(testing "chart link returns nil when chart not found and no query fallback"
(let [result (links/resolve-metabase-uri "metabase://chart/nonexistent" {} {})]
(is (nil? result))))
(testing "chart link falls back to query when chart-id matches query-id"
(let [query-id "shared-id-123"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
charts-state {}
result (links/resolve-metabase-uri (str "metabase://chart/" query-id) queries-state charts-state)]
(is (string? result))
(is (str/starts-with? result "/question#"))))
(testing "chart link resolution with missing query-id in chart state"
;; Chart exists but references a query that doesn't exist
(let [charts-state {"chart-1" {:query-id "missing-query" :chart-type :line}}
result (links/resolve-metabase-uri "metabase://chart/chart-1" {} charts-state)]
;; Should return nil since the underlying query can't be resolved
(is (nil? result)))))
(deftest query-link-resolution-test
(testing "resolves query links with valid query in state"
(let [query-id "test-query-id"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
result (links/resolve-metabase-uri (str "metabase://query/" query-id) queries-state {})]
(is (string? result))
(is (str/starts-with? result "/question#"))))
(testing "returns nil for missing query"
(is (nil? (links/resolve-metabase-uri "metabase://query/nonexistent" {} {}))))
(testing "handles query with complex nested structure"
(let [query-id "complex-query"
query {:database 1
:type :query
:query {:source-table 1
:joins [{:fields :all :source-table 2}]
:filter [:= [:field 1 nil] "test"]}}
queries-state {query-id query}
result (links/resolve-metabase-uri (str "metabase://query/" query-id) queries-state {})]
(is (string? result))
(is (str/starts-with? result "/question#")))))
(deftest multiple-links-in-text-test
(testing "processes multiple different link types in same text"
(let [query-id "q1"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
text "See [Model](metabase://model/1), [Metric](metabase://metric/2), and [Query](metabase://query/q1)"
result (links/resolve-links text queries-state {})]
(is (str/includes? result "[Model](/model/1)"))
(is (str/includes? result "[Metric](/metric/2)"))
(is (str/includes? result "/question#"))))
(testing "preserves text between links"
(let [text "Start [A](metabase://model/1) middle [B](metabase://model/2) end"
result (links/resolve-links text {} {})]
(is (str/includes? result "Start "))
(is (str/includes? result " middle "))
(is (str/includes? result " end")))))
(deftest special-characters-in-links-test
(testing "handles link text with special characters"
(let [text "[Link with (parens)](metabase://model/1)"
result (links/resolve-links text {} {})]
;; Markdown parser should handle this - may or may not match depending on regex
(is (string? result))))
(testing "handles entity IDs that look like UUIDs"
(let [query-id "550e8400-e29b-41d4-a716-446655440000"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
result (links/resolve-metabase-uri (str "metabase://query/" query-id) queries-state {})]
(is (string? result))))
(testing "handles nano-id style identifiers"
(let [query-id "puL95JSvym3k23W1UUuog"
query {:database 1 :type :query :query {:source-table 1}}
queries-state {query-id query}
result (links/resolve-metabase-uri (str "metabase://query/" query-id) queries-state {})]
(is (string? result)))))

View File

@@ -0,0 +1,157 @@
(ns metabase-enterprise.metabot-v3.agent.markdown-link-buffer-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.agent.markdown-link-buffer :as mlb]))
(defn- process
"Process text through a fresh state with given queries/charts context.
Returns [output flushed]."
([text] (process text {} {}))
([text queries] (process text queries {}))
([text queries charts]
(let [[state output] (mlb/step (mlb/with-context mlb/initial-state queries charts) text)]
[output (mlb/flush-state state)])))
(defn- process-chunks
"Process multiple chunks through state, returns [outputs flushed]."
([chunks] (process-chunks chunks {} {}))
([chunks queries] (process-chunks chunks queries {}))
([chunks queries charts]
(loop [state (mlb/with-context mlb/initial-state queries charts)
[chunk & more] chunks
outputs []]
(if chunk
(let [[new-state output] (mlb/step state chunk)]
(recur new-state more (conj outputs output)))
[outputs (mlb/flush-state state)]))))
;;; State machine / buffering tests
(deftest nested-bracket-handling-test
(testing "does not drop characters when nested brackets appear"
(let [[output flushed] (process "[foo[")]
(is (= "[foo" output))
(is (= "[" flushed)))))
(deftest basic-text-passthrough-test
(testing "passes through plain text unchanged"
(let [[output flushed] (process "Hello world")]
(is (= "Hello world" output))
(is (= "" flushed)))))
(deftest buffering-incomplete-link-test
(testing "buffers incomplete markdown link"
(let [[outputs flushed] (process-chunks ["Check [link" "](http://example.com)"])]
(is (= ["Check " "[link](http://example.com)"] outputs))
(is (= "" flushed))))
(testing "buffers link split across multiple chunks"
(let [[outputs flushed] (process-chunks ["See [My " "Link](http:" "//example.com)"])]
(is (= ["See " "" "[My Link](http://example.com)"] outputs))
(is (= "" flushed))))
(testing "flushes incomplete link at end of stream"
(let [[output flushed] (process "Incomplete [link")]
(is (= "Incomplete " output))
(is (= "[link" flushed)))))
(deftest regular-url-passthrough-test
(testing "passes through regular URLs unchanged"
(let [[output flushed] (process "[Google](https://google.com)")]
(is (= "[Google](https://google.com)" output))
(is (= "" flushed)))))
;;; metabase:// link resolution tests
(def resolved-link-re #"\[Results\]\(/question#[a-zA-ZZ0-9=]+\)")
(deftest resolve-query-link-test
(testing "resolves metabase://query links using queries-state"
(let [query {:database 1 :type :query :query {:source-table 1}}
[output flushed] (process "[Results](metabase://query/q-123)" {"q-123" query})]
(is (=? resolved-link-re output))
(is (not (re-find #"metabase://" output)))
(is (= "" flushed))))
(testing "falls back to link text for unknown query"
(let [[output flushed] (process "[Results](metabase://query/unknown)")]
(is (= "Results" output))
(is (= "" flushed))))
(testing "incomplete chunks are also replaced well"
(let [query {:database 1 :type :query :query {:source-table 1}}
[output flushed] (process-chunks ["Your [Res" "ults](metabase://qu" "ery/q-123)"] {"q-123" query})]
(is (=? ["Your " "" resolved-link-re] output))
(is (= "" flushed)))))
(deftest resolve-chart-link-test
(testing "resolves metabase://chart links using charts-state"
(let [query {:database 1 :type :query :query {:source-table 1}}
queries {"q-456" query}
charts {"c-789" {:query-id "q-456" :chart-type :bar}}
[output flushed] (process "[My Chart](metabase://chart/c-789)" queries charts)]
(is (re-find #"\[My Chart\]\(/question#" output))
(is (not (re-find #"metabase://" output)))
(is (= "" flushed))))
(testing "falls back to link text for unknown chart"
(let [[output flushed] (process "[Chart](metabase://chart/unknown)")]
(is (= "Chart" output))
(is (= "" flushed)))))
(deftest resolve-entity-link-test
(testing "resolves metabase://model links"
(let [[output flushed] (process "[My Model](metabase://model/123)")]
(is (= "[My Model](/model/123)" output))
(is (= "" flushed))))
(testing "resolves metabase://metric links"
(let [[output flushed] (process "[Revenue](metabase://metric/456)")]
(is (= "[Revenue](/metric/456)" output))
(is (= "" flushed))))
(testing "resolves metabase://dashboard links"
(let [[output flushed] (process "[Sales Dashboard](metabase://dashboard/789)")]
(is (= "[Sales Dashboard](/dashboard/789)" output))
(is (= "" flushed))))
(testing "resolves metabase://table links"
(let [[output flushed] (process "[Users Table](metabase://table/42)")]
(is (= "[Users Table](/table/42)" output))
(is (= "" flushed)))))
(deftest resolve-link-split-across-chunks-test
(testing "resolves metabase:// link split across multiple chunks"
(let [query {:database 1 :type :query :query {:source-table 1}}
[outputs flushed] (process-chunks ["Check [Results](metabase://" "query/split-query)"]
{"split-query" query})]
(is (= "Check " (first outputs)))
(is (re-find #"\[Results\]\(/question#" (second outputs)))
(is (= "" flushed)))))
(deftest multiple-links-in-text-test
(testing "resolves multiple links in same chunk"
(let [[output flushed] (process "[Model A](metabase://model/1) and [Model B](metabase://model/2)")]
(is (= "[Model A](/model/1) and [Model B](/model/2)" output))
(is (= "" flushed))))
(testing "handles mix of resolvable and regular links"
(let [[output flushed] (process "[Model](metabase://model/1) and [Google](https://google.com)")]
(is (= "[Model](/model/1) and [Google](https://google.com)" output))
(is (= "" flushed)))))
;;; with-context tests
(deftest with-context-test
(testing "updates state for subsequent link resolution"
(let [;; First, link cannot be resolved
state1 (mlb/with-context mlb/initial-state {} {})
[state2 output1] (mlb/step state1 "[Results](metabase://query/new-query)")
_ (is (= "Results" output1))
;; Update state with the query
query {:database 1 :type :query :query {:source-table 1}}
state3 (mlb/with-context state2 {"new-query" query} {})
;; Now link can be resolved
[state4 output2] (mlb/step state3 " See [More](metabase://query/new-query)")]
(is (re-find #"\[More\]\(/question#" output2))
(is (= "" (mlb/flush-state state4))))))

View File

@@ -0,0 +1,87 @@
(ns metabase-enterprise.metabot-v3.agent.memory-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.agent.memory :as memory]))
(deftest initialize-test
(testing "initializes memory with messages and state"
(let [messages [{:role :user :content "Hello"}]
state {:queries {} :charts {}}
mem (memory/initialize messages state)]
(is (= messages (:input-messages mem)))
(is (= state (:state mem)))
(is (= [] (:steps-taken mem)))))
(testing "initializes with default state when nil"
(let [messages [{:role :user :content "Hello"}]
mem (memory/initialize messages nil)]
(is (= {:queries {} :charts {} :todos [] :transforms {}} (:state mem))))))
(deftest add-step-test
(testing "adds step to memory"
(let [mem (memory/initialize [] {})
parts [{:type :text :text "Response"}
{:type :tool-input :function "search" :arguments {:query "test"}}]
mem' (memory/add-step mem parts)]
(is (= 1 (count (:steps-taken mem'))))
(is (= parts (-> mem' :steps-taken first :parts)))))
(testing "adds multiple steps"
(let [mem (memory/initialize [] {})
parts1 [{:type :tool-input :function "search"}]
parts2 [{:type :text :text "Response"}]
mem' (-> mem
(memory/add-step parts1)
(memory/add-step parts2))]
(is (= 2 (count (:steps-taken mem'))))
(is (= parts1 (-> mem' :steps-taken first :parts)))
(is (= parts2 (-> mem' :steps-taken second :parts))))))
(deftest get-state-test
(testing "retrieves state from memory"
(let [state {:queries {"q1" {:id "q1"}} :charts {}}
mem (memory/initialize [] state)]
(is (= state (memory/get-state mem))))))
(deftest update-state-test
(testing "updates state with new values"
(let [mem (memory/initialize [] {:queries {}})
mem' (memory/update-state mem {:charts {"c1" {:id "c1"}}})]
(is (= {:queries {} :charts {"c1" {:id "c1"}}}
(memory/get-state mem')))))
(testing "merges state updates"
(let [mem (memory/initialize [] {:queries {"q1" {:id "q1"}}})
mem' (memory/update-state mem {:charts {"c1" {:id "c1"}}})]
(is (= {:queries {"q1" {:id "q1"}} :charts {"c1" {:id "c1"}}}
(memory/get-state mem'))))))
(deftest get-steps-test
(testing "retrieves all steps"
(let [parts1 [{:type :tool-input}]
parts2 [{:type :text}]
mem (-> (memory/initialize [] {})
(memory/add-step parts1)
(memory/add-step parts2))]
(is (= 2 (count (memory/get-steps mem))))
(is (= parts1 (-> mem memory/get-steps first :parts)))
(is (= parts2 (-> mem memory/get-steps second :parts))))))
(deftest get-input-messages-test
(testing "retrieves input messages"
(let [messages [{:role :user :content "Hello"}
{:role :assistant :content "Hi"}]
mem (memory/initialize messages {})]
(is (= messages (memory/get-input-messages mem))))))
(deftest iteration-count-test
(testing "counts number of steps"
(let [mem (-> (memory/initialize [] {})
(memory/add-step [{:type :tool-input}])
(memory/add-step [{:type :text}])
(memory/add-step [{:type :tool-input}]))]
(is (= 3 (memory/iteration-count mem)))))
(testing "returns 0 for new memory"
(let [mem (memory/initialize [] {})]
(is (= 0 (memory/iteration-count mem))))))

View File

@@ -0,0 +1,174 @@
(ns metabase-enterprise.metabot-v3.agent.messages-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.agent.messages :as messages]))
;;; ──────────────────────────────────────────────────────────────────
;;; input-message->parts
;;; ──────────────────────────────────────────────────────────────────
(deftest ^:parallel input-message-user-test
(testing "plain user message"
(is (=? [{:role :user :content "Hello"}]
(messages/input-message->parts {:role :user :content "Hello"}))))
(testing "user message with string role"
(is (=? [{:role :user :content "Hello"}]
(messages/input-message->parts {:role "user" :content "Hello"})))))
(deftest ^:parallel input-message-assistant-test
(testing "assistant with plain text"
(is (=? [{:type :text :text "Hi there"}]
(messages/input-message->parts {:role :assistant :content "Hi there"}))))
(testing "assistant with tool_calls (OpenAI style)"
(is (=? [{:type :text :text "Searching..."}
{:type :tool-input :id "t1" :function "search" :arguments {:q "test"}}]
(messages/input-message->parts
{:role :assistant
:content "Searching..."
:tool_calls [{:id "t1" :name "search" :arguments "{\"q\":\"test\"}"}]}))))
(testing "assistant with only tool_calls (no content)"
(is (=? [{:type :tool-input :id "t1" :function "search" :arguments {}}]
(messages/input-message->parts
{:role :assistant
:tool_calls [{:id "t1" :name "search" :arguments "{}"}]}))))
(testing "assistant with content blocks (Claude style)"
(is (=? [{:type :text :text "Let me check..."}
{:type :tool-input :id "t1" :function "search" :arguments {:q "test"}}]
(messages/input-message->parts
{:role :assistant
:content [{:type "text" :text "Let me check..."}
{:type "tool_use" :id "t1" :name "search" :input {:q "test"}}]}))))
(testing "assistant with only content block tool_use (no text)"
(is (=? [{:type :tool-input :id "t1" :function "search"}]
(messages/input-message->parts
{:role :assistant
:content [{:type "tool_use" :id "t1" :name "search" :input {:q "test"}}]}))))
(testing "malformed tool_call arguments fall through"
(is (=? [{:type :tool-input :id "t1" :function "search" :arguments "{bad-json"}]
(messages/input-message->parts
{:role :assistant
:tool_calls [{:id "t1" :name "search" :arguments "{bad-json"}]})))))
(deftest ^:parallel input-message-tool-test
(testing "tool result message"
(is (=? [{:type :tool-output :id "t1" :result {:output "Found 42"}}]
(messages/input-message->parts
{:role :tool :tool_call_id "t1" :content "Found 42"}))))
(testing "tool result with string role"
(is (=? [{:type :tool-output :id "t1" :result {:output "results"}}]
(messages/input-message->parts
{:role "tool" :tool_call_id "t1" :content "results"})))))
(deftest ^:parallel input-message-user-with-tool-results-test
(testing "user message with tool_result content blocks"
(is (=? [{:type :tool-output :id "t1" :result {:output "Result 1"}}
{:type :tool-output :id "t2" :result {:output "Result 2"}}]
(messages/input-message->parts
{:role :user
:content [{:type "tool_result" :tool_use_id "t1" :content "Result 1"}
{:type "tool_result" :tool_use_id "t2" :content "Result 2"}]})))))
;;; ──────────────────────────────────────────────────────────────────
;;; build-message-history
;;; ──────────────────────────────────────────────────────────────────
(deftest ^:parallel build-message-history-test
(testing "builds history from user messages only"
(is (=? [{:role :user :content "Hello"}]
(messages/build-message-history
(memory/initialize [{:role :user :content "Hello"}] {})))))
(testing "includes assistant text from input"
(is (=? [{:role :user :content "Hello"}
{:type :text :text "Hi there"}]
(messages/build-message-history
(memory/initialize [{:role :user :content "Hello"}
{:role :assistant :content "Hi there"}] {})))))
(testing "includes step parts from memory"
(is (=? [{:role :user :content "Hello"}
{:type :text :text "Response text"}]
(messages/build-message-history
(-> (memory/initialize [{:role :user :content "Hello"}] {})
(memory/add-step [{:type :text :text "Response text"}]))))))
(testing "includes tool calls from steps"
(is (=? [{:role :user :content "Search for revenue"}
{:type :tool-input :id "t1" :function "search" :arguments {:query "revenue"}}]
(messages/build-message-history
(-> (memory/initialize [{:role :user :content "Search for revenue"}] {})
(memory/add-step [{:type :tool-input
:id "t1"
:function "search"
:arguments {:query "revenue"}}]))))))
(testing "includes tool results from steps"
(is (=? [{:role :user :content "Search"}
{:type :tool-input :id "t1" :function "search"}
{:type :tool-output :id "t1"}]
(messages/build-message-history
(-> (memory/initialize [{:role :user :content "Search"}] {})
(memory/add-step [{:type :tool-input :id "t1" :function "search" :arguments {:query "test"}}])
(memory/add-step [{:type :tool-output :id "t1" :result {:data []}}]))))))
(testing "handles multiple iterations"
(is (=? [{:role :user :content "Hello"}
{:type :tool-input :id "t1"}
{:type :tool-output :id "t1"}
{:type :text :text "Found results"}]
(messages/build-message-history
(-> (memory/initialize [{:role :user :content "Hello"}] {})
(memory/add-step [{:type :tool-input :id "t1" :function "search" :arguments {}}])
(memory/add-step [{:type :tool-output :id "t1" :result {:data []}}])
(memory/add-step [{:type :text :text "Found results"}]))))))
(testing "filters out non-message parts from steps"
(is (=? [{:role :user :content "Hello"}
{:type :text :text "Response"}]
(messages/build-message-history
(-> (memory/initialize [{:role :user :content "Hello"}] {})
(memory/add-step [{:type :start :messageId "m1"}
{:type :text :text "Response"}
{:type :usage :usage {:promptTokens 10}}]))))))
(testing "merges consecutive assistant messages from input history"
;; Frontend may send separate text and tool_calls messages
(is (=? [{:role :user :content "Hello"}
{:type :text :text "I'll search for that."}
{:type :tool-input :id "t1" :function "search"}
{:type :tool-output :id "t1"}]
(messages/build-message-history
(memory/initialize [{:role :user :content "Hello"}
{:role :assistant :content "I'll search for that."}
{:role :assistant :tool_calls [{:id "t1" :name "search" :arguments "{}"}]}
{:role :tool :tool_call_id "t1" :content "results"}]
{}))))))
(deftest ^:parallel build-system-message-test
(testing "builds basic system message"
(let [profile {:prompt-template "internal.selmer"
:model "claude-sonnet-4-5-20250929"}]
(is (=? {:role "system" :content #"(?s).*Metabot.*"}
(messages/build-system-message {} profile {})))))
(testing "includes viewing context when provided"
(let [context {:user_is_viewing [{:type "dashboard" :id 1 :name "Sales Dashboard"}]}
profile {:prompt-template "internal.selmer"
:model "claude-sonnet-4-5-20250929"}]
(is (=? {:content #(not (str/blank? %))}
(messages/build-system-message context profile {})))))
(testing "handles empty context gracefully"
(let [profile {:prompt-template "internal.selmer"
:model "claude-sonnet-4-5-20250929"}]
(is (=? {:content #(not (str/blank? %))}
(messages/build-system-message {} profile {}))))))

Some files were not shown because too many files have changed in this diff Show More