Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 32 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ on:
- "**"

jobs:
backend-format:
backend-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install just
uses: extractions/setup-just@v2

- name: Install uv
uses: astral-sh/setup-uv@v7

Expand All @@ -21,27 +24,27 @@ jobs:

- name: Install dependencies
working-directory: backend/omni
run: uv sync --all-extras

- name: Check formatting
working-directory: backend/omni
run: uv run ruff format --check .
run: just install

- name: Lint
- name: Check
working-directory: backend/omni
run: uv run ruff check .
run: just check

frontend-format:
frontend-check:
runs-on: ubuntu-latest
strategy:
matrix:
working-dir:
- frontend/omni
- e2e_tests/tests_omni_full
- e2e_tests/tests_omni_light
- docs
steps:
- uses: actions/checkout@v6

- name: Install just
uses: extractions/setup-just@v2

- name: Set up Node.js
uses: actions/setup-node@v6
with:
Expand All @@ -52,17 +55,20 @@ jobs:

- name: Install dependencies
working-directory: ${{ matrix.working-dir }}
run: pnpm install
run: just install

- name: Check formatting and linting
- name: Check
working-directory: ${{ matrix.working-dir }}
run: pnpm check
run: just check

backend-unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install just
uses: extractions/setup-just@v2

- name: Install uv
uses: astral-sh/setup-uv@v7

Expand All @@ -71,17 +77,20 @@ jobs:

- name: Install dependencies
working-directory: backend/omni
run: uv sync --all-extras
run: just install

- name: Run tests
working-directory: backend/omni
run: uv run pytest -v
run: just test

frontend-unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install just
uses: extractions/setup-just@v2

- name: Set up Node.js
uses: actions/setup-node@v6
with:
Expand All @@ -92,11 +101,11 @@ jobs:

- name: Install dependencies
working-directory: frontend/omni
run: pnpm install
run: just install

- name: Run unit tests
working-directory: frontend/omni
run: pnpm test
run: just test

e2e-test:
runs-on: ubuntu-latest
Expand All @@ -108,6 +117,9 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: Install just
uses: extractions/setup-just@v2

- name: Install uv
uses: astral-sh/setup-uv@v7

Expand All @@ -124,24 +136,20 @@ jobs:

- name: Install dependencies for frontend
working-directory: frontend/omni
run: pnpm install
run: just install

- name: Install dependencies for backend (full tests only)
if: matrix.name == 'tests_omni_full'
working-directory: backend/omni
run: uv sync --all-extras
run: just install

- name: Install dependencies for tests
working-directory: e2e_tests/${{ matrix.name }}
run: pnpm install

- name: Install Playwright browsers
working-directory: e2e_tests/${{ matrix.name }}
run: pnpm playwright install --with-deps
run: just install

- name: Run Playwright tests
working-directory: e2e_tests/${{ matrix.name }}
run: pnpm test
run: just test

- name: Upload Test Results (traces, screenshots, videos)
uses: actions/upload-artifact@v7
Expand Down
2 changes: 1 addition & 1 deletion backend/omni/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ default:

# Install dependencies
install:
uv sync
uv sync --all-extras

# Start the development server
start:
Expand Down
6 changes: 4 additions & 2 deletions backend/omni/src/modai/default_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,11 @@ modules:
config:
# Map tool parameter names that differ from the predefined variable name.
# Format: <tool_param_name>: <predefined_var_name_without_leading_underscore>
# Example: the predefined _session_id fills the tool's X-Session-Id header param.
# Only needed when the tool parameter name cannot be derived from the header
# name by normalisation (lowercase + hyphens to underscores). Example:
# variable_mappings:
# X-Session-Id: session_id
# - from_modai_header: X-Session-Id
# to_tool_parameter: session_id

tool_registry:
class: modai.modules.tools.tool_router.ToolsRouterModule
Expand Down
32 changes: 32 additions & 0 deletions backend/omni/src/modai/modules/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,35 @@ Behavior:
- Returns tool names as provided by underlying registries (no prefixing).
- On `run_tool`, routes to the registry that provides the requested tool name.
- If multiple registries provide the same tool name, invocation fails with an ambiguity error.

