소스 검색

feat: invoke agent capabilities

Jax Docker 1 개월 전
부모
커밋
870e8baa03

+ 8 - 0
README.md

@@ -386,6 +386,14 @@ Agent memory policy is stored on `agent_version.memory_policy_json`:
 - `write_enabled`: write a conversation memory after successful model execution
 - `config_json.write_importance_score`: optional importance score for written memories
 
+Agent capability refs are stored on `agent_version.tool_refs_json` and
+`agent_version.skill_refs_json`.
+
+- Tool refs are selected when `required=true`, `config_json.auto_invoke=true`, or `selection_keywords` match the run input.
+- Skill refs are selected by default unless `config_json.auto_invoke=false`; `selection_keywords` can also select them.
+- Dry-run execution returns `selected_tool_refs` and `selected_skill_refs` without calling downstream tools/skills.
+- Normal execution invokes selected HTTP tool bindings and selected skills before the model call, then injects their results into the model messages.
+
 Example version with session memory:
 
 ```powershell

+ 12 - 0
deployments/docker/docker-compose.yml

@@ -114,6 +114,8 @@ services:
       AGENT_PLATFORM_DATABASE_URL: sqlite:////data/agent_service.db
       AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
       AGENT_PLATFORM_MEMORY_SERVICE_URL: http://memory-service:8008
+      AGENT_PLATFORM_TOOL_SERVICE_URL: http://tool-service:8004
+      AGENT_PLATFORM_SKILL_SERVICE_URL: http://skill-service:8010
     ports:
       - "8007:8007"
     volumes:
@@ -123,6 +125,10 @@ services:
         condition: service_started
       memory-service:
         condition: service_started
+      tool-service:
+        condition: service_started
+      skill-service:
+        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8007/agents/health').read()"]
       interval: 15s
@@ -140,6 +146,8 @@ services:
       AGENT_PLATFORM_DATABASE_URL: sqlite:////data/agent_service.db
       AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
       AGENT_PLATFORM_MEMORY_SERVICE_URL: http://memory-service:8008
+      AGENT_PLATFORM_TOOL_SERVICE_URL: http://tool-service:8004
+      AGENT_PLATFORM_SKILL_SERVICE_URL: http://skill-service:8010
       AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS: ${AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS:-1}
       AGENT_PLATFORM_WORKER_LEASE_SECONDS: ${AGENT_PLATFORM_WORKER_LEASE_SECONDS:-300}
       AGENT_PLATFORM_WORKER_DRY_RUN: ${AGENT_PLATFORM_AGENT_WORKER_DRY_RUN:-false}
@@ -150,6 +158,10 @@ services:
         condition: service_started
       memory-service:
         condition: service_started
+      tool-service:
+        condition: service_started
+      skill-service:
+        condition: service_started
 
   memory-service:
     build:

+ 319 - 5
services/agent-service/app/application/services.py

@@ -4,6 +4,8 @@ from typing import cast
 from sqlalchemy.orm import Session
 
 from core_domain import (
+    AgentSkillRefContract,
+    AgentToolRefContract,
     ChatCompletionRequestContract,
     ChatMessageContract,
     MemoryCreateContract,
@@ -22,6 +24,8 @@ from app.domain.repositories import (
 )
 from app.infrastructure.model_gateway_client import ModelGatewayClient, ModelGatewayClientError
 from app.infrastructure.memory_client import MemoryClient, MemoryClientError
+from app.infrastructure.skill_client import SkillServiceClient, SkillServiceClientError
+from app.infrastructure.tool_client import ToolServiceClient, ToolServiceClientError
 from app.schemas.agent import (
     AgentCreateRequest,
     AgentRunCreateRequest,
@@ -41,12 +45,16 @@ class AgentApplicationService:
         agent_run_repository: AgentRunRepository,
         model_gateway_client: ModelGatewayClient | None = None,
         memory_client: MemoryClient | None = None,
+        tool_client: ToolServiceClient | None = None,
+        skill_client: SkillServiceClient | None = None,
     ) -> None:
         self.agent_repository = agent_repository
         self.agent_version_repository = agent_version_repository
         self.agent_run_repository = agent_run_repository
         self.model_gateway_client = model_gateway_client
         self.memory_client = memory_client
+        self.tool_client = tool_client
+        self.skill_client = skill_client
 
     def create_agent(self, payload: AgentCreateRequest) -> AgentDefinition:
         return self.agent_repository.create(
@@ -187,12 +195,18 @@ class AgentApplicationService:
             agent_run=agent_run,
             agent_version=agent_version,
         )
-        messages = self._build_chat_messages(
-            agent_run=agent_run,
-            agent_version=agent_version,
-            memory_results=memory_results,
-        )
+        selected_tools = self._select_tool_refs(agent_run=agent_run, agent_version=agent_version)
+        selected_skills = self._select_skill_refs(agent_run=agent_run, agent_version=agent_version)
         if payload.dry_run:
+            messages = self._build_chat_messages(
+                agent_run=agent_run,
+                agent_version=agent_version,
+                memory_results=memory_results,
+                capability_context=self._format_capability_plan(
+                    selected_tools=selected_tools,
+                    selected_skills=selected_skills,
+                ),
+            )
             return self.agent_run_repository.update_status(
                 agent_run_id=agent_run.id,
                 status="completed",
@@ -206,10 +220,35 @@ class AgentApplicationService:
                     "agent_version_id": agent_version.id,
                     "message_count": len(messages),
                     "messages": [message.model_dump(mode="json") for message in messages],
+                    "selected_tool_refs": [
+                        tool_ref.model_dump(mode="json") for tool_ref in selected_tools
+                    ],
+                    "selected_skill_refs": [
+                        skill_ref.model_dump(mode="json") for skill_ref in selected_skills
+                    ],
                     **memory_metadata,
                 },
             )
 
+        tool_invocations = self._invoke_selected_tools(
+            agent_run=agent_run,
+            selected_tools=selected_tools,
+        )
+        skill_invocations = self._invoke_selected_skills(
+            agent_run=agent_run,
+            selected_skills=selected_skills,
+            worker_key=payload.worker_key,
+        )
+        messages = self._build_chat_messages(
+            agent_run=agent_run,
+            agent_version=agent_version,
+            memory_results=memory_results,
+            capability_context=self._format_capability_results(
+                tool_invocations=tool_invocations,
+                skill_invocations=skill_invocations,
+            ),
+        )
+
         if self.model_gateway_client is None:
             return self.agent_run_repository.update_status(
                 agent_run_id=agent_run.id,
@@ -217,6 +256,11 @@ class AgentApplicationService:
                 worker_key=payload.worker_key,
                 error_code="model_gateway_missing",
                 error_message="model gateway client is not configured",
+                output_json={
+                    "tool_invocations": tool_invocations,
+                    "skill_invocations": skill_invocations,
+                    **memory_metadata,
+                },
             )
 
         try:
@@ -263,6 +307,8 @@ class AgentApplicationService:
                 "finish_reason": response.finish_reason,
                 "usage_json": response.usage_json,
                 "raw_response_json": response.raw_response_json,
+                "tool_invocations": tool_invocations,
+                "skill_invocations": skill_invocations,
                 **memory_metadata,
                 **memory_write_metadata,
             },
@@ -320,6 +366,7 @@ class AgentApplicationService:
         agent_run: AgentRun,
         agent_version: AgentVersion,
         memory_results: list[MemorySearchResultContract] | None = None,
+        capability_context: str | None = None,
     ) -> list[ChatMessageContract]:
         messages = [
             ChatMessageContract(role="system", content=agent_version.system_prompt),
@@ -333,6 +380,8 @@ class AgentApplicationService:
                     content=self._format_memory_context(memory_results),
                 )
             )
+        if capability_context:
+            messages.append(ChatMessageContract(role="system", content=capability_context))
         if agent_run.input_text:
             messages.append(ChatMessageContract(role="user", content=agent_run.input_text))
         if agent_run.input_json:
@@ -344,6 +393,263 @@ class AgentApplicationService:
             )
         return messages
 
+    def _select_tool_refs(
+        self,
+        *,
+        agent_run: AgentRun,
+        agent_version: AgentVersion,
+    ) -> list[AgentToolRefContract]:
+        input_preview = self._build_input_preview(agent_run)
+        selected: list[AgentToolRefContract] = []
+        for item in agent_version.tool_refs_json:
+            ref = AgentToolRefContract.model_validate(item)
+            if (
+                ref.required
+                or self._read_bool(ref.config_json, "auto_invoke", default=False)
+                or self._matches_selection_keywords(ref.config_json, input_preview)
+            ):
+                selected.append(ref)
+        return selected
+
+    def _select_skill_refs(
+        self,
+        *,
+        agent_run: AgentRun,
+        agent_version: AgentVersion,
+    ) -> list[AgentSkillRefContract]:
+        input_preview = self._build_input_preview(agent_run)
+        selected: list[AgentSkillRefContract] = []
+        for item in agent_version.skill_refs_json:
+            ref = AgentSkillRefContract.model_validate(item)
+            auto_invoke = self._read_bool(ref.config_json, "auto_invoke", default=True)
+            if auto_invoke or self._matches_selection_keywords(ref.config_json, input_preview):
+                selected.append(ref)
+        return selected
+
+    def _invoke_selected_tools(
+        self,
+        *,
+        agent_run: AgentRun,
+        selected_tools: list[AgentToolRefContract],
+    ) -> list[dict[str, JSONValue]]:
+        invocations: list[dict[str, JSONValue]] = []
+        for ref in selected_tools:
+            if ref.tool_binding_id is None:
+                invocations.append(
+                    {
+                        "status": "skipped",
+                        "reason": "tool_binding_id_missing",
+                        "tool_code": ref.tool_code,
+                    }
+                )
+                continue
+            if self.tool_client is None:
+                invocations.append(
+                    {
+                        "status": "failed",
+                        "reason": "tool_client_missing",
+                        "tool_binding_id": ref.tool_binding_id,
+                    }
+                )
+                continue
+            try:
+                detail = self.tool_client.get_tool_binding_detail(
+                    tenant_id=agent_run.tenant_id,
+                    binding_id=ref.tool_binding_id,
+                )
+                if not detail.binding.enabled:
+                    invocations.append(
+                        {
+                            "status": "failed",
+                            "reason": "tool_binding_disabled",
+                            "tool_binding_id": ref.tool_binding_id,
+                        }
+                    )
+                    continue
+                if detail.tool_definition.tool_type != "http":
+                    invocations.append(
+                        {
+                            "status": "skipped",
+                            "reason": "unsupported_tool_type",
+                            "tool_type": detail.tool_definition.tool_type,
+                            "tool_binding_id": ref.tool_binding_id,
+                        }
+                    )
+                    continue
+                output_text, output_json = self.tool_client.invoke_http_tool(
+                    detail=detail,
+                    input_json=agent_run.input_json or {},
+                    config_json=ref.config_json,
+                )
+            except ToolServiceClientError as exc:
+                invocations.append(
+                    {
+                        "status": "failed",
+                        "reason": str(exc),
+                        "tool_binding_id": ref.tool_binding_id,
+                    }
+                )
+                continue
+
+            invocations.append(
+                {
+                    "status": "completed",
+                    "tool_binding_id": ref.tool_binding_id,
+                    "tool_code": detail.tool_definition.code,
+                    "output_text": output_text,
+                    "output_json": output_json,
+                }
+            )
+        return invocations
+
+    def _invoke_selected_skills(
+        self,
+        *,
+        agent_run: AgentRun,
+        selected_skills: list[AgentSkillRefContract],
+        worker_key: str | None,
+    ) -> list[dict[str, JSONValue]]:
+        invocations: list[dict[str, JSONValue]] = []
+        for ref in selected_skills:
+            if self.skill_client is None:
+                invocations.append(
+                    {
+                        "status": "failed",
+                        "reason": "skill_client_missing",
+                        "skill_id": ref.skill_id,
+                        "skill_code": ref.skill_code,
+                    }
+                )
+                continue
+
+            skill_id = ref.skill_id or self._resolve_skill_id_by_code(
+                tenant_id=agent_run.tenant_id,
+                skill_code=ref.skill_code,
+            )
+            if skill_id is None:
+                invocations.append(
+                    {
+                        "status": "failed",
+                        "reason": "skill_id_missing",
+                        "skill_code": ref.skill_code,
+                    }
+                )
+                continue
+
+            try:
+                created_run = self.skill_client.create_skill_run(
+                    tenant_id=agent_run.tenant_id,
+                    skill_id=skill_id,
+                    skill_version_id=self._read_optional_string(
+                        ref.config_json,
+                        "skill_version_id",
+                    ),
+                    installation_id=self._read_optional_string(
+                        ref.config_json,
+                        "installation_id",
+                    ),
+                    input_json=self._build_skill_input_json(agent_run=agent_run, ref=ref),
+                )
+                executed_run = self.skill_client.execute_skill_run(
+                    tenant_id=agent_run.tenant_id,
+                    skill_run_id=created_run.id,
+                    worker_key=worker_key,
+                )
+            except SkillServiceClientError as exc:
+                invocations.append(
+                    {
+                        "status": "failed",
+                        "reason": str(exc),
+                        "skill_id": skill_id,
+                        "skill_code": ref.skill_code,
+                    }
+                )
+                continue
+
+            invocations.append(
+                {
+                    "status": executed_run.status,
+                    "skill_id": skill_id,
+                    "skill_code": ref.skill_code,
+                    "skill_run_id": executed_run.id,
+                    "output_text": executed_run.output_text,
+                    "output_json": executed_run.output_json,
+                    "error_code": executed_run.error_code,
+                    "error_message": executed_run.error_message,
+                }
+            )
+        return invocations
+
+    def _format_capability_plan(
+        self,
+        *,
+        selected_tools: list[AgentToolRefContract],
+        selected_skills: list[AgentSkillRefContract],
+    ) -> str:
+        return (
+            "Selected capability plan before model call:\n"
+            f"Tools: {[item.model_dump(mode='json') for item in selected_tools]}\n"
+            f"Skills: {[item.model_dump(mode='json') for item in selected_skills]}"
+        )
+
+    def _format_capability_results(
+        self,
+        *,
+        tool_invocations: list[dict[str, JSONValue]],
+        skill_invocations: list[dict[str, JSONValue]],
+    ) -> str | None:
+        if not tool_invocations and not skill_invocations:
+            return None
+        return (
+            "Capability invocation results before model call:\n"
+            f"Tools: {tool_invocations}\n"
+            f"Skills: {skill_invocations}"
+        )
+
+    def _build_skill_input_json(
+        self,
+        *,
+        agent_run: AgentRun,
+        ref: AgentSkillRefContract,
+    ) -> dict[str, JSONValue]:
+        input_json: dict[str, JSONValue] = dict(agent_run.input_json or {})
+        if agent_run.input_text:
+            input_json.setdefault("input_text", agent_run.input_text)
+        configured_input = ref.config_json.get("input_json")
+        if isinstance(configured_input, dict):
+            input_json.update(
+                {str(item_key): item_value for item_key, item_value in configured_input.items()}
+            )
+        return input_json
+
+    def _resolve_skill_id_by_code(self, *, tenant_id: str, skill_code: str | None) -> str | None:
+        if skill_code is None or self.skill_client is None:
+            return None
+        try:
+            skills = self.skill_client.list_skills(tenant_id=tenant_id)
+        except SkillServiceClientError:
+            return None
+        for skill in skills:
+            if skill.code == skill_code:
+                return skill.id
+        return None
+
+    def _build_input_preview(self, agent_run: AgentRun) -> str:
+        return f"{agent_run.input_text or ''} {agent_run.input_json or {}}".lower()
+
+    def _matches_selection_keywords(
+        self,
+        config_json: dict[str, JSONValue],
+        input_preview: str,
+    ) -> bool:
+        keywords = config_json.get("selection_keywords")
+        if not isinstance(keywords, list):
+            return False
+        return any(
+            isinstance(keyword, str) and keyword.lower() in input_preview
+            for keyword in keywords
+        )
+
     def _build_dry_run_output(self, *, agent_run: AgentRun, agent_version: AgentVersion) -> str:
         input_preview = agent_run.input_text or str(agent_run.input_json or {})
         return (
@@ -577,4 +883,12 @@ def build_agent_application_service(
             base_url=settings.memory_service_url,
             timeout_seconds=settings.memory_service_timeout_seconds,
         ),
+        tool_client=ToolServiceClient(
+            base_url=settings.tool_service_url,
+            timeout_seconds=settings.tool_service_timeout_seconds,
+        ),
+        skill_client=SkillServiceClient(
+            base_url=settings.skill_service_url,
+            timeout_seconds=settings.skill_service_timeout_seconds,
+        ),
     )

+ 4 - 0
services/agent-service/app/bootstrap/settings.py

@@ -9,6 +9,10 @@ class AgentServiceSettings(ServiceSettings):
     model_gateway_timeout_seconds: float = 60.0
     memory_service_url: str = "http://127.0.0.1:8008"
     memory_service_timeout_seconds: float = 10.0
+    tool_service_url: str = "http://127.0.0.1:8004"
+    tool_service_timeout_seconds: float = 10.0
+    skill_service_url: str = "http://127.0.0.1:8010"
+    skill_service_timeout_seconds: float = 10.0
     worker_poll_interval_seconds: float = 1.0
     worker_lease_seconds: int = 300
     worker_max_idle_cycles: int | None = None

+ 78 - 0
services/agent-service/app/infrastructure/skill_client.py

@@ -0,0 +1,78 @@
+import httpx
+
+from core_domain import SkillDefinitionContract, SkillRunContract
+from core_shared import JSONValue
+
+
+class SkillServiceClientError(Exception):
+    pass
+
+
+class SkillServiceClient:
+    def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
+        self.base_url = base_url.rstrip("/")
+        self.timeout_seconds = timeout_seconds
+
+    def list_skills(self, *, tenant_id: str) -> list[SkillDefinitionContract]:
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                response = client.get(
+                    f"{self.base_url}/skills",
+                    params={"tenant_id": tenant_id},
+                )
+                response.raise_for_status()
+                return [
+                    SkillDefinitionContract.model_validate(item)
+                    for item in response.json()
+                ]
+        except httpx.HTTPError as exc:
+            raise SkillServiceClientError(f"skill-service list failed: {exc}") from exc
+
+    def create_skill_run(
+        self,
+        *,
+        tenant_id: str,
+        skill_id: str,
+        skill_version_id: str | None,
+        installation_id: str | None,
+        input_json: dict[str, JSONValue],
+    ) -> SkillRunContract:
+        payload: dict[str, JSONValue] = {
+            "tenant_id": tenant_id,
+            "skill_id": skill_id,
+            "input_json": input_json,
+        }
+        if skill_version_id is not None:
+            payload["skill_version_id"] = skill_version_id
+        if installation_id is not None:
+            payload["installation_id"] = installation_id
+
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                response = client.post(f"{self.base_url}/skills/runs", json=payload)
+                response.raise_for_status()
+                return SkillRunContract.model_validate(response.json())
+        except httpx.HTTPError as exc:
+            raise SkillServiceClientError(f"skill-service create run failed: {exc}") from exc
+
+    def execute_skill_run(
+        self,
+        *,
+        tenant_id: str,
+        skill_run_id: str,
+        worker_key: str | None,
+    ) -> SkillRunContract:
+        payload: dict[str, JSONValue] = {"tenant_id": tenant_id}
+        if worker_key is not None:
+            payload["worker_key"] = worker_key
+
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                response = client.post(
+                    f"{self.base_url}/skills/runs/{skill_run_id}/execute",
+                    json=payload,
+                )
+                response.raise_for_status()
+                return SkillRunContract.model_validate(response.json())
+        except httpx.HTTPError as exc:
+            raise SkillServiceClientError(f"skill-service execute run failed: {exc}") from exc

+ 146 - 0
services/agent-service/app/infrastructure/tool_client.py

@@ -0,0 +1,146 @@
+import httpx
+
+from core_domain import ToolBindingDetailContract
+from core_shared import JSONValue
+
+
+class ToolServiceClientError(Exception):
+    pass
+
+
+class ToolServiceClient:
+    def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
+        self.base_url = base_url.rstrip("/")
+        self.timeout_seconds = timeout_seconds
+
+    def get_tool_binding_detail(
+        self,
+        *,
+        tenant_id: str,
+        binding_id: str,
+    ) -> ToolBindingDetailContract:
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                response = client.get(
+                    f"{self.base_url}/tools/bindings/{binding_id}",
+                    params={"tenant_id": tenant_id},
+                )
+                response.raise_for_status()
+                return ToolBindingDetailContract.model_validate(response.json())
+        except httpx.HTTPError as exc:
+            raise ToolServiceClientError(f"tool-service lookup failed: {exc}") from exc
+
+    def invoke_http_tool(
+        self,
+        *,
+        detail: ToolBindingDetailContract,
+        input_json: dict[str, JSONValue],
+        config_json: dict[str, JSONValue],
+    ) -> tuple[str | None, dict[str, JSONValue]]:
+        invoke_config_json = detail.tool_version.invoke_config_json or {}
+        binding_config_json = detail.binding.config_json or {}
+
+        url = _read_string(config_json, "url") or _read_string(invoke_config_json, "url")
+        base_url = (
+            _read_string(config_json, "base_url")
+            or _read_string(binding_config_json, "base_url")
+            or _read_string(invoke_config_json, "base_url")
+        )
+        path = _read_string(config_json, "path") or _read_string(invoke_config_json, "path")
+        resolved_url = _resolve_url(url=url, base_url=base_url, path=path)
+        if resolved_url is None:
+            raise ToolServiceClientError("http tool requires url or base_url/path")
+
+        method = (_read_string(invoke_config_json, "method") or "GET").upper()
+        headers = _merge_string_maps(
+            _read_dict(invoke_config_json, "headers"),
+            _read_dict(binding_config_json, "headers"),
+            _read_dict(config_json, "headers"),
+        )
+        params = _merge_string_maps(
+            _read_dict(invoke_config_json, "query"),
+            _read_dict(config_json, "query"),
+        )
+        body_json = _merge_json_dicts(
+            _read_dict(invoke_config_json, "body"),
+            _read_dict(config_json, "body"),
+        )
+        if not body_json and method not in {"GET", "HEAD"}:
+            body_json = input_json
+
+        try:
+            with httpx.Client(timeout=self.timeout_seconds) as client:
+                response = client.request(
+                    method=method,
+                    url=resolved_url,
+                    headers=headers,
+                    params=params,
+                    json=None if method in {"GET", "HEAD"} else body_json,
+                )
+                response.raise_for_status()
+        except httpx.HTTPError as exc:
+            raise ToolServiceClientError(f"http tool invocation failed: {exc}") from exc
+
+        response_json = _try_parse_json_response(response)
+        response_text = None if response_json is not None else response.text
+        return response_text, {
+            "tool_binding_id": detail.binding.id,
+            "tool_code": detail.tool_definition.code,
+            "tool_version_id": detail.tool_version.id,
+            "tool_name": detail.tool_definition.name,
+            "request_url": resolved_url,
+            "request_method": method,
+            "response_status_code": response.status_code,
+            "response_json": response_json,
+        }
+
+
+def _read_string(payload: dict[str, JSONValue], key: str) -> str | None:
+    value = payload.get(key)
+    if isinstance(value, str) and value:
+        return value
+    return None
+
+
+def _read_dict(payload: dict[str, JSONValue], key: str) -> dict[str, JSONValue]:
+    value = payload.get(key)
+    if isinstance(value, dict):
+        return {str(item_key): item_value for item_key, item_value in value.items()}
+    return {}
+
+
+def _merge_json_dicts(*items: dict[str, JSONValue]) -> dict[str, JSONValue]:
+    merged: dict[str, JSONValue] = {}
+    for item in items:
+        merged.update(item)
+    return merged
+
+
+def _merge_string_maps(*items: dict[str, JSONValue]) -> dict[str, str]:
+    merged: dict[str, str] = {}
+    for item in items:
+        for key, value in item.items():
+            if isinstance(value, (str, int, float, bool)):
+                merged[key] = str(value)
+    return merged
+
+
+def _resolve_url(*, url: str | None, base_url: str | None, path: str | None) -> str | None:
+    if url is not None:
+        return url
+    if base_url is None or path is None:
+        return None
+    return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
+
+
+def _try_parse_json_response(response: httpx.Response) -> JSONValue | None:
+    content_type = response.headers.get("content-type", "")
+    if "json" not in content_type.lower():
+        return None
+    try:
+        value = response.json()
+    except ValueError:
+        return None
+    if isinstance(value, (dict, list, str, int, float, bool)) or value is None:
+        return value
+    return None