-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathe2e_test.py
More file actions
207 lines (176 loc) · 7.54 KB
/
Copy pathe2e_test.py
File metadata and controls
207 lines (176 loc) · 7.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""End-to-end check against two live MCP workers. Costs real API tokens.
python e2e_test.py
Unlike `smoke_test.py` this is **not** part of the standard gates: it needs
ANTHROPIC_API_KEY, makes real requests, and takes ~15s. Run it when you touch
`core/tools.py`, `core/chat.py`, `mcp_client.py`, or anything about how a turn
reaches a worker.
It exists because `smoke_test.py` proves the router's invariants against fakes
inside one process, which leaves three claims resting on assertion alone:
1. that the duplicate-name failure is real. `smoke_test.py` enforces the API's
tool-name rule locally, from a regex. This sends both lists to the API and
confirms the namespaced one is accepted *and* that the un-namespaced one is
rejected — so the premise this whole repo is built on stays falsifiable
rather than becoming folklore in a comment;
2. that namespacing survives a real MCP transport, not just a Python object
pretending to be one;
3. that a real model, given only the `[worker: ...]` description headers,
actually issues both calls in a single turn — which is what makes the
fan-out reachable at all. Correct plumbing that the model never triggers
would pass every check in `smoke_test.py`.
Two `e2e_worker.py` subprocesses stand in for the fleet. See that file for why
it isn't pointed at a real ResearchMesh install.
"""
import asyncio
import os
import sys
import time
from contextlib import AsyncExitStack
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
from anthropic import Anthropic
from core.chat import Chat
from core.claude import Claude
from core.tools import ToolManager
from mcp_client import MCPClient
MODEL = os.getenv("CLAUDE_MODEL", "claude-sonnet-5")
WORKER_SCRIPT = str(ROOT / "e2e_worker.py")
# Two workers whose *tools* are identical and whose *roles* are not — the case
# the router exists to make navigable. The second name is deliberately illegal
# for a tool name, to prove sanitisation survives a real connection.
DESCRIPTIONS = {
"gpu-box": (
"Headless Linux with the CUDA stack and the training data in /data. "
"No display at all, so GUI work fails here."
),
"win.box 2": (
"Windows desktop with a real display, Excel, and the label printer "
"attached. Use it for anything that must be seen or printed."
),
}
FAILURES: list[str] = []
def check(label: str, ok: bool, detail: str = "") -> None:
print(f" {'ok ' if ok else 'FAIL'} {label}{' — ' + detail if detail else ''}")
if not ok:
FAILURES.append(label)
def build_worker(name: str) -> MCPClient:
return MCPClient(
command=sys.executable,
args=[WORKER_SCRIPT],
env={**os.environ, "FAKE_WORKER_NAME": name},
transport="stdio",
timeout_seconds=120,
)
def tool_names_used(messages) -> set[str]:
"""Every tool name the model actually called, across the conversation."""
used: set[str] = set()
for message in messages:
content = message.get("content")
if not isinstance(content, list):
continue
for block in content:
name = getattr(block, "name", None)
if name:
used.add(name)
return used
async def main() -> int:
if not os.getenv("ANTHROPIC_API_KEY"):
print("ANTHROPIC_API_KEY is not set — this test makes real API calls.")
return 1
async with AsyncExitStack() as stack:
clients: dict[str, MCPClient] = {}
for name in DESCRIPTIONS:
client = build_worker(name)
await client.connect()
stack.push_async_callback(client.cleanup)
clients[name] = client
print(f"connected {len(clients)} workers over stdio\n")
print("tool index")
index = await ToolManager.build(clients, DESCRIPTIONS)
names = [t["name"] for t in index.tool_defs]
check(
"two identical workers yield two distinct tools",
len(set(names)) == 2,
str(names),
)
check(
"an illegal worker id is sanitised over a real connection",
"win_box_2__delegate" in names,
str(names),
)
print("\nthe premise: duplicate tool names are rejected by the API")
api = Anthropic()
# `count_tokens` works here only because `index.tool_defs` is
# worker-only. It cannot validate the array the router actually sends:
# that one also carries `web_search`/`web_fetch`, and the endpoint
# answers "Server tools are not supported in the count_tokens
# endpoint" — a 400 that looks like a tool-list problem and is not.
# Widen this to include local_tools.TOOLS and you must switch to a real
# `beta.messages.create` call.
def count(tools) -> tuple[bool, str]:
try:
api.messages.count_tokens(
model=MODEL,
messages=[{"role": "user", "content": "hi"}],
tools=tools,
)
return True, ""
except Exception as e:
return False, str(e)
accepted, error = count(index.tool_defs)
check("namespaced list is accepted", accepted, error[:120])
# What ResearchMesh's own bridge would have sent.
raw = [dict(t, name="delegate") for t in index.tool_defs]
accepted, error = count(raw)
check(
"un-namespaced list is rejected",
not accepted and "unique" in error.lower(),
error[:120] if not accepted else "it was accepted?!",
)
print("\nlive router turn")
chat = Chat(
claude_service=Claude(model=MODEL),
clients=clients,
descriptions=DESCRIPTIONS,
max_parallel=8,
)
started = time.time()
answer = await chat.run(
"Ask both workers, at the same time, to report their status. "
"Send each one a single delegation with the task text 'report status'. "
"Then tell me what each replied."
)
elapsed = time.time() - started
print(f"\n--- router answer ({elapsed:.1f}s) ---\n{answer}\n---")
used = tool_names_used(chat.messages)
# Containment, not equality. The router carries 18 local tools of its
# own now, so the model may legitimately reach for one during this turn
# — that says nothing about the claim under test, which is only that
# both *workers* were called off their description headers alone. An
# equality check here would fail on an unrelated `bash` call.
check(
"the model called both workers from the descriptions alone",
set(names) <= used,
f"called {sorted(used)}, missing {sorted(set(names) - used)}",
)
check(
"both workers' replies reached the model",
"gpu-box" in answer and "win" in answer.lower(),
answer[:120],
)
# The workers sleep 2s each. Sequential is >=4s of sleep plus two model
# round trips; concurrent is ~2s plus the same. The margin is wide
# because model latency dominates and varies.
check(
"workers ran concurrently",
elapsed < 12,
f"{elapsed:.1f}s — check whether fan-out regressed to sequential",
)
print()
if FAILURES:
print(f"FAILED ({len(FAILURES)}): {', '.join(FAILURES)}")
return 1
print("end-to-end checks passed")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))