## PredefinedVariablesToolRegistryModule

Purpose: wraps another `ToolRegistryModule` and automatically hides tool parameters whose values are already known from request headers, so the AI never sees or fills them.

Class used in config:
- `modai.modules.tools.tool_registry_predefined_vars.PredefinedVariablesToolRegistryModule`

Every incoming request header is normalised (lowercased, hyphens replaced with underscores) and matched against tool parameter names. A tool parameter is hidden and auto-filled if:
- its name equals the normalised header name (e.g. `x_session_id` ← header `X-Session-Id`), **or**
- its name normalises to the same value (e.g. `X-Session-Id` ← header `X-Session-Id`)

No configuration is needed for either case. `variable_mappings` is only required when the tool parameter name cannot be derived from the header name by normalisation (e.g. tool expects `session_id` from header `X-Session-Id`).

```yaml
modules:
predefined_tool_registry:
class: modai.modules.tools.tool_registry_predefined_vars.PredefinedVariablesToolRegistryModule
module_dependencies:
delegate_registry: "openapi_tool_registry"
config:
# Optional — only needed when the tool param name cannot be derived from the header:
# variable_mappings:
# - from_modai_header: X-Session-Id
# to_tool_parameter: session_id
```

Supported config keys:
- `variable_mappings` (optional list): each entry has `from_modai_header` (request header name) and `to_tool_parameter` (tool parameter name to fill).

Module dependencies:
- `delegate_registry`: the `ToolRegistryModule` to wrap
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock

