agent-core-busproxy 0.9.0__tar.gz

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.
@@ -0,0 +1,248 @@
1
+ # Memory compiler runtime state
2
+ memory-compiler/daily/
3
+ memory-compiler/reports/
4
+ memory-compiler/scripts/state.json
5
+ memory-compiler/scripts/last-flush.json
6
+ memory-compiler/scripts/flush.log
7
+ memory-compiler/scripts/compile.log
8
+ memory-compiler/scripts/session-flush-*.md
9
+ memory-compiler/scripts/flush-context-*.md
10
+
11
+ # Handoff writer state (placed alongside handoff files)
12
+ **/handoff-state.json
13
+
14
+ # Git worktrees (superpowers / parallel feature work)
15
+ .worktrees/
16
+ # Native (harness) worktrees
17
+ .claude/worktrees/
18
+
19
+ # Superpowers brainstorm mockups / scratch
20
+ .superpowers/
21
+
22
+ # Agent-core runtime state (handoffs, bus.sqlite, transcripts, etc.)
23
+ .agent-core/
24
+
25
+ # Local scratch / smoke-test artifacts
26
+ .tmp/
27
+
28
+ # Claude Code per-machine local overrides
29
+ .claude/settings.local.json
30
+ .claude/*.lock
31
+
32
+ # Byte-compiled / optimized / DLL files
33
+ __pycache__/
34
+ *.py[codz]
35
+ *$py.class
36
+
37
+ # C extensions
38
+ *.so
39
+
40
+ # Distribution / packaging
41
+ .Python
42
+ build/
43
+ develop-eggs/
44
+ dist/
45
+ downloads/
46
+ eggs/
47
+ .eggs/
48
+ lib/
49
+ lib64/
50
+ parts/
51
+ sdist/
52
+ var/
53
+ wheels/
54
+ share/python-wheels/
55
+ *.egg-info/
56
+ .installed.cfg
57
+ *.egg
58
+ MANIFEST
59
+
60
+ # PyInstaller
61
+ # Usually these files are written by a python script from a template
62
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
63
+ *.manifest
64
+ *.spec
65
+
66
+ # Installer logs
67
+ pip-log.txt
68
+ pip-delete-this-directory.txt
69
+
70
+ # Unit test / coverage reports
71
+ htmlcov/
72
+ .tox/
73
+ .nox/
74
+ .coverage
75
+ .coverage.*
76
+ .cache
77
+ nosetests.xml
78
+ coverage.xml
79
+ *.cover
80
+ *.py.cover
81
+ .hypothesis/
82
+ .pytest_cache/
83
+ cover/
84
+
85
+ # Translations
86
+ *.mo
87
+ *.pot
88
+
89
+ # Django stuff:
90
+ *.log
91
+ local_settings.py
92
+ db.sqlite3
93
+ db.sqlite3-journal
94
+
95
+ # Flask stuff:
96
+ instance/
97
+ .webassets-cache
98
+
99
+ # Scrapy stuff:
100
+ .scrapy
101
+
102
+ # Sphinx documentation
103
+ docs/_build/
104
+
105
+ # PyBuilder
106
+ .pybuilder/
107
+ target/
108
+
109
+ # Jupyter Notebook
110
+ .ipynb_checkpoints
111
+
112
+ # IPython
113
+ profile_default/
114
+ ipython_config.py
115
+
116
+ # pyenv
117
+ # For a library or package, you might want to ignore these files since the code is
118
+ # intended to run in multiple environments; otherwise, check them in:
119
+ # .python-version
120
+
121
+ # pipenv
122
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
123
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
124
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
125
+ # install all needed dependencies.
126
+ #Pipfile.lock
127
+
128
+ # UV
129
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
130
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
131
+ # commonly ignored for libraries.
132
+ #uv.lock
133
+
134
+ # poetry
135
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
136
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
137
+ # commonly ignored for libraries.
138
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
139
+ #poetry.lock
140
+ #poetry.toml
141
+
142
+ # pdm
143
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
144
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
145
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
146
+ #pdm.lock
147
+ #pdm.toml
148
+ .pdm-python
149
+ .pdm-build/
150
+
151
+ # pixi
152
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
153
+ #pixi.lock
154
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
155
+ # in the .venv directory. It is recommended not to include this directory in version control.
156
+ .pixi
157
+
158
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
159
+ __pypackages__/
160
+
161
+ # Celery stuff
162
+ celerybeat-schedule
163
+ celerybeat.pid
164
+
165
+ # SageMath parsed files
166
+ *.sage.py
167
+
168
+ # Environments
169
+ .env
170
+ .envrc
171
+ .venv
172
+ env/
173
+ venv/
174
+ ENV/
175
+ env.bak/
176
+ venv.bak/
177
+ # ...but agent_core.venv is a SOURCE package, not a virtualenv — don't ignore it.
178
+ !packages/core/src/agent_core/venv/
179
+ !packages/core/src/agent_core/venv/**
180
+
181
+ # Spyder project settings
182
+ .spyderproject
183
+ .spyproject
184
+
185
+ # Rope project settings
186
+ .ropeproject
187
+
188
+ # mkdocs documentation
189
+ /site
190
+
191
+ # mypy
192
+ .mypy_cache/
193
+ .dmypy.json
194
+ dmypy.json
195
+
196
+ # Pyre type checker
197
+ .pyre/
198
+
199
+ # pytype static type analyzer
200
+ .pytype/
201
+
202
+ # Cython debug symbols
203
+ cython_debug/
204
+
205
+ # PyCharm
206
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
207
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
208
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
209
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
210
+ #.idea/
211
+
212
+ # Abstra
213
+ # Abstra is an AI-powered process automation framework.
214
+ # Ignore directories containing user credentials, local state, and settings.
215
+ # Learn more at https://abstra.io/docs
216
+ .abstra/
217
+
218
+ # Visual Studio Code
219
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
220
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
221
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
222
+ # you could uncomment the following to ignore the entire vscode folder
223
+ # .vscode/
224
+
225
+ # Ruff stuff:
226
+ .ruff_cache/
227
+
228
+ # PyPI configuration file
229
+ .pypirc
230
+
231
+ # Cursor
232
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
233
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
234
+ # refer to https://docs.cursor.com/context/ignore-files
235
+ .cursorignore
236
+ .cursorindexingignore
237
+
238
+ # Marimo
239
+ marimo/_static/
240
+ marimo/_lsp/
241
+ __marimo__/
242
+ .gstack/
243
+
244
+ # macOS Finder metadata (occasionally sneaks in via vendored sources)
245
+ .DS_Store
246
+
247
+ # Operational docs that must not live in source control
248
+ pepper-vault-plan.txt
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ ## [0.9.0](https://github.com/jeffrichley/agent_core/compare/v0.8.1...agent-core-busproxy-v0.9.0) (2026-08-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * **mypy:** enable --strict for briefs, busproxy, inbound, voice, webcam, qa ([#532](https://github.com/jeffrichley/agent_core/issues/532)) ([9663d87](https://github.com/jeffrichley/agent_core/commit/9663d8783176116a7ae352b2ba07933bf056fbe7))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * republish package family to complete rate-limited first publish ([#477](https://github.com/jeffrichley/agent_core/issues/477)) ([32977ac](https://github.com/jeffrichley/agent_core/commit/32977ac78da4a15553329bb4e1fdaa9e894834ad))
14
+
15
+ ## [0.8.1](https://github.com/jeffrichley/agent_core/compare/v0.8.0...agent-core-busproxy-v0.8.1) (2026-07-22)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * republish package family to complete rate-limited first publish ([#477](https://github.com/jeffrichley/agent_core/issues/477)) ([32977ac](https://github.com/jeffrichley/agent_core/commit/32977ac78da4a15553329bb4e1fdaa9e894834ad))
21
+
22
+ ## [0.8.0](https://github.com/jeffrichley/agent_core/compare/v0.7.0...agent-core-busproxy-v0.8.0) (2026-07-21)
23
+
24
+
25
+ ### Features
26
+
27
+ * **hatchery:** ungendered templates + .mcp.json generation ([#81](https://github.com/jeffrichley/agent_core/issues/81)/[#82](https://github.com/jeffrichley/agent_core/issues/82)) ([#357](https://github.com/jeffrichley/agent_core/issues/357)) ([f48762d](https://github.com/jeffrichley/agent_core/commit/f48762df78eb12cfa9524e9b5a277741534ea8a7)), closes [#311](https://github.com/jeffrichley/agent_core/issues/311)
28
+ * **release:** configure release-please for 12-package synchronized version train ([#428](https://github.com/jeffrichley/agent_core/issues/428)) ([c1b4097](https://github.com/jeffrichley/agent_core/commit/c1b409765c61d09a6657b0e8b5e43a27cb235b3e))
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-core-busproxy
3
+ Version: 0.9.0
4
+ Summary: Stdio MCP proxy — bridges Claude Code to the agent-core daemon bus tool surface with per-request backend sessions so a daemon bounce never strands the session.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: anyio>=4.0
8
+ Requires-Dist: fastmcp>=3.2
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: mcp>=1.0
11
+ Requires-Dist: typer>=0.12
@@ -0,0 +1 @@
1
+ 0.9.0
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "agent-core-busproxy"
3
+ dynamic = ["version"]
4
+ description = "Stdio MCP proxy — bridges Claude Code to the agent-core daemon bus tool surface with per-request backend sessions so a daemon bounce never strands the session."
5
+ license = "MIT"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "fastmcp>=3.2",
9
+ "mcp>=1.0",
10
+ "anyio>=4.0",
11
+ "httpx>=0.27",
12
+ "typer>=0.12",
13
+ ]
14
+
15
+ [project.scripts]
16
+ agent-core-busproxy = "agent_core_busproxy.__main__:app"
17
+
18
+ [build-system]
19
+ requires = ["hatchling", "uv-dynamic-versioning"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/agent_core_busproxy"]
24
+
25
+ [tool.hatch.version]
26
+ source = "uv-dynamic-versioning"
27
+
28
+ [tool.uv-dynamic-versioning]
29
+ fallback-version = "0.0.0"
30
+
31
+ [tool.uv]
32
+ cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }]
@@ -0,0 +1 @@
1
+ """agent-core-busproxy — stdio MCP proxy to the daemon bus tool surface."""
@@ -0,0 +1,37 @@
1
+ """Typer CLI for agent-core-busproxy.
2
+
3
+ Runs a FastMCP proxy over stdio, backed by the daemon's per-agent HTTP
4
+ endpoint. Spawned by Claude Code as a stdio MCP server; its lifetime is
5
+ the Claude Code session, decoupled from the daemon's lifetime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import anyio
11
+ import typer
12
+
13
+ app = typer.Typer(add_completion=False)
14
+
15
+
16
+ @app.command()
17
+ def main(
18
+ agent: str = typer.Option(..., "--agent", help="Agent name on the bus."),
19
+ daemon_url: str = typer.Option(
20
+ "http://127.0.0.1:8789",
21
+ "--daemon-url",
22
+ help="agent-core daemon URL (default: http://127.0.0.1:8789).",
23
+ ),
24
+ ) -> None:
25
+ """Run the agent-core stdio bus proxy."""
26
+ from agent_core_busproxy.proxy import build_busproxy
27
+
28
+ proxy = build_busproxy(agent=agent, daemon_url=daemon_url)
29
+
30
+ async def _run() -> None:
31
+ await proxy.run_async(transport="stdio")
32
+
33
+ anyio.run(_run)
34
+
35
+
36
+ if __name__ == "__main__":
37
+ app()
@@ -0,0 +1,117 @@
1
+ """Assemble the stdio bus proxy.
2
+
3
+ The proxy forwards the daemon's per-agent MCP tool surface. Each tool
4
+ call mints a FRESH backend session (ProxyClient.new()), so a restarted
5
+ daemon is never presented a stale mcp-session-id — issue #91 is removed
6
+ by construction, not by recovery logic.
7
+
8
+ Cache hardening (spec Amendment 2026-05-16): FastMCP's ProxyProvider
9
+ caches the backend tool list and swallows a backend-down list into an
10
+ EMPTY registry. We build the proxy via the documented lower-level
11
+ pattern — FastMCP + add_provider(ProxyProvider(client_factory,
12
+ cache_ttl=<long>)) — with a 24h cache_ttl so the tool palette survives
13
+ any realistic daemon bounce. (FastMCPProxy hardcodes ProxyProvider with
14
+ no cache_ttl passthrough, so it cannot be used here.) No active
15
+ keepalive: a keepalive cannot help the cold-start-while-down edge, and a
16
+ long TTL covers expiry (YAGNI).
17
+
18
+ `init_timeout` keeps a down/bouncing daemon from hanging the call: the
19
+ backend connect fails fast and the TransientErrorMiddleware (attached in
20
+ Task 5) turns that into a structured retryable tool result.
21
+
22
+ Output-schema stripping (spec Amendment 2): a FastMCP ProxyProvider
23
+ mirrors each backend tool's output_schema and the server then
24
+ re-validates every result against it. That would reject the
25
+ TransientErrorMiddleware's {transient:true} payload (it cannot match an
26
+ arbitrary tool's schema) and mangle it into an opaque output-validation
27
+ error. A transparent passthrough proxy must not re-validate the
28
+ backend's own outputs anyway, so _NoOutputSchemaProxyProvider drops the
29
+ output_schema on every proxied tool.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Any
35
+
36
+ from fastmcp import FastMCP
37
+ from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
38
+
39
+ from agent_core_busproxy.transient import TransientErrorMiddleware
40
+
41
+
42
+ class _NoOutputSchemaProxyProvider(ProxyProvider):
43
+ """ProxyProvider that drops backend tool output schemas.
44
+
45
+ See module docstring (spec Amendment 2). Overrides both the list and
46
+ single-tool resolution paths so FastMCP performs no output validation
47
+ on proxied results — letting the transient payload AND normal
48
+ passthrough results through unchanged.
49
+ """
50
+
51
+ @staticmethod
52
+ def _strip(tool: Any) -> Any:
53
+ # Any: avoid annotating with fastmcp's non-public Tool internals.
54
+ if tool is None:
55
+ return None
56
+ return tool.model_copy(update={"output_schema": None})
57
+
58
+ async def _list_tools(self) -> Any:
59
+ return [self._strip(t) for t in await super()._list_tools()]
60
+
61
+ async def _get_tool(self, name: str, version: Any = None) -> Any:
62
+ return self._strip(await super()._get_tool(name, version))
63
+
64
+ # Fail-fast: a down daemon must not hang a tool call. Small connect/init
65
+ # budget; the agent owns the retry decision (spec: fail-fast retryable).
66
+ _BACKEND_INIT_TIMEOUT_SECONDS = 5.0
67
+ # Per-call request budget once connected (a healthy daemon answers in ms;
68
+ # this only bounds a half-open connection).
69
+ _BACKEND_REQUEST_TIMEOUT_SECONDS = 60.0
70
+ # Tool-list cache lifetime. Long enough that the palette survives any
71
+ # realistic daemon bounce/refresh within a session (spec Amendment).
72
+ _TOOL_CACHE_TTL_SECONDS = 86400.0
73
+
74
+
75
+ def build_busproxy(
76
+ *,
77
+ agent: str,
78
+ daemon_url: str | None,
79
+ _backend: Any | None = None,
80
+ ) -> FastMCP:
81
+ """Return a FastMCP proxy over the daemon's per-agent endpoint.
82
+
83
+ Args:
84
+ agent: bus agent name (URL path segment).
85
+ daemon_url: e.g. ``http://127.0.0.1:8789``. Ignored when
86
+ ``_backend`` is supplied.
87
+ _backend: test seam — an in-process FastMCP server to proxy
88
+ instead of an HTTP URL. Production always passes a URL.
89
+ """
90
+ if _backend is not None:
91
+ base_client = ProxyClient(_backend)
92
+ else:
93
+ if not daemon_url:
94
+ raise ValueError("daemon_url is required when no _backend is given")
95
+ url = f"{daemon_url.rstrip('/')}/mcp/{agent}"
96
+ base_client = ProxyClient(
97
+ url,
98
+ init_timeout=_BACKEND_INIT_TIMEOUT_SECONDS,
99
+ timeout=_BACKEND_REQUEST_TIMEOUT_SECONDS,
100
+ )
101
+
102
+ # -> Any: ProxyClient.new() returns an httpx-backed client (URL path)
103
+ # or an in-process FastMCP client (_backend path) — undifferentiated.
104
+ def client_factory() -> Any:
105
+ # Fresh session per request — the #91 fix.
106
+ return base_client.new()
107
+
108
+ proxy = FastMCP(name=f"agent-core[{agent}]")
109
+ proxy.add_provider(
110
+ _NoOutputSchemaProxyProvider(
111
+ client_factory, cache_ttl=_TOOL_CACHE_TTL_SECONDS
112
+ )
113
+ )
114
+ # daemon_url is the middleware's liveness-probe target. None on the
115
+ # in-process test path => probing disabled (ambiguous => genuine).
116
+ proxy.add_middleware(TransientErrorMiddleware(daemon_url=daemon_url))
117
+ return proxy
@@ -0,0 +1,149 @@
1
+ """Translate backend-unreachable failures into a structured, retryable
2
+ tool result. Genuine backend tool errors pass through verbatim so the
3
+ agent never retry-loops on a real failure.
4
+
5
+ Two backend-down shapes (spec Amendment 2026-05-16):
6
+ 1. Transport error — warm tool cache, per-request connect to the dead
7
+ daemon fails. Unambiguously transient.
8
+ 2. NotFoundError("Unknown tool: ...") — the tool-list fetch failed and
9
+ FastMCP's ProxyProvider/AggregateProvider swallowed it into an empty
10
+ registry. AMBIGUOUS: identical shape to a genuine unknown-tool / tool
11
+ exception from a HEALTHY daemon. Disambiguated with a fast TCP
12
+ liveness probe to the daemon: unreachable => transient; reachable =>
13
+ genuine, re-raised unchanged.
14
+
15
+ NOTE: fastmcp raises `NotFoundError` server-side from
16
+ `FastMCP.call_tool` when the resolved tool list is empty (backend-down
17
+ → swallowed list → empty registry). ToolError is also classified
18
+ AMBIGUOUS for forward-compatibility with versions that may change this
19
+ behaviour.
20
+
21
+ Unknown exception types are treated as genuine — a real bug is never
22
+ masked as retryable.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ import re
30
+ from enum import Enum
31
+ from typing import Any
32
+ from urllib.parse import urlparse
33
+
34
+ import httpx
35
+ from fastmcp.exceptions import NotFoundError, ToolError
36
+ from fastmcp.server.middleware import Middleware
37
+ from fastmcp.tools.base import ToolResult
38
+ from mcp.shared.exceptions import McpError
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+ TRANSIENT_ERROR_CODE = "bus_unavailable"
43
+ _RETRY_AFTER_SECONDS = 5
44
+ _PROBE_TIMEOUT_SECONDS = 2.0
45
+
46
+ # Reuse the #76 redaction shape: drop the entire query string (signed CDN
47
+ # tokens / session ids live there).
48
+ _URL_QS_RE = re.compile(r"(https?://[^\s?]+)\?\S*")
49
+
50
+
51
+ def redact(text: str) -> str:
52
+ """Strip query strings from any URLs in a human string."""
53
+ return _URL_QS_RE.sub(r"\1?<redacted>", text)
54
+
55
+
56
+ class Disposition(Enum):
57
+ """How a backend exception should be handled."""
58
+
59
+ TRANSIENT = "transient" # definitely daemon unreachable
60
+ GENUINE = "genuine" # definitely a real error — re-raise
61
+ AMBIGUOUS = "ambiguous" # could be either — probe the daemon to decide
62
+
63
+
64
+ # Transport-class: could not reach / handshake the daemon. Definitely
65
+ # transient. (NotFoundError/ToolError are NOT in here — ambiguous, handled below.)
66
+ _TRANSIENT_TYPES: tuple[type[BaseException], ...] = (
67
+ httpx.HTTPError,
68
+ ConnectionError,
69
+ TimeoutError,
70
+ OSError,
71
+ McpError,
72
+ )
73
+
74
+
75
+ def classify_backend_error(exc: BaseException) -> Disposition:
76
+ """Triage a backend exception (see module docstring)."""
77
+ if isinstance(exc, _TRANSIENT_TYPES):
78
+ return Disposition.TRANSIENT
79
+ # NotFoundError: fastmcp raises this (not ToolError) from FastMCP.call_tool
80
+ # when the resolved tool list is empty (backend-down → swallowed list →
81
+ # empty registry). ToolError: kept for forward-compatibility.
82
+ if isinstance(exc, (NotFoundError, ToolError)):
83
+ return Disposition.AMBIGUOUS
84
+ return Disposition.GENUINE
85
+
86
+
87
+ async def daemon_reachable(daemon_url: str) -> bool:
88
+ """Fast TCP liveness probe — can we open a socket to the daemon?
89
+
90
+ A bounced/down daemon has nothing listening on its port, so a refused
91
+ or timed-out connect means 'down'. Bounded by _PROBE_TIMEOUT_SECONDS
92
+ so it cannot itself hang a tool call.
93
+ """
94
+ parsed = urlparse(daemon_url)
95
+ host = parsed.hostname or "127.0.0.1"
96
+ port = parsed.port or 80
97
+ try:
98
+ fut = asyncio.open_connection(host, port)
99
+ _reader, writer = await asyncio.wait_for(
100
+ fut, timeout=_PROBE_TIMEOUT_SECONDS
101
+ )
102
+ except (TimeoutError, OSError):
103
+ return False
104
+ writer.close()
105
+ try:
106
+ await writer.wait_closed()
107
+ except Exception: # close best-effort
108
+ pass
109
+ return True
110
+
111
+
112
+ def _transient_result(exc: BaseException) -> ToolResult:
113
+ detail = redact(f"{type(exc).__name__}: {exc}")
114
+ log.warning("busproxy: backend unavailable (transient): %s", detail)
115
+ return ToolResult(
116
+ structured_content={
117
+ "error": TRANSIENT_ERROR_CODE,
118
+ "transient": True,
119
+ "retry_after_seconds": _RETRY_AFTER_SECONDS,
120
+ "detail": detail,
121
+ }
122
+ )
123
+
124
+
125
+ class TransientErrorMiddleware(Middleware):
126
+ """Map daemon-unreachable to a retryable result; pass real errors through.
127
+
128
+ ``daemon_url`` is the liveness-probe target. ``None`` (the in-process
129
+ test path) disables probing — an AMBIGUOUS error is then treated as
130
+ genuine so a real error is never masked when we cannot verify.
131
+ """
132
+
133
+ def __init__(self, daemon_url: str | None = None) -> None:
134
+ self._daemon_url = daemon_url
135
+
136
+ async def on_call_tool(self, context: Any, call_next: Any) -> ToolResult:
137
+ try:
138
+ result: ToolResult = await call_next(context)
139
+ return result
140
+ except BaseException as exc: # triage then re-raise (never swallow)
141
+ disposition = classify_backend_error(exc)
142
+ if disposition is Disposition.TRANSIENT:
143
+ return _transient_result(exc)
144
+ if disposition is Disposition.AMBIGUOUS and (
145
+ self._daemon_url is not None
146
+ and not await daemon_reachable(self._daemon_url)
147
+ ):
148
+ return _transient_result(exc)
149
+ raise
@@ -0,0 +1,40 @@
1
+ """Disposable test backend for the #91 subprocess regression.
2
+
3
+ Run as: ``python _backend_server.py <port>``. Serves ONE throwaway
4
+ ``ClaudeCodeMCPEndpoint`` via the production ``HTTPHost`` on
5
+ ``127.0.0.1:<port>`` until the process is killed. This is NOT shipped
6
+ (lives under tests/) and never touches the real daemon — the regression
7
+ always passes an OS-assigned free port and kills only this child.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import sys
14
+
15
+ from agent_core.bus.http_host import HTTPHost
16
+ from agent_core.endpoints.claude_code_mcp import ClaudeCodeMCPEndpoint
17
+
18
+
19
+ class _StubHandle:
20
+ async def ack(self, envelope_id: str) -> None: ...
21
+ async def publish(self, envelope, to=None) -> None: ...
22
+ async def nack(self, envelope_id, requeue=True) -> None: ...
23
+ def endpoints(self) -> list:
24
+ return [type("E", (), {"name": "discord", "description": "d"})()]
25
+
26
+
27
+ async def _main(port: int) -> None:
28
+ ep = ClaudeCodeMCPEndpoint(name="agent", mount="/mcp/agent")
29
+ await ep.start(_StubHandle()) # type: ignore[arg-type]
30
+ host = HTTPHost(bind_host="127.0.0.1", bind_port=port)
31
+ host.mount(ep)
32
+ await host.start()
33
+ # Readiness is observed by the parent via a TCP port-probe (this
34
+ # process's stdout is discarded), so nothing to emit here.
35
+ # Serve until the parent kills this process (faithful daemon death).
36
+ await asyncio.Event().wait()
37
+
38
+
39
+ if __name__ == "__main__":
40
+ asyncio.run(_main(int(sys.argv[1])))
@@ -0,0 +1,35 @@
1
+ """Smoke tests for the busproxy Typer CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from agent_core_busproxy.__main__ import app
8
+ from typer.testing import CliRunner
9
+
10
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
11
+
12
+
13
+ def _norm(text: str) -> str:
14
+ """Strip ANSI escape codes and collapse whitespace.
15
+
16
+ Typer/Rich splits option tokens with ANSI style spans under CI
17
+ (no COLUMNS, non-tty), so raw substring checks are unreliable.
18
+ """
19
+ return re.sub(r"\s+", " ", _ANSI_RE.sub("", text))
20
+
21
+
22
+ def test_cli_help_runs() -> None:
23
+ result = CliRunner().invoke(app, ["--help"])
24
+ assert result.exit_code == 0
25
+ assert "--agent" in _norm(result.output)
26
+ assert "--daemon-url" in _norm(result.output)
27
+
28
+
29
+ def test_cli_requires_agent() -> None:
30
+ result = CliRunner().invoke(app, [])
31
+ assert result.exit_code != 0
32
+ assert (
33
+ "Missing option" in result.output
34
+ or "required" in result.output.lower()
35
+ )
@@ -0,0 +1,31 @@
1
+ """Daemon-down window: a tool call returns the structured transient
2
+ result PROMPTLY (fail-fast, no long hang)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+
8
+ import pytest
9
+ from agent_core_busproxy.proxy import build_busproxy
10
+ from agent_core_busproxy.transient import TRANSIENT_ERROR_CODE
11
+ from fastmcp import Client
12
+
13
+ # Real connect to a dead port (waits out the connection failure) — slow by
14
+ # nature, so keep it out of the default fast lane. See the `slow` marker
15
+ # definition in pyproject.toml.
16
+ pytestmark = pytest.mark.slow
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_dead_backend_returns_transient_result_fast() -> None:
21
+ # Nothing listening on 65535 => connect refused/timeout.
22
+ proxy = build_busproxy(agent="agent", daemon_url="http://127.0.0.1:65535")
23
+
24
+ async with Client(proxy) as c:
25
+ # Whole call must finish well under the 5s init budget * slack.
26
+ result = await asyncio.wait_for(
27
+ c.call_tool("list_endpoints", {}), timeout=15.0
28
+ )
29
+
30
+ assert result.structured_content["error"] == TRANSIENT_ERROR_CODE
31
+ assert result.structured_content["transient"] is True
@@ -0,0 +1,121 @@
1
+ """Pin the installed FastMCP proxy API surface this package depends on.
2
+
3
+ If FastMCP changes these, this test fails first and points at exactly
4
+ what to update in proxy.py / transient.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+
11
+ import pytest
12
+
13
+
14
+ def test_fastmcpproxy_and_proxyclient_importable() -> None:
15
+ from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
16
+
17
+ # FastMCPProxy takes a client_factory kwarg.
18
+ sig = inspect.signature(FastMCPProxy.__init__)
19
+ assert "client_factory" in sig.parameters
20
+
21
+ # ProxyClient accepts a URL string target and supports .new().
22
+ pc = ProxyClient("http://127.0.0.1:65535/mcp/nobody")
23
+ assert hasattr(pc, "new")
24
+
25
+
26
+ def test_client_supports_init_timeout() -> None:
27
+ from fastmcp import Client
28
+
29
+ sig = inspect.signature(Client.__init__)
30
+ assert "init_timeout" in sig.parameters
31
+
32
+
33
+ def test_middleware_has_on_call_tool() -> None:
34
+ from fastmcp.server.middleware import Middleware
35
+
36
+ assert hasattr(Middleware, "on_call_tool")
37
+
38
+
39
+ def test_toolresult_accepts_structured_content() -> None:
40
+ from fastmcp.tools.base import ToolResult
41
+
42
+ r = ToolResult(structured_content={"error": "x", "transient": True})
43
+ assert r.structured_content == {"error": "x", "transient": True}
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_client_surfaces_structured_content_attribute() -> None:
48
+ """Pin the CLIENT-side accessor: a tool returning ToolResult(
49
+ structured_content=...) is readable as result.structured_content.
50
+ Every later test asserts on this attribute.
51
+
52
+ NOTE: The inner tool function deliberately omits a return-type annotation.
53
+ Using ``-> ToolResult`` with ``from __future__ import annotations`` (PEP 563)
54
+ causes pydantic to evaluate it as a forward-reference against the *module*
55
+ globalns, where the name ``ToolResult`` is absent (the import lives only in
56
+ the test-function locals). Omitting the annotation is equivalent for the
57
+ purpose of this characterization test.
58
+ """
59
+ from fastmcp import Client, FastMCP
60
+ from fastmcp.tools.base import ToolResult
61
+
62
+ srv = FastMCP("pin")
63
+
64
+ @srv.tool()
65
+ async def t(): # no return annotation — see docstring
66
+ return ToolResult(structured_content={"transient": True})
67
+
68
+ async with Client(srv) as c:
69
+ res = await c.call_tool("t", {})
70
+
71
+ assert res.structured_content == {"transient": True}
72
+
73
+
74
+ @pytest.mark.slow # real connect to a dead port; see slow marker in pyproject.toml
75
+ @pytest.mark.asyncio
76
+ async def test_dead_backend_raises_on_tool_call() -> None:
77
+ """Calling a tool through a proxy whose backend is unreachable raises.
78
+
79
+ Records the concrete exception type so transient.py can catch it.
80
+ Port 65535 with nothing listening => connect failure.
81
+ """
82
+ from fastmcp import Client
83
+ from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient
84
+
85
+ base = ProxyClient("http://127.0.0.1:65535/mcp/nobody", init_timeout=2.0)
86
+ proxy = FastMCPProxy(client_factory=lambda: base.new(), name="probe")
87
+
88
+ with pytest.raises(BaseException) as excinfo: # broad: characterizing
89
+ async with Client(proxy) as c:
90
+ await c.call_tool("list_endpoints", {})
91
+
92
+ # Concrete type pinned for transient.classify_backend_error.
93
+ # Assert it is NOT a clean MCP tool-result (i.e. the failure surfaces
94
+ # as an exception, which is what the middleware will intercept).
95
+ assert excinfo.value is not None
96
+ chain = []
97
+ e: BaseException | None = excinfo.value
98
+ while e is not None:
99
+ chain.append(type(e).__name__)
100
+ e = e.__cause__ or e.__context__
101
+ # Connection-class failure somewhere in the chain.
102
+ #
103
+ # OBSERVED BEHAVIOUR (fastmcp 3.2.2): AggregateProvider._collect_list_results
104
+ # absorbs the actual connect error during list_tools() (logs a WARNING) and
105
+ # returns an empty tool list. A subsequent call_tool() therefore fails with
106
+ # ToolError("Unknown tool: '...'") — never a raw connection exception.
107
+ # transient.classify_backend_error must therefore match "ToolError" (in
108
+ # addition to raw transport errors that could surface in other code-paths).
109
+ assert any(
110
+ n in chain
111
+ for n in (
112
+ "ToolError",
113
+ "ConnectError",
114
+ "ConnectionError",
115
+ "ConnectTimeout",
116
+ "McpError",
117
+ "HTTPError",
118
+ "OSError",
119
+ "TimeoutError",
120
+ )
121
+ ), f"unexpected exception chain: {chain}"
@@ -0,0 +1,52 @@
1
+ """build_busproxy assembles a proxy that mirrors the daemon tool surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from agent_core_busproxy.proxy import build_busproxy
7
+ from fastmcp import Client
8
+
9
+ from agent_core.endpoints.claude_code_mcp import ClaudeCodeMCPEndpoint
10
+
11
+
12
+ class _StubHandle:
13
+ async def ack(self, envelope_id: str) -> None: ...
14
+ async def publish(self, envelope, to=None) -> None: ...
15
+ async def nack(self, envelope_id, requeue=True) -> None: ...
16
+ def endpoints(self) -> list:
17
+ return []
18
+
19
+
20
+ # The bus tool surface ClaudeCodeMCPEndpoint guarantees (see
21
+ # claude_code_mcp.py _register_tools). Asserting these by name avoids
22
+ # depending on any FastMCP-internal tool-enumeration API.
23
+ _EXPECTED_BUS_TOOLS = {
24
+ "send",
25
+ "list_endpoints",
26
+ "describe_endpoint",
27
+ "list_pending",
28
+ "handle",
29
+ "ack",
30
+ "nack",
31
+ "consume",
32
+ "reply",
33
+ "peek",
34
+ "show_my_day",
35
+ }
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_proxy_mirrors_backend_tool_surface() -> None:
40
+ """tools/list through the proxy exposes the daemon endpoint's tools."""
41
+ backend = ClaudeCodeMCPEndpoint(name="agent", mount="/mcp/agent")
42
+ await backend.start(_StubHandle()) # type: ignore[arg-type]
43
+ try:
44
+ # Proxy pointed directly at the in-process backend server.
45
+ proxy = build_busproxy(agent="agent", daemon_url=None, _backend=backend._mcp)
46
+ async with Client(proxy) as c:
47
+ proxied = {t.name for t in await c.list_tools()}
48
+
49
+ missing = _EXPECTED_BUS_TOOLS - proxied
50
+ assert not missing, f"proxy missing bus tools: {missing}"
51
+ finally:
52
+ await backend.stop()
@@ -0,0 +1,180 @@
1
+ """#91 regression: a daemon restart must not strand a live session.
2
+
3
+ A single long-lived busproxy Client stays connected across a full
4
+ daemon bounce. Because every tool call mints a fresh backend session,
5
+ the post-restart call recovers without any client re-initialize.
6
+
7
+ The backend runs as a real SEPARATE PROCESS on an OS-assigned free port
8
+ (never the real daemon's port) so killing it and starting a fresh one is
9
+ a faithful daemon bounce (process dies → new process binds the port),
10
+ free of in-process event-loop/lifespan races. The test only ever kills
11
+ its own child process.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+ import pytest
24
+ from agent_core_busproxy.proxy import build_busproxy
25
+ from fastmcp import Client
26
+
27
+ _BACKEND = Path(__file__).parent / "_backend_server.py"
28
+
29
+ # Both tests spawn real backend subprocesses (cold Python + fastmcp import +
30
+ # socket bind) — slow by nature, so keep them out of the default fast lane.
31
+ # See the `slow` marker definition in pyproject.toml.
32
+ pytestmark = pytest.mark.slow
33
+
34
+
35
+ def _free_port() -> int:
36
+ s = socket.socket()
37
+ s.bind(("127.0.0.1", 0))
38
+ port = s.getsockname()[1]
39
+ s.close()
40
+ return port
41
+
42
+
43
+ async def _port_open(port: int) -> bool:
44
+ try:
45
+ fut = asyncio.open_connection("127.0.0.1", port)
46
+ _r, w = await asyncio.wait_for(fut, timeout=1.0)
47
+ except (TimeoutError, OSError):
48
+ return False
49
+ w.close()
50
+ return True
51
+
52
+
53
+ async def _spawn_backend(port: int, *, script: Path = _BACKEND) -> subprocess.Popen:
54
+ """Start the disposable backend subprocess and wait until it listens.
55
+
56
+ Readiness budget is generous (30s) because cold Python + fastmcp
57
+ import + bind is slow on a loaded host — e.g. CI under xdist, or this
58
+ suite running inside a resource-contended container (the foreman
59
+ daemon's worker check). The old 10s budget passed on a dedicated
60
+ runner but flaked under contention, where bind routinely took ~8-10s.
61
+
62
+ stderr is captured (not discarded) so a genuine startup *failure*
63
+ surfaces in the raised error instead of a blind "did not become
64
+ ready" — the original DEVNULL hid the difference between slow-bind
65
+ and crash-on-start.
66
+ """
67
+ stderr = tempfile.TemporaryFile()
68
+ proc = subprocess.Popen(
69
+ [sys.executable, str(script), str(port)],
70
+ stdout=subprocess.DEVNULL,
71
+ stderr=stderr,
72
+ )
73
+
74
+ def _captured_stderr() -> str:
75
+ try:
76
+ stderr.seek(0)
77
+ return stderr.read().decode("utf-8", "replace").strip() or "<empty>"
78
+ except Exception: # pragma: no cover - diagnostic best-effort
79
+ return "<unavailable>"
80
+
81
+ for _ in range(300): # ~30s bounded readiness (load-tolerant)
82
+ if proc.poll() is not None:
83
+ raise RuntimeError(
84
+ f"backend exited early (rc={proc.returncode}); "
85
+ f"stderr:\n{_captured_stderr()}"
86
+ )
87
+ if await _port_open(port):
88
+ return proc
89
+ await asyncio.sleep(0.1)
90
+ proc.kill()
91
+ raise RuntimeError(
92
+ f"backend did not become ready within 30s; stderr:\n{_captured_stderr()}"
93
+ )
94
+
95
+
96
+ def _kill(proc: subprocess.Popen) -> None:
97
+ if proc.poll() is not None:
98
+ return
99
+ proc.terminate()
100
+ try:
101
+ proc.wait(timeout=10)
102
+ except subprocess.TimeoutExpired:
103
+ proc.kill()
104
+ proc.wait(timeout=5)
105
+
106
+
107
+ @pytest.mark.asyncio
108
+ async def test_spawn_backend_surfaces_crash_with_stderr(tmp_path: Path) -> None:
109
+ """A crash-on-start must raise with the cause named AND the stderr text.
110
+
111
+ This is the diagnostic the DEVNULL -> TemporaryFile change exists to
112
+ provide: the old code blindly reported "did not become ready" whether the
113
+ backend was slow or dead. A backend that writes to stderr and exits
114
+ non-zero should surface both the exit code and that stderr in the error.
115
+ """
116
+ crasher = tmp_path / "crasher.py"
117
+ crasher.write_text(
118
+ "import sys\n"
119
+ "sys.stderr.write('boom: backend refused to start\\n')\n"
120
+ "sys.exit(3)\n"
121
+ )
122
+ with pytest.raises(RuntimeError) as excinfo:
123
+ await _spawn_backend(_free_port(), script=crasher)
124
+ message = str(excinfo.value)
125
+ assert "exited early" in message
126
+ assert "rc=3" in message
127
+ assert "boom: backend refused to start" in message
128
+
129
+
130
+ # The two internal readiness budgets (spawn #1 + recovery spawn #2) sum to
131
+ # ~60s worst-case, which would collide with the global 60s pytest-timeout and
132
+ # reintroduce the very flake this test guards against. Give a per-test ceiling
133
+ # with real headroom over the worst-case internal sum + spawn/teardown overhead.
134
+ @pytest.mark.timeout(150)
135
+ @pytest.mark.asyncio
136
+ async def test_session_survives_backend_bounce() -> None:
137
+ port = _free_port()
138
+ proxy = build_busproxy(agent="agent", daemon_url=f"http://127.0.0.1:{port}")
139
+
140
+ backend = await _spawn_backend(port) # daemon #1 (own child, own port)
141
+ second: subprocess.Popen | None = None
142
+ try:
143
+ async with Client(proxy) as client: # one long-lived client
144
+ r1 = await client.call_tool("list_endpoints", {})
145
+ assert any(e["name"] == "discord" for e in r1.data)
146
+
147
+ _kill(backend) # real daemon bounce: process dies
148
+ backend = None # type: ignore[assignment]
149
+
150
+ # Daemon-down window: SAME client, no re-handshake → transient.
151
+ mid = await client.call_tool("list_endpoints", {})
152
+ assert mid.structured_content["transient"] is True
153
+
154
+ # Fresh daemon process on the SAME port (OS released it on the
155
+ # kill above — no in-process race). The spec contract is
156
+ # fail-fast + the agent RETRIES on {transient}, so model that:
157
+ # poll the same client until it recovers, bounded. #91 is the
158
+ # property that the session *eventually recovers* across the
159
+ # bounce with no re-handshake.
160
+ second = await _spawn_backend(port)
161
+ recovered = False
162
+ for _ in range(60): # ~30s bounded retry budget (load-tolerant)
163
+ try:
164
+ res = await client.call_tool("list_endpoints", {})
165
+ except Exception:
166
+ await asyncio.sleep(0.5)
167
+ continue
168
+ sc = res.structured_content
169
+ if isinstance(sc, dict) and sc.get("transient"):
170
+ await asyncio.sleep(0.5)
171
+ continue
172
+ assert any(e["name"] == "discord" for e in res.data)
173
+ recovered = True
174
+ break
175
+ assert recovered, "session did not recover after daemon restart"
176
+ finally:
177
+ if backend is not None:
178
+ _kill(backend)
179
+ if second is not None:
180
+ _kill(second)
@@ -0,0 +1,9 @@
1
+ """Package scaffold smoke test."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def test_package_imports() -> None:
7
+ import agent_core_busproxy
8
+
9
+ assert agent_core_busproxy.__doc__ is not None
@@ -0,0 +1,167 @@
1
+ """TransientErrorMiddleware: backend-down -> structured retryable result;
2
+ genuine tool errors pass through verbatim.
3
+
4
+ Per spec Amendment 2026-05-16: a backend-down failure can surface either
5
+ as a transport error (warm cache, per-request connect fails) OR as a
6
+ ToolError("Unknown tool: ...") (empty registry because the list fetch
7
+ was swallowed). The latter is AMBIGUOUS with a genuine unknown-tool /
8
+ tool exception, so it is disambiguated with a fast daemon liveness probe.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import socket
14
+
15
+ import httpx
16
+ import pytest
17
+ from agent_core_busproxy.transient import (
18
+ TRANSIENT_ERROR_CODE,
19
+ Disposition,
20
+ TransientErrorMiddleware,
21
+ classify_backend_error,
22
+ daemon_reachable,
23
+ redact,
24
+ )
25
+ from fastmcp.exceptions import NotFoundError, ToolError
26
+
27
+
28
+ def test_redact_strips_query_string() -> None:
29
+ # Same discipline as the #76 signed-CDN redaction.
30
+ assert redact("connect to https://cdn.example.com/x?sig=SECRET&t=9") == (
31
+ "connect to https://cdn.example.com/x?<redacted>"
32
+ )
33
+
34
+
35
+ def test_classify_transport_error_is_transient() -> None:
36
+ assert classify_backend_error(httpx.ConnectError("refused")) is Disposition.TRANSIENT
37
+ assert classify_backend_error(ConnectionError("refused")) is Disposition.TRANSIENT
38
+ assert classify_backend_error(TimeoutError()) is Disposition.TRANSIENT
39
+
40
+
41
+ def test_classify_tool_error_is_ambiguous() -> None:
42
+ # Could be 'down -> empty registry' OR a real unknown-tool error.
43
+ assert classify_backend_error(ToolError("Unknown tool: x")) is Disposition.AMBIGUOUS
44
+
45
+
46
+ def test_classify_unknown_is_genuine() -> None:
47
+ # Never mask an unrecognized error as retryable.
48
+ assert classify_backend_error(ValueError("nope")) is Disposition.GENUINE
49
+
50
+
51
+ @pytest.mark.slow # probes a dead port (waits out the connect failure)
52
+ @pytest.mark.asyncio
53
+ async def test_daemon_reachable_true_and_false() -> None:
54
+ srv = socket.socket()
55
+ srv.bind(("127.0.0.1", 0))
56
+ srv.listen(1)
57
+ host, port = srv.getsockname()
58
+ try:
59
+ assert await daemon_reachable(f"http://{host}:{port}") is True
60
+ finally:
61
+ srv.close()
62
+ # Nothing listening on 65535 => not reachable.
63
+ assert await daemon_reachable("http://127.0.0.1:65535") is False
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_transport_error_becomes_transient_result() -> None:
68
+ mw = TransientErrorMiddleware(daemon_url="http://127.0.0.1:65535")
69
+
70
+ async def call_next(_ctx):
71
+ raise httpx.ConnectError("Connection refused to http://h/x?token=ABC")
72
+
73
+ result = await mw.on_call_tool(object(), call_next)
74
+
75
+ assert result.structured_content["error"] == TRANSIENT_ERROR_CODE
76
+ assert result.structured_content["transient"] is True
77
+ assert isinstance(result.structured_content["retry_after_seconds"], int)
78
+ assert "token=ABC" not in result.structured_content["detail"]
79
+ assert "<redacted>" in result.structured_content["detail"]
80
+
81
+
82
+ @pytest.mark.slow # probes a dead port (waits out the connect failure)
83
+ @pytest.mark.asyncio
84
+ async def test_toolerror_with_daemon_down_becomes_transient() -> None:
85
+ # 65535 unbound => probe says down => AMBIGUOUS resolves to transient.
86
+ mw = TransientErrorMiddleware(daemon_url="http://127.0.0.1:65535")
87
+
88
+ async def call_next(_ctx):
89
+ raise ToolError("Unknown tool: 'consume'")
90
+
91
+ result = await mw.on_call_tool(object(), call_next)
92
+ assert result.structured_content["transient"] is True
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_toolerror_with_daemon_up_passes_through() -> None:
97
+ # A real listening socket => probe says reachable => genuine passthrough.
98
+ srv = socket.socket()
99
+ srv.bind(("127.0.0.1", 0))
100
+ srv.listen(1)
101
+ host, port = srv.getsockname()
102
+ try:
103
+ mw = TransientErrorMiddleware(daemon_url=f"http://{host}:{port}")
104
+
105
+ async def call_next(_ctx):
106
+ raise ToolError("envelope_id not found")
107
+
108
+ with pytest.raises(ToolError, match="envelope_id not found"):
109
+ await mw.on_call_tool(object(), call_next)
110
+ finally:
111
+ srv.close()
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_no_daemon_url_treats_ambiguous_as_genuine() -> None:
116
+ # In-process/test path (no URL): cannot probe => do not mask as transient.
117
+ mw = TransientErrorMiddleware(daemon_url=None)
118
+
119
+ async def call_next(_ctx):
120
+ raise ToolError("envelope_id not found")
121
+
122
+ with pytest.raises(ToolError, match="envelope_id not found"):
123
+ await mw.on_call_tool(object(), call_next)
124
+
125
+
126
+ def test_classify_not_found_error_is_ambiguous() -> None:
127
+ # fastmcp 3.2.4 raises NotFoundError (NOT ToolError) server-side for an
128
+ # empty-registry unknown tool — the #91 backend-down path the proxy's
129
+ # middleware actually sees. Must be AMBIGUOUS so the liveness probe can
130
+ # disambiguate down-daemon vs a genuinely missing tool.
131
+ assert (
132
+ classify_backend_error(NotFoundError("Unknown tool: 'x'"))
133
+ is Disposition.AMBIGUOUS
134
+ )
135
+
136
+
137
+ @pytest.mark.slow # probes a dead port (waits out the connect failure)
138
+ @pytest.mark.asyncio
139
+ async def test_not_found_error_with_daemon_down_becomes_transient() -> None:
140
+ mw = TransientErrorMiddleware(daemon_url="http://127.0.0.1:65535")
141
+
142
+ async def call_next(_ctx):
143
+ raise NotFoundError("Unknown tool: 'consume'")
144
+
145
+ result = await mw.on_call_tool(object(), call_next)
146
+ assert result.structured_content["transient"] is True
147
+
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_not_found_error_with_daemon_up_passes_through() -> None:
151
+ # The no-masking guarantee for the exact exception that motivated the
152
+ # Task 5 deviation: a genuinely-missing tool on a HEALTHY daemon must
153
+ # re-raise, never be masked as a retryable transient result.
154
+ srv = socket.socket()
155
+ srv.bind(("127.0.0.1", 0))
156
+ srv.listen(1)
157
+ host, port = srv.getsockname()
158
+ try:
159
+ mw = TransientErrorMiddleware(daemon_url=f"http://{host}:{port}")
160
+
161
+ async def call_next(_ctx):
162
+ raise NotFoundError("Unknown tool: 'genuinely_missing'")
163
+
164
+ with pytest.raises(NotFoundError, match="genuinely_missing"):
165
+ await mw.on_call_tool(object(), call_next)
166
+ finally:
167
+ srv.close()