IssueTriage can now interact with task in YouTrack and it is idempotent
This commit is contained in:
@@ -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
@@ -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")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user