import pytest
from fastapi import Request
Expand Down Expand Up @@ -35,9 +35,10 @@ def _request(headers: dict[str, str] | None = None) -> Request:
"properties": {
"user_id": {"type": "string"},
"session_id": {"type": "string"},
"x_session_id": {"type": "string"},
"X-Session-Id": {"type": "string"},
},
"required": ["user_id", "session_id", "X-Session-Id"],
"required": ["user_id", "session_id", "x_session_id", "X-Session-Id"],
},
"strict": True,
}
Expand All @@ -46,7 +47,7 @@ def _request(headers: dict[str, str] | None = None) -> Request:
def _make_module(
inner_get_tools: AsyncMock,
inner_run_tool: AsyncMock,
variable_mappings: dict[str, str] | None = None,
variable_mappings: list[dict[str, str]] | None = None,
) -> PredefinedVariablesToolRegistryModule:
inner = MagicMock(spec=ToolRegistryModule)
inner.get_tools = inner_get_tools
Expand All @@ -60,95 +61,125 @@ def _make_module(


class TestExtractPredefinedParams:
def test_extracts_header_values_with_underscore_prefix(self):
def test_stores_both_hyphen_and_underscore_forms(self):
params = _extract_predefined_params(
_request({"Authorization": "Bearer abc", "X-Session-Id": "sid-1"})
_request({"X-Session-Id": "sid-1", "X-User-Id": "uid-1"})
)
assert params == {
"_authorization": "Bearer abc",
"_x_session_id": "sid-1",
"x-session-id": "sid-1",
"x_session_id": "sid-1",
"x-user-id": "uid-1",
"x_user_id": "uid-1",
}

def test_explicit_mapping_adds_target_param_name(self):
params = _extract_predefined_params(
_request({"X-Session-Id": "sid-1"}),
variable_mappings=[
{"from_modai_header": "X-Session-Id", "to_tool_parameter": "session_id"}
],
)
assert params["session_id"] == "sid-1"


class TestGetTools:
@pytest.mark.asyncio
async def test_hides_schema_properties_that_are_predefined(self):
async def test_auto_hides_predefined_vars_form_parameter(self):
"""Header X-Session-Id auto-fills tool param x_session_id without config."""
module = _make_module(
inner_get_tools=AsyncMock(return_value=[FULL_TOOL]),
inner_run_tool=AsyncMock(),
)

result = await module.get_tools(_request({"Session-Id": "sid-1"}))
result = await module.get_tools(_request({"X-Session-Id": "sid-1"}))

assert len(result) == 1
props = result[0]["parameters"]["properties"]
assert "session_id" not in props
assert "x_session_id" not in props
assert "X-Session-Id" not in props
assert "session_id" in props
assert "user_id" in props

@pytest.mark.asyncio
async def test_hides_mapped_property_from_definition(self):
async def test_explicit_mapping_hides_target_parameter(self):
"""Explicit mapping hides the mapped param in addition to auto-matched forms."""
module = _make_module(
inner_get_tools=AsyncMock(return_value=[FULL_TOOL]),
inner_run_tool=AsyncMock(),
variable_mappings={"X-Session-Id": "x_session_id"},
variable_mappings=[
{"from_modai_header": "X-Session-Id", "to_tool_parameter": "session_id"}
],
)

result = await module.get_tools(_request({"X-Session-Id": "sid-1"}))

props = result[0]["parameters"]["properties"]
# explicit mapping target is hidden
assert "session_id" not in props
# auto-matched forms are still hidden
assert "x_session_id" not in props
assert "X-Session-Id" not in props
assert "user_id" in props


class TestRunTool:
@pytest.mark.asyncio
async def test_injects_predefined_values_into_arguments(self):
async def test_auto_injects_underscore_form_parameter(self):
"""Header X-Session-Id is injected as x_session_id without explicit config."""
inner_run_tool = AsyncMock(return_value="ok")
module = _make_module(
inner_get_tools=AsyncMock(return_value=[FULL_TOOL]),
inner_run_tool=inner_run_tool,
)

await module.run_tool(
_request({"Session-Id": "sid-1"}),
{
"name": "get_user_order",
"arguments": {"user_id": "alice"},
},
_request({"X-Session-Id": "sid-1"}),
{"name": "get_user_order", "arguments": {"user_id": "alice"}},
)

inner_run_tool.assert_awaited_once_with(
ANY,
{
"name": "get_user_order",
"arguments": {"user_id": "alice", "session_id": "sid-1"},
},
)
call_args = inner_run_tool.call_args[0][1]
assert call_args["arguments"]["x_session_id"] == "sid-1"

@pytest.mark.asyncio
async def test_mapped_injection_uses_configured_tool_param_name(self):
async def test_auto_injects_header_style_parameter(self):
"""Header X-Session-Id is also injected as X-Session-Id without explicit config."""
inner_run_tool = AsyncMock(return_value="ok")
module = _make_module(
inner_get_tools=AsyncMock(return_value=[FULL_TOOL]),
inner_run_tool=inner_run_tool,
variable_mappings={"X-Session-Id": "x_session_id"},
)

await module.run_tool(
_request({"X-Session-Id": "sid-1"}),
{
"name": "get_user_order",
"arguments": {"user_id": "alice"},
},
{"name": "get_user_order", "arguments": {"user_id": "alice"}},
)

inner_run_tool.assert_awaited_once_with(
ANY,
{
"name": "get_user_order",
"arguments": {"user_id": "alice", "X-Session-Id": "sid-1"},
},
call_args = inner_run_tool.call_args[0][1]
assert call_args["arguments"]["X-Session-Id"] == "sid-1"

@pytest.mark.asyncio
async def test_explicit_mapping_injects_target_parameter(self):
"""Explicit mapping fills the specified tool param alongside auto-matched ones."""
inner_run_tool = AsyncMock(return_value="ok")
module = _make_module(
inner_get_tools=AsyncMock(return_value=[FULL_TOOL]),
inner_run_tool=inner_run_tool,
variable_mappings=[
{"from_modai_header": "X-Session-Id", "to_tool_parameter": "session_id"}
],
)

await module.run_tool(
_request({"X-Session-Id": "sid-1"}),
{"name": "get_user_order", "arguments": {"user_id": "alice"}},
)

call_args = inner_run_tool.call_args[0][1]
# explicit mapping target is injected
assert call_args["arguments"]["session_id"] == "sid-1"
# auto-matched forms are also still injected
assert call_args["arguments"]["x_session_id"] == "sid-1"
assert call_args["arguments"]["X-Session-Id"] == "sid-1"

@pytest.mark.asyncio
async def test_unknown_tool_raises(self):
module = _make_module(
Expand Down
Loading
Loading