Skip to content

feat(spec): resolve agent instructions from MLflow Prompt Registry - #3229

Open
Praneeth16 wants to merge 4 commits into
omnigent-ai:mainfrom
Praneeth16:feat/mlflow-prompt-registry-3226
Open

feat(spec): resolve agent instructions from MLflow Prompt Registry#3229
Praneeth16 wants to merge 4 commits into
omnigent-ai:mainfrom
Praneeth16:feat/mlflow-prompt-registry-3226

Conversation

@Praneeth16

Copy link
Copy Markdown
Contributor

Related issue

Closes #3226

Summary

Agent instructions: (legacy alias prompt:) could only be inline text or a bundle-relative file. Teams that keep prompts in the MLflow 3 Prompt Registry had to hand-copy prompt text into each bundle, which drifts from the registry-of-record and loses versioning/alias control.

This adds a third instructions: form that references a registry prompt and resolves to plain text at bundle-load time:

  • structured: instructions: {source: mlflow, reference: prompts:/name@production, vars: {product: X}}
  • shorthand: instructions: mlflow+prompts:/name@production

Resolution runs through one shared helper (omnigent/spec/mlflow_prompts.py) called by BOTH parser copies (spec/parser.py and inner/loader.py), so the rules can never drift between them. AgentSpec.instructions stays a plain str and no harness sees anything new. The OSS and Databricks-managed (Unity Catalog) registries share one code path, selected by config/env (registry_uri, tracking URI, auth) rather than branching business logic. v1 is load-time resolution only.

Safety properties (all covered by tests):

  • The registry fetch is gated on expand_env. A scaffolding / validation parse (untrusted or HTTP-uploaded bundle, expand_env=False) leaves the reference as its literal string and never contacts the registry on the author's behalf.
  • Prompts resolve by version or alias only. Pinned versions are immutable so MLflow caches them forever; aliases honor MLflow's own cache TTL (never wrapped in an unbounded cache) so a re-point is picked up.
  • Chat-style (message-list) prompts are rejected with a clear error; instructions must be a single text string.
  • Registry failures (down, missing alias/prompt, auth) surface a clear error and never silently fall back to a hardcoded prompt.
  • Auth stays in the environment / MLflow unified auth; secrets never live in YAML. Only the resolved prompt name and version are logged, never prompt contents.
  • mlflow is an optional extra, imported lazily; a registry reference with mlflow absent raises a clear pip install 'omnigent[mlflow]' error. The _read_contained_file path-containment model is untouched.

Test Plan

New tests (run from the worktree root against a throwaway sqlite-backed MLflow registry):

  • .venv/bin/python -m pytest tests/spec/test_mlflow_prompts.py tests/inner/test_loader_mlflow_prompts.py — 21 tests, all pass. Covers: shorthand + structured detection, malformed-form rejection, non-prompts:/ URI rejection, OSS resolve by version / alias / no-vars, chat-prompt rejection, missing alias/prompt failure, missing-mlflow-package install hint, parser + loader wiring, the expand_env=False no-fetch guard (monkeypatches the resolver to assert it is never called), the shared-helper assertion for the loader path, and Databricks-UC backend selection (mocked SDK: asserts registry_uri="databricks-uc", tracking URI not set, link_to_model=False).
  • Regression: existing tests/spec + tests/inner suites pass (302 tests + 3 subtests).
  • .venv/bin/pre-commit run --files <all changed files> — all hooks pass (ruff-format, ruff-check, lint hooks, version sync, normalize-uv-lock-registry, whitespace/EOF/merge-conflict/large-file checks).

Demo

N/A — non-visual change (backend prompt resolution).

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Manual verification: registered a text prompt and a chat-style prompt in a local sqlite MLflow registry, then confirmed a bundle with instructions: mlflow+prompts:/greeting@production resolves to the rendered text, the chat prompt is rejected with a clear error, and a validation parse (expand_env=False) returns the literal reference without any registry call.

Changelog

Agent instructions: can now reference an MLflow 3 Prompt Registry prompt (mlflow+prompts:/name@alias or a source: mlflow mapping), resolved to text at load time.

Agent `instructions:` (legacy alias `prompt:`) could only be inline text
or a bundle-relative file. Teams that manage prompts in the MLflow 3
Prompt Registry had to copy prompt text into each bundle by hand, which
drifts from the registry-of-record and loses versioning/alias control.

Add a third `instructions:` form that references a registry prompt and
resolves to plain text at bundle-load time:

  - structured: `instructions: {source: mlflow, reference:
    prompts:/name@production, vars: {product: X}}`
  - shorthand:  `instructions: mlflow+prompts:/name@production`

Resolution is funneled through one shared helper
(`omnigent/spec/mlflow_prompts.py`) called by BOTH parser copies
(`spec/parser.py` and `inner/loader.py`) so the rules can never drift.
`AgentSpec.instructions` stays a plain `str`; no harness sees anything
new. The OSS and Databricks-managed (Unity Catalog) registries share one
code path, selected by config/env (`registry_uri`, tracking URI, auth)
rather than branching business logic.

