IssueTriage can now interact with task in YouTrack and it is idempotent

This commit is contained in:
2026-09-20 20:56:36 +03:00
parent 74577cb50e
commit f25d9a5fab
34 changed files with 1834 additions and 201 deletions
+5
View File
@@ -14,6 +14,9 @@ def _as_dict(result: Any) -> dict:
if isinstance(result, dict):
return result
if isinstance(result, str):
return json.loads(result)
if not isinstance(result, list):
raise ValueError(f"Unexpected MCP response shape: {type(result)}")
@@ -57,6 +60,8 @@ def _as_list(result: Any) -> list[dict]:
text = result[0].get("text", "")
elif isinstance(result, list):
return result # already a list of dicts
elif isinstance(result, str):
return json.loads(result)
else:
raise ValueError(f"Unexpected MCP response shape: {type(result)}")
+41 -1
View File
@@ -6,6 +6,18 @@ class LLMClient:
self.model = model
self.client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
def chat_with_schema(self, prompt: str, schema: dict):
assert_strict_mode_clean(schema)
kwargs = {
"model": self.model,
"messages": [
{"role": "user", "content": prompt},
],
"response_format": schema,
}
resp = self.client.chat.completions.create(**kwargs)
return resp.choices[0].message.content
def chat(self, system: str, user: str, json_mode: bool = False, images: list[str] | None = None) -> str | None:
images = images or []
if images:
@@ -28,4 +40,32 @@ class LLMClient:
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = self.client.chat.completions.create(**kwargs)
return resp.choices[0].message.content
return resp.choices[0].message.content
def assert_strict_mode_clean(schema: dict, path: str = "$") -> None:
"""Recursively verify a JSON Schema obeys OpenAI strict mode."""
if schema.get("type") == "object":
props = schema.get("properties", {})
required = set(schema.get("required", []))
if schema.get("additionalProperties") is not False:
raise ValueError(f"{path}: additionalProperties must be false")
for name in props:
if name not in required:
raise ValueError(f"{path}.{name}: not in required")
for name, sub in props.items():
assert_strict_mode_clean(sub, f"{path}.{name}")
if schema.get("type") == "array":
assert_strict_mode_clean(schema["items"], f"{path}[]")
for name, sub in schema.get("$defs", {}).items():
assert_strict_mode_clean(sub, f"$defs.{name}")
# OpenAI rejects these keywords in strict mode
for banned in ("default", "oneOf"):
if banned in schema:
raise ValueError(f"{path}: '{banned}' not allowed in strict mode")
+24 -2
View File
@@ -63,7 +63,25 @@ class YouTrackMCPClient:
logger.debug("MCP CALL: %s with %s", name, arguments)
result = await self._call_tool_or_raise(name, arguments)
logger.debug("MCP RAW RESULT: %s", repr(result)[:500])
return result.structured_content or result.content
if result.structured_content is not None:
return result.structured_content
# Convert content blocks to JSON-serializable form
if result.content:
out = []
for block in result.content:
if getattr(block, "type", None) == "text":
out.append({"type": "text", "text": block.text})
else:
# fallback for other block types
out.append({"type": getattr(block, "type", "unknown"),
"data": str(block)})
# If it's a single text block, unwrap to plain string
if len(out) == 1 and out[0]["type"] == "text":
return out[0]["text"]
return out
return None
async def _call_tool_or_raise(self, name: str, arguments: dict):
try:
@@ -87,4 +105,8 @@ class YouTrackMCPClient:
raise IssueNotFound(text)
raise RuntimeError(f"MCP tool {name} failed: {text}")
return result
return result
async def list_tools(self):
"""List all available tools from the MCP server."""
return await self._client.list_tools()