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.
- agent_core_busproxy-0.9.0/.gitignore +248 -0
- agent_core_busproxy-0.9.0/CHANGELOG.md +28 -0
- agent_core_busproxy-0.9.0/PKG-INFO +11 -0
- agent_core_busproxy-0.9.0/VERSION +1 -0
- agent_core_busproxy-0.9.0/pyproject.toml +32 -0
- agent_core_busproxy-0.9.0/src/agent_core_busproxy/__init__.py +1 -0
- agent_core_busproxy-0.9.0/src/agent_core_busproxy/__main__.py +37 -0
- agent_core_busproxy-0.9.0/src/agent_core_busproxy/proxy.py +117 -0
- agent_core_busproxy-0.9.0/src/agent_core_busproxy/transient.py +149 -0
- agent_core_busproxy-0.9.0/tests/_backend_server.py +40 -0
- agent_core_busproxy-0.9.0/tests/test_cli.py +35 -0
- agent_core_busproxy-0.9.0/tests/test_down_window.py +31 -0
- agent_core_busproxy-0.9.0/tests/test_proxy_api_contract.py +121 -0
- agent_core_busproxy-0.9.0/tests/test_proxy_build.py +52 -0
- agent_core_busproxy-0.9.0/tests/test_regression_daemon_bounce.py +180 -0
- agent_core_busproxy-0.9.0/tests/test_scaffold.py +9 -0
- agent_core_busproxy-0.9.0/tests/test_transient.py +167 -0
|
@@ -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,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()
|