Skip to content

Commit a021e24

Browse files
authored
Merge pull request #747 from NeptuneHub/devel
AI tools integration fix
2 parents fd7e7fa + 935cf28 commit a021e24

4 files changed

Lines changed: 76 additions & 7 deletions

File tree

tasks/ai/prompts.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,39 @@ def build_ollama_tool_calling_prompt(
284284
Fill only fields the user asked for. Return ONLY the JSON object."""
285285

286286

287+
def _inject_unique_items(schema: Dict) -> Dict:
288+
"""Add ``uniqueItems: True`` to every array-typed property recursively.
289+
290+
Small Ollama models can loop the same value forever in structured-output
291+
mode; ``uniqueItems`` prevents that. Injected here rather than in the
292+
shared ``tools.py`` schema so that only the Ollama structured-output path
293+
is affected -- Gemini, Mistral, and native OpenAI tool-calling use the
294+
schema as-is.
295+
"""
296+
if not isinstance(schema, dict):
297+
return schema
298+
# Snapshot keys so we can safely mutate the dict while walking it.
299+
for key, value in list(schema.items()):
300+
if key == 'type' and value == 'array':
301+
schema.setdefault('uniqueItems', True)
302+
elif isinstance(value, dict):
303+
_inject_unique_items(value)
304+
elif isinstance(value, list):
305+
for item in value:
306+
if isinstance(item, dict):
307+
_inject_unique_items(item)
308+
return schema
309+
310+
287311
def build_tool_calls_schema(tools: List[Dict]) -> Dict:
288312
branches: List[Dict] = []
289313
for t in tools:
290314
name = t.get('name')
291315
if not name:
292316
continue
293-
arg_schema = copy.deepcopy(t.get('inputSchema') or {"type": "object"})
317+
arg_schema = _inject_unique_items(
318+
copy.deepcopy(t.get('inputSchema') or {"type": "object"})
319+
)
294320
arg_schema['additionalProperties'] = False
295321
branches.append(
296322
{

tasks/ai/tools.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
Main Features:
1717
* get_mcp_tools builds the schema dynamically, exposing text_match modes only when CLAP/LYRICS are enabled; tool descriptions carry the routing rules (when to use each tool and when to use a sibling instead) so they work as the primary routing signal for small models, with genre/voice/mood enums from the canonical vocab.
1818
* execute_mcp_tool converts normalized energy 0..1 to raw score units before search_database, expands female/male voice spelling variants deterministically, passes exclude_artists/exclude_genres through as hard SQL cuts, and rejects year-only text_match queries (routing them to search_database); all failures return a generic error, never a traceback.
19-
* Array args carry maxItems/uniqueItems caps so small-model structured output cannot loop a value forever; exclusion fields document that excluded names never go in seeds or positive filters.
19+
* Array args carry maxItems caps so small-model structured output cannot loop a value forever; the Ollama structured-output path in prompts.py adds uniqueItems dynamically. Exclusion fields document that excluded names never go in seeds or positive filters.
2020
"""
2121

2222
import logging
@@ -493,14 +493,12 @@ def get_mcp_tools() -> List[Dict]:
493493
"type": "array",
494494
"items": {"type": "string", "enum": list(GENRE_VOCAB)},
495495
"maxItems": 5,
496-
"uniqueItems": True,
497496
"description": "Music genres the user WANTS, e.g. ['jazz'] or ['rock', 'blues'].",
498497
},
499498
"voices": {
500499
"type": "array",
501500
"items": {"type": "string", "enum": list(VOICE_ENUM)},
502501
"maxItems": 2,
503-
"uniqueItems": True,
504502
"description": (
505503
"Vocal type: 'female vocalists' for any female-voice request, "
506504
"'male vocalists' for any male-voice request."
@@ -510,7 +508,6 @@ def get_mcp_tools() -> List[Dict]:
510508
"type": "array",
511509
"items": {"type": "string", "enum": list(config.OTHER_FEATURE_LABELS)},
512510
"maxItems": 3,
513-
"uniqueItems": True,
514511
"description": "How it feels, e.g. ['sad'] or ['danceable', 'party'].",
515512
},
516513
"tempo_min": {"type": "number", "description": "Min BPM 40-200, e.g. 120"},
@@ -552,7 +549,6 @@ def get_mcp_tools() -> List[Dict]:
552549
"type": "array",
553550
"items": {"type": "string"},
554551
"maxItems": 10,
555-
"uniqueItems": True,
556552
"description": (
557553
"Artists the user does NOT want ('no 50 Cent' -> ['50 Cent']). "
558554
"Hard-removed from the results; never put these in artist or seeds."
@@ -562,7 +558,6 @@ def get_mcp_tools() -> List[Dict]:
562558
"type": "array",
563559
"items": {"type": "string", "enum": list(GENRE_VOCAB)},
564560
"maxItems": 5,
565-
"uniqueItems": True,
566561
"description": (
567562
"Genres the user does NOT want ('no rap' -> ['Hip-Hop']). "
568563
"Hard-removed from the results; never put these in genres."

test/unit/test_mcp_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,21 @@ def test_unknown_tool_returns_error(self):
607607

608608

609609
class TestToolSurface:
610+
def test_shared_schemas_omit_ollama_only_unique_items(self):
611+
ai_mod = _import_ai_mcp_client()
612+
613+
def contains_unique_items(value):
614+
if isinstance(value, dict):
615+
return 'uniqueItems' in value or any(
616+
contains_unique_items(child) for child in value.values()
617+
)
618+
if isinstance(value, list):
619+
return any(contains_unique_items(child) for child in value)
620+
return False
621+
622+
for tool in ai_mod.get_mcp_tools():
623+
assert not contains_unique_items(tool['inputSchema'])
624+
610625
def test_no_llm_facing_get_songs_or_dead_params(self):
611626
ai_mod = _import_ai_mcp_client()
612627
for tool in ai_mod.get_mcp_tools():

test/unit/test_tool_plan.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,39 @@ def test_source_schema_not_mutated(self):
593593
pr.build_tool_calls_schema(tools)
594594
assert 'additionalProperties' not in tools[0]['inputSchema']
595595

596+
def test_unique_items_are_added_only_to_derived_argument_arrays(self):
597+
pr = _prompts()
598+
tools = _tools_fixture()
599+
600+
schema = pr.build_tool_calls_schema(tools)
601+
branches = schema['properties']['tool_calls']['items']['oneOf']
602+
argument_schemas = [branch['properties']['arguments'] for branch in branches]
603+
604+
def array_schemas(value):
605+
if isinstance(value, dict):
606+
if value.get('type') == 'array':
607+
yield value
608+
for child in value.values():
609+
yield from array_schemas(child)
610+
elif isinstance(value, list):
611+
for child in value:
612+
yield from array_schemas(child)
613+
614+
derived_arrays = [
615+
array_schema
616+
for argument_schema in argument_schemas
617+
for array_schema in array_schemas(argument_schema)
618+
]
619+
source_arrays = [
620+
array_schema
621+
for tool in tools
622+
for array_schema in array_schemas(tool['inputSchema'])
623+
]
624+
625+
assert derived_arrays
626+
assert all(array_schema['uniqueItems'] is True for array_schema in derived_arrays)
627+
assert all('uniqueItems' not in array_schema for array_schema in source_arrays)
628+
596629

597630
class TestPromptRendering:
598631
def test_system_prompt_derives_tools_and_rules(self):

0 commit comments

Comments
 (0)