Safety properties baked in and covered by tests:

  - The registry fetch is gated on `expand_env`. A scaffolding /
    validation parse (untrusted or HTTP-uploaded bundle, `expand_env=
    False`) leaves the reference as its literal string and never
    contacts the registry on the author's behalf.
  - Prompts resolve by version or alias only. Pinned versions are
    immutable so MLflow caches them forever; aliases honor MLflow's own
    cache TTL (never wrapped in an unbounded cache) so a re-point is
    picked up.
  - Chat-style (message-list) prompts are rejected with a clear error;
    instructions must be a single text string.
  - Registry failures (down, missing alias/prompt, auth) surface a clear
    error and never silently fall back to a hardcoded prompt.
  - Auth stays in the environment / MLflow unified auth; secrets never
    live in YAML. Only the resolved prompt name and version are logged,
    never prompt contents.
  - `mlflow` is an optional extra, imported lazily; a registry reference
    with mlflow absent raises a clear `pip install 'omnigent[mlflow]'`
    error. The `_read_contained_file` path-containment model is
    untouched.

Closes omnigent-ai#3226

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
@github-actions github-actions Bot added the size/XL Pull request size: XL label Jul 24, 2026
@github-actions
github-actions Bot requested a review from bbqiu July 24, 2026 11:57
praneeth_paikray-data added 3 commits July 24, 2026 17:55
The new optional `mlflow` extra pulls in mlflow-skinny, whose transitive
dependency gitpython resolved to 3.1.50. That version carries three OSV
advisories fixed in 3.1.51:

  GHSA-2f96-g7mh-g2hx
  GHSA-v396-v7q4-x2qj
  GHSA-956x-8gvw-wg5v

Because the extra widened the dependency graph, the Security Scan job's
`uv export --frozen --all-extras | pip-audit` step now sees gitpython and
fails, cascading into the Security Gate shards and Maintainer Approval.

Add a uv constraint (`gitpython>=3.1.51,<4`) rather than a direct
dependency so gitpython is only pulled in by extras that already use it,
and relock. Resolution lands on gitpython 3.1.52, which OSV reports with
zero known vulnerabilities. Only gitpython moves in the lock; no other
package churns.

Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
…curity bugs

Cross-vendor review of the MLflow Prompt Registry resolver surfaced six
blocking bugs where the resolver could silently resolve the wrong prompt,
leak state across registries, stringify a repr into an agent's
instructions, or accept secrets in YAML. Fix each at the source and add a
regression test per bug.

- URI validation was prefix-only, so `prompts:/greeting/1?tag=production`
  passed and MLflow silently resolved version 1, ignoring the tag. Require
  exactly `prompts:/<name>@<alias>` or `prompts:/<name>/<version>` and
  reject any query string, fragment, or trailing junk — MLflow resolves by
  version or alias only and would drop the extra selector.
- Resolution mutated `mlflow.set_tracking_uri` / `set_registry_uri`
  process globals and never restored them, so a Databricks-UC load
  contaminated a later OSS load and concurrent loads raced. Resolve through
  a per-call `MlflowClient(tracking_uri=..., registry_uri=...)` instead and
  never touch globals.
- MLflow's prompt cache is process-global and keyed by URI only, so
  registry B could be served registry A's cached prompt for the same
  `prompts:/name/version` URI. Disable the cache (cache_ttl_seconds=0) by
  default; callers opt back in with an explicit cache_ttl when a single
  registry is in play.
- `str(prompt.format(**vars))` defeated type safety: with a partial render
  `format` returns a `PromptVersion` (or a message list), and `str(...)`
  stuffed its repr into the instructions. Assert the render is a `str` and
  raise otherwise.
- tracking_uri / registry_uri accepted `user:pass@host` credentials,
  putting secrets in agent YAML. Reject any userinfo-bearing URI and point
  at the environment / MLflow unified auth.
- `if vars:` skipped formatting for an explicit empty mapping; key on
  presence (`vars is not None`) so `vars: {}` renders consistently.

`MlflowClient.load_prompt(cache_ttl_seconds=...)` landed in MLflow 3.8.0
(mlflow#18963; absent in 3.7.x), so raise the `mlflow` extra and dev floor
from >=3 to >=3.8 and relock (specifier metadata only; no resolved
versions move, gitpython>=3.1.51 constraint intact).

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
…a is absent

mlflow is an optional extra, but both prompt-registry test modules imported
it at module top level, so CI pytest groups that don't install
omnigent[mlflow] failed at collection with ModuleNotFoundError instead of
skipping. Guard the optional import with pytest.importorskip('mlflow') as the
first mlflow touch in each module and defer the mlflow.genai symbol imports
until after the guard, so the modules skip cleanly when mlflow is absent and
still run in full where it is installed.

Co-authored-by: Isaac
Signed-off-by: praneeth_paikray-data <praneeth.paikray@databricks.com>
@github-actions

Copy link
Copy Markdown
Contributor

@Praneeth16 This PR is a Bug fix, Feature, or UI / frontend change but the Demo section is missing or only contains a placeholder.

These change types require a screenshot or screen recording so reviewers can see the new behaviour without checking out the branch. Please update the Demo section with:

  • A screenshot or screen recording of the change, or
  • A link to a hosted video or GIF showing the new behaviour.

Use N/A only when the change has no user-visible effect whatsoever (e.g. a pure refactor or test-only change). If that's the case, uncheck the relevant type box and check Refactor / chore or Test / CI instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-demo PR needs a demo screenshot or recording size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Resolve agent instructions from an MLflow 3 Prompt Registry

2 participants