yeschef-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yeschef/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """yeschef: task dispatch and multi-agent conversation hub."""
2
+
3
+ __version__ = "0.1.0"
yeschef/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m yeschef`` (used for detached child processes)."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,6 @@
1
+ """Reference harness that turns a local model endpoint into a hub agent."""
2
+
3
+ from .config import AgentConfig
4
+ from .harness import Harness, run_agent
5
+
6
+ __all__ = ["AgentConfig", "Harness", "run_agent"]
@@ -0,0 +1,53 @@
1
+ """Model backends for the harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .anthropic_compat import AnthropicCompatBackend
6
+ from .base import Backend, ChatResult, ToolCall, ToolResult, Turn
7
+ from .cli import CliBackend
8
+ from .openai_compat import OpenAICompatBackend
9
+
10
+
11
+ def build_backend(config: dict) -> Backend:
12
+ """Construct a backend from the `[backend]` block of an agent config.
13
+
14
+ `tahoma` is an OpenAI-compatible preset — Tahoma serves /v1/chat/completions, so it
15
+ shares the adapter and only differs in defaults.
16
+ """
17
+ kind = (config.get("type") or "openai_compat").lower()
18
+ if kind == "cli":
19
+ return CliBackend(
20
+ command=list(config.get("command") or []),
21
+ model=config.get("model", "cli-agent"),
22
+ timeout=float(config.get("timeout_s", 1800.0)),
23
+ env=dict(config.get("env") or {}),
24
+ )
25
+ common = {
26
+ "base_url": config["base_url"],
27
+ "model": config["model"],
28
+ "api_key": config.get("api_key"),
29
+ "timeout": float(config.get("timeout_s", 600.0)),
30
+ "extra_body": config.get("extra_body") or {},
31
+ "extra_headers": config.get("extra_headers") or {},
32
+ }
33
+ if kind == "anthropic_compat":
34
+ return AnthropicCompatBackend(**common)
35
+ if kind in ("openai_compat", "tahoma"):
36
+ backend = OpenAICompatBackend(**common)
37
+ if kind == "tahoma":
38
+ backend.name = "tahoma"
39
+ return backend
40
+ raise ValueError(f"unknown backend type: {kind}")
41
+
42
+
43
+ __all__ = [
44
+ "AnthropicCompatBackend",
45
+ "Backend",
46
+ "CliBackend",
47
+ "ChatResult",
48
+ "OpenAICompatBackend",
49
+ "ToolCall",
50
+ "ToolResult",
51
+ "Turn",
52
+ "build_backend",
53
+ ]
@@ -0,0 +1,119 @@
1
+ """Anthropic Messages API shape — Ollama v0.14+ serves this natively at /v1/messages.
2
+
3
+ Ollama defaults to a 4,096-token context; raise it (num_ctx / OLLAMA_CONTEXT_LENGTH) or
4
+ long task prompts are silently truncated.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .base import ChatResult, ToolCall, Turn
14
+
15
+
16
+ class AnthropicCompatBackend:
17
+ name = "anthropic_compat"
18
+
19
+ def __init__(
20
+ self,
21
+ base_url: str,
22
+ model: str,
23
+ api_key: str | None = None,
24
+ timeout: float = 600.0,
25
+ extra_body: dict | None = None,
26
+ extra_headers: dict | None = None,
27
+ ) -> None:
28
+ self.base_url = base_url.rstrip("/")
29
+ self.model = model
30
+ self.extra_body = extra_body or {}
31
+ headers = {"Content-Type": "application/json", "anthropic-version": "2023-06-01"}
32
+ if api_key:
33
+ headers["x-api-key"] = api_key
34
+ headers.update(extra_headers or {})
35
+ self._http = httpx.AsyncClient(headers=headers, timeout=timeout)
36
+
37
+ async def close(self) -> None:
38
+ await self._http.aclose()
39
+
40
+ async def chat(
41
+ self,
42
+ system: str,
43
+ turns: list[Turn],
44
+ tools: list[dict] | None = None,
45
+ max_tokens: int = 2048,
46
+ temperature: float = 0.7,
47
+ ) -> ChatResult:
48
+ messages: list[dict[str, Any]] = []
49
+ for turn in turns:
50
+ if turn.role == "assistant" and turn.tool_calls:
51
+ content: list[dict] = []
52
+ if turn.content:
53
+ content.append({"type": "text", "text": turn.content})
54
+ content.extend(
55
+ {
56
+ "type": "tool_use",
57
+ "id": call.id,
58
+ "name": call.name,
59
+ "input": call.arguments,
60
+ }
61
+ for call in turn.tool_calls
62
+ )
63
+ messages.append({"role": "assistant", "content": content})
64
+ else:
65
+ messages.append({"role": turn.role, "content": turn.content})
66
+ if turn.tool_results:
67
+ messages.append(
68
+ {
69
+ "role": "user",
70
+ "content": [
71
+ {
72
+ "type": "tool_result",
73
+ "tool_use_id": result.call_id,
74
+ "content": result.content,
75
+ "is_error": result.is_error,
76
+ }
77
+ for result in turn.tool_results
78
+ ],
79
+ }
80
+ )
81
+
82
+ payload: dict[str, Any] = {
83
+ "model": self.model,
84
+ "system": system,
85
+ "messages": messages,
86
+ "max_tokens": max_tokens,
87
+ "temperature": temperature,
88
+ **self.extra_body,
89
+ }
90
+ if tools:
91
+ payload["tools"] = [
92
+ {
93
+ "name": tool["name"],
94
+ "description": tool.get("description", ""),
95
+ "input_schema": tool.get("input_schema", {"type": "object"}),
96
+ }
97
+ for tool in tools
98
+ ]
99
+
100
+ response = await self._http.post(f"{self.base_url}/v1/messages", json=payload)
101
+ response.raise_for_status()
102
+ data = response.json()
103
+
104
+ text_parts, calls = [], []
105
+ for block in data.get("content") or []:
106
+ if block.get("type") == "text":
107
+ text_parts.append(block.get("text", ""))
108
+ elif block.get("type") == "tool_use":
109
+ calls.append(
110
+ ToolCall(id=block["id"], name=block["name"], arguments=block.get("input") or {})
111
+ )
112
+ usage = data.get("usage") or {}
113
+ return ChatResult(
114
+ text="".join(text_parts),
115
+ tool_calls=calls,
116
+ input_tokens=usage.get("input_tokens", 0),
117
+ output_tokens=usage.get("output_tokens", 0),
118
+ stop_reason=data.get("stop_reason"),
119
+ )
@@ -0,0 +1,50 @@
1
+ """Backend protocol: one normalized chat call over any local model server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol
7
+
8
+ from ...models import ToolCall, ToolResult
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class ChatResult:
13
+ text: str
14
+ tool_calls: list[ToolCall] = field(default_factory=list)
15
+ input_tokens: int = 0
16
+ output_tokens: int = 0
17
+ stop_reason: str | None = None
18
+
19
+ @property
20
+ def total_tokens(self) -> int:
21
+ return self.input_tokens + self.output_tokens
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class Turn:
26
+ """One entry of conversation history in backend-neutral form."""
27
+
28
+ role: str # "user" | "assistant"
29
+ content: str = ""
30
+ tool_calls: list[ToolCall] = field(default_factory=list)
31
+ tool_results: list[ToolResult] = field(default_factory=list)
32
+
33
+
34
+ class Backend(Protocol):
35
+ name: str
36
+ model: str
37
+
38
+ async def chat(
39
+ self,
40
+ system: str,
41
+ turns: list[Turn],
42
+ tools: list[dict] | None = None,
43
+ max_tokens: int = 2048,
44
+ temperature: float = 0.7,
45
+ ) -> ChatResult: ...
46
+
47
+ async def close(self) -> None: ...
48
+
49
+
50
+ __all__ = ["Backend", "ChatResult", "ToolCall", "ToolResult", "Turn"]
@@ -0,0 +1,99 @@
1
+ """Run a real coding-agent CLI as the worker's engine.
2
+
3
+ The chat backends give a worker one model call plus a bounded tool loop — fine for
4
+ drafts and triage, not for a buildout. This backend hands the whole task to an agent
5
+ CLI (``claude -p``, opencode, aider …) running in the task's workspace, where it brings
6
+ its own tool loop, its own depth, and its own judgment.
7
+
8
+ The flagship configuration points ``claude -p`` at a local model server, so the full
9
+ Claude Code harness runs against your own hardware:
10
+
11
+ [backend]
12
+ type = "cli"
13
+ command = ["claude", "-p", "{prompt}", "--dangerously-skip-permissions"]
14
+ [backend.env]
15
+ ANTHROPIC_BASE_URL = "http://localhost:11434" # Ollama v0.14+ speaks Anthropic
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import os
22
+
23
+ from .base import ChatResult, Turn
24
+
25
+ DEFAULT_TIMEOUT_S = 1800.0
26
+ MAX_OUTPUT_CHARS = 60_000
27
+ PROMPT_PLACEHOLDER = "{prompt}"
28
+
29
+
30
+ class CliBackend:
31
+ name = "cli"
32
+ uses_workspace = True
33
+
34
+ def __init__(
35
+ self,
36
+ command: list[str],
37
+ model: str = "cli-agent",
38
+ timeout: float = DEFAULT_TIMEOUT_S,
39
+ env: dict[str, str] | None = None,
40
+ base_url: str | None = None, # accepted for config symmetry; unused
41
+ api_key: str | None = None,
42
+ extra_body: dict | None = None,
43
+ ) -> None:
44
+ if not command:
45
+ raise ValueError("cli backend needs a command")
46
+ if not any(PROMPT_PLACEHOLDER in part for part in command):
47
+ raise ValueError(f"cli backend command must contain {PROMPT_PLACEHOLDER}")
48
+ self.command = list(command)
49
+ self.model = model
50
+ self.timeout = timeout
51
+ self.env = env or {}
52
+ # Set per task by the harness before each call; None runs in the process cwd.
53
+ self.workspace: str | None = None
54
+
55
+ async def close(self) -> None:
56
+ return None
57
+
58
+ async def chat(
59
+ self,
60
+ system: str,
61
+ turns: list[Turn],
62
+ tools: list[dict] | None = None,
63
+ max_tokens: int = 2048,
64
+ temperature: float = 0.7,
65
+ ) -> ChatResult:
66
+ del tools, max_tokens, temperature # the CLI agent brings its own
67
+ prompt = _flatten(system, turns)
68
+ argv = [part.replace(PROMPT_PLACEHOLDER, prompt) for part in self.command]
69
+
70
+ process = await asyncio.create_subprocess_exec(
71
+ *argv,
72
+ stdout=asyncio.subprocess.PIPE,
73
+ stderr=asyncio.subprocess.PIPE,
74
+ cwd=self.workspace,
75
+ env={**os.environ, **self.env},
76
+ )
77
+ try:
78
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=self.timeout)
79
+ except TimeoutError:
80
+ process.kill()
81
+ await process.wait()
82
+ raise RuntimeError(f"cli agent timed out after {self.timeout:.0f}s (killed)") from None
83
+
84
+ out = stdout.decode(errors="replace").strip()
85
+ if process.returncode != 0:
86
+ err = stderr.decode(errors="replace").strip()
87
+ raise RuntimeError(f"cli agent exited {process.returncode}: {(err or out)[:800]}")
88
+ return ChatResult(text=out[-MAX_OUTPUT_CHARS:], stop_reason="cli_exit")
89
+
90
+
91
+ def _flatten(system: str, turns: list[Turn]) -> str:
92
+ """One prompt for a one-shot CLI run: instructions first, then the conversation."""
93
+ parts = [system.strip()] if system else []
94
+ for turn in turns:
95
+ content = (turn.content or "").strip()
96
+ if not content:
97
+ continue
98
+ parts.append(content if turn.role == "user" else f"(your earlier reply) {content}")
99
+ return "\n\n".join(parts)
@@ -0,0 +1,118 @@
1
+ """Any OpenAI-compatible /chat/completions endpoint: vLLM, LM Studio, Ollama, Tahoma."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .base import ChatResult, ToolCall, Turn
11
+
12
+
13
+ class OpenAICompatBackend:
14
+ name = "openai_compat"
15
+
16
+ def __init__(
17
+ self,
18
+ base_url: str,
19
+ model: str,
20
+ api_key: str | None = None,
21
+ timeout: float = 600.0,
22
+ extra_body: dict | None = None,
23
+ extra_headers: dict | None = None,
24
+ ) -> None:
25
+ self.base_url = base_url.rstrip("/")
26
+ self.model = model
27
+ self.extra_body = extra_body or {}
28
+ headers = {"Content-Type": "application/json"}
29
+ if api_key:
30
+ headers["Authorization"] = f"Bearer {api_key}"
31
+ headers.update(extra_headers or {}) # e.g. OpenRouter's HTTP-Referer / X-Title
32
+ self._http = httpx.AsyncClient(headers=headers, timeout=timeout)
33
+
34
+ async def close(self) -> None:
35
+ await self._http.aclose()
36
+
37
+ async def chat(
38
+ self,
39
+ system: str,
40
+ turns: list[Turn],
41
+ tools: list[dict] | None = None,
42
+ max_tokens: int = 2048,
43
+ temperature: float = 0.7,
44
+ ) -> ChatResult:
45
+ messages: list[dict[str, Any]] = [{"role": "system", "content": system}]
46
+ for turn in turns:
47
+ if turn.role == "assistant" and turn.tool_calls:
48
+ messages.append(
49
+ {
50
+ "role": "assistant",
51
+ "content": turn.content or None,
52
+ "tool_calls": [
53
+ {
54
+ "id": call.id,
55
+ "type": "function",
56
+ "function": {
57
+ "name": call.name,
58
+ "arguments": json.dumps(call.arguments),
59
+ },
60
+ }
61
+ for call in turn.tool_calls
62
+ ],
63
+ }
64
+ )
65
+ else:
66
+ messages.append({"role": turn.role, "content": turn.content})
67
+ for result in turn.tool_results:
68
+ messages.append(
69
+ {
70
+ "role": "tool",
71
+ "tool_call_id": result.call_id,
72
+ "content": result.content,
73
+ }
74
+ )
75
+
76
+ payload: dict[str, Any] = {
77
+ "model": self.model,
78
+ "messages": messages,
79
+ "max_tokens": max_tokens,
80
+ "temperature": temperature,
81
+ **self.extra_body,
82
+ }
83
+ if tools:
84
+ payload["tools"] = [
85
+ {
86
+ "type": "function",
87
+ "function": {
88
+ "name": tool["name"],
89
+ "description": tool.get("description", ""),
90
+ "parameters": tool.get("input_schema", {"type": "object"}),
91
+ },
92
+ }
93
+ for tool in tools
94
+ ]
95
+
96
+ response = await self._http.post(f"{self.base_url}/chat/completions", json=payload)
97
+ response.raise_for_status()
98
+ data = response.json()
99
+ choice = data["choices"][0]
100
+ message = choice.get("message", {})
101
+ calls = []
102
+ for raw in message.get("tool_calls") or []:
103
+ fn = raw.get("function", {})
104
+ try:
105
+ args = json.loads(fn.get("arguments") or "{}")
106
+ except json.JSONDecodeError:
107
+ args = {"_raw": fn.get("arguments")}
108
+ calls.append(
109
+ ToolCall(id=raw.get("id", fn.get("name", "call")), name=fn["name"], arguments=args)
110
+ )
111
+ usage = data.get("usage") or {}
112
+ return ChatResult(
113
+ text=message.get("content") or "",
114
+ tool_calls=calls,
115
+ input_tokens=usage.get("prompt_tokens", 0),
116
+ output_tokens=usage.get("completion_tokens", 0),
117
+ stop_reason=choice.get("finish_reason"),
118
+ )
@@ -0,0 +1,98 @@
1
+ """Agent configuration: one TOML file per agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import socket
7
+ import sys
8
+ import tomllib
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from ..models import ReplyWhen
13
+ from ..settings import env as _settings_env
14
+ from ..tools.executor import ToolsConfig
15
+
16
+ DEFAULT_SYSTEM_PROMPT = (
17
+ "You are {name}, a cook in the kitchen working on node {node}. You collaborate "
18
+ "with Claude Code and with other local agents through a shared hub. Be direct and "
19
+ "concrete. When you are given a task, do the work and report the result; when you are in "
20
+ "a conversation, reply with substance and stop when the goal is met."
21
+ )
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class AgentConfig:
26
+ name: str
27
+ hub: str = "http://localhost:8787"
28
+ node: str = field(default_factory=socket.gethostname)
29
+ tags: list[str] = field(default_factory=list)
30
+ register_token: str | None = None
31
+
32
+ backend: dict = field(default_factory=dict)
33
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT
34
+ reply_when: ReplyWhen = ReplyWhen.MENTIONED
35
+ max_tokens: int = 2048
36
+ temperature: float = 0.7
37
+ max_context_messages: int = 30
38
+ max_tool_iterations: int = 8
39
+ max_concurrent_tasks: int = 1
40
+ tools: ToolsConfig = field(default_factory=ToolsConfig)
41
+
42
+ @classmethod
43
+ def load(cls, path: str | Path) -> AgentConfig:
44
+ raw = tomllib.loads(Path(path).expanduser().read_text())
45
+ return cls.from_dict(raw)
46
+
47
+ @classmethod
48
+ def from_dict(cls, raw: dict) -> AgentConfig:
49
+ known = {
50
+ "name",
51
+ "hub",
52
+ "node",
53
+ "tags",
54
+ "register_token",
55
+ "backend",
56
+ "persona",
57
+ "runtime",
58
+ "tools",
59
+ }
60
+ for key in raw:
61
+ if key not in known:
62
+ # A misplaced key (register_token under [tools], a typo) otherwise
63
+ # fails much later with an error that never mentions it.
64
+ print(
65
+ f"warning: unknown config key {key!r} ignored "
66
+ f"(known: {', '.join(sorted(known))})",
67
+ file=sys.stderr,
68
+ )
69
+ persona = raw.get("persona") or {}
70
+ runtime = raw.get("runtime") or {}
71
+ backend = dict(raw.get("backend") or {})
72
+ if "api_key_env" in backend:
73
+ backend["api_key"] = os.environ.get(backend.pop("api_key_env"))
74
+ node = raw.get("node") or socket.gethostname()
75
+ return cls(
76
+ name=raw["name"],
77
+ hub=raw.get("hub", "http://localhost:8787"),
78
+ node=node,
79
+ tags=list(raw.get("tags") or []),
80
+ register_token=raw.get("register_token") or _settings_env("REGISTER_TOKEN"),
81
+ backend=backend,
82
+ system_prompt=persona.get("system_prompt") or DEFAULT_SYSTEM_PROMPT,
83
+ reply_when=ReplyWhen(persona.get("reply_when", ReplyWhen.MENTIONED)),
84
+ max_tokens=int(runtime.get("max_tokens", 2048)),
85
+ temperature=float(runtime.get("temperature", 0.7)),
86
+ max_context_messages=int(runtime.get("max_context_messages", 30)),
87
+ max_tool_iterations=int(runtime.get("max_tool_iterations", 8)),
88
+ max_concurrent_tasks=int(runtime.get("max_concurrent_tasks", 1)),
89
+ tools=ToolsConfig.from_dict(raw.get("tools")),
90
+ )
91
+
92
+ def rendered_system_prompt(self) -> str:
93
+ return self.system_prompt.format(name=self.name, node=self.node)
94
+
95
+ def backend_label(self) -> str:
96
+ """Prefer the detected runtime name (ollama, vllm) — it is what a human means."""
97
+ kind = self.backend.get("runtime") or self.backend.get("type", "openai_compat")
98
+ return f"{kind}/{self.backend.get('model', 'unknown')}"
@@ -0,0 +1,98 @@
1
+ """Find a local model server so `join` can configure itself.
2
+
3
+ Probes the endpoints local runtimes conventionally listen on and reports what is there,
4
+ so an operator can bring a worker online without hand-writing a backend config.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import httpx
12
+
13
+ PROBE_TIMEOUT_S = 1.5
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class DetectedBackend:
18
+ runtime: str # "ollama" | "vllm" | "lmstudio" | ...
19
+ base_url: str # OpenAI-compatible base, e.g. http://localhost:11434/v1
20
+ models: list[str]
21
+
22
+ def pick_model(self, preferred: str | None) -> str | None:
23
+ if not self.models:
24
+ return preferred
25
+ if preferred:
26
+ exact = [m for m in self.models if m == preferred]
27
+ if exact:
28
+ return exact[0]
29
+ partial = [m for m in self.models if preferred in m]
30
+ if partial:
31
+ return partial[0]
32
+ return self.models[0]
33
+
34
+
35
+ # host is filled in per probe so a worker can point at a model on another box.
36
+ _PROBES = [
37
+ ("ollama", 11434, "/api/tags"),
38
+ ("vllm", 8000, "/v1/models"),
39
+ ("lmstudio", 1234, "/v1/models"),
40
+ ("openai_compat", 8080, "/v1/models"), # Tahoma's usual port
41
+ ]
42
+
43
+
44
+ def _models_from(runtime: str, payload: dict) -> list[str]:
45
+ if runtime == "ollama":
46
+ return [m.get("name") or m.get("model") for m in payload.get("models", []) if m]
47
+ data = payload.get("data") or []
48
+ return [entry.get("id") for entry in data if entry.get("id")]
49
+
50
+
51
+ def probe(host: str = "localhost", timeout: float = PROBE_TIMEOUT_S) -> list[DetectedBackend]:
52
+ """Return every model server found on `host`, in priority order."""
53
+ found: list[DetectedBackend] = []
54
+ with httpx.Client(timeout=timeout) as client:
55
+ for runtime, port, path in _PROBES:
56
+ url = f"http://{host}:{port}{path}"
57
+ try:
58
+ response = client.get(url)
59
+ response.raise_for_status()
60
+ payload = response.json()
61
+ except (httpx.HTTPError, ValueError):
62
+ continue
63
+ models = [m for m in _models_from(runtime, payload) if m]
64
+ # Ollama and everything else expose an OpenAI-compatible base at /v1.
65
+ base = f"http://{host}:{port}/v1"
66
+ found.append(DetectedBackend(runtime=runtime, base_url=base, models=models))
67
+ return found
68
+
69
+
70
+ def autodetect(host: str = "localhost") -> DetectedBackend | None:
71
+ """The single best local backend, or None if nothing is listening."""
72
+ candidates = probe(host)
73
+ return candidates[0] if candidates else None
74
+
75
+
76
+ async def preflight(backend_config: dict) -> tuple[bool, str]:
77
+ """Prove a backend config actually answers before a worker commits to it.
78
+
79
+ A wrong base_url or model otherwise registers happily and only surfaces when the
80
+ first task fails. Returns (ok, human message).
81
+ """
82
+ from .backends import build_backend
83
+ from .backends.base import Turn
84
+
85
+ backend = build_backend(backend_config)
86
+ try:
87
+ result = await backend.chat(
88
+ "You are a health check.",
89
+ [Turn(role="user", content="Reply with the single word: ok")],
90
+ max_tokens=8,
91
+ temperature=0.0,
92
+ )
93
+ except Exception as exc: # noqa: BLE001 - the whole point is to report the failure
94
+ return False, f"{type(exc).__name__}: {exc}"
95
+ finally:
96
+ await backend.close()
97
+ text = (result.text or "").strip()
98
+ return True, text or "(empty reply, but the endpoint answered)"