Add skill lanes for MCP tool serialization#2308
Conversation
Signed-off-by: Ritwij Aryan Parmar <ritwij.aryan.parmar@gmail.com>
Greptile SummaryThis PR adds optional execution-lane support to the
Confidence Score: 4/5Safe to merge for production use; lane-serialization logic is correct in the single-event-loop uvicorn environment, but the global lock dict creates a test-isolation trap. The production serialization behavior is sound — one event loop, locks never cross loop boundaries. The only real concern is app.state.lane_locks being a module-global not injectable through handle_request, which means loop-bound asyncio.Lock objects created in one asyncio.run() test session will silently persist and raise RuntimeError if a second test exercises the same lane in a fresh session. Current tests avoid this only because each lane name appears in exactly one test. dimos/agents/mcp/mcp_server.py — the lane_locks global state and its interaction with handle_request parameter design. Important Files Changed
Sequence DiagramsequenceDiagram
participant C as MCP Client
participant H as handle_request
participant TC as _handle_tools_call
participant LL as lane_locks (global)
participant EX as ThreadPoolExecutor
C->>H: "tools/call {name: drive}"
H->>TC: req_id, params, skills, rpc_calls, app.state.lane_locks
TC->>TC: lane lookup from skills list
alt lane is None
TC->>EX: run_in_executor(rpc_call)
EX-->>TC: result
else lane is motion
TC->>LL: setdefault(motion, asyncio.Lock())
LL-->>TC: lock
TC->>TC: async with lock acquire
TC->>EX: run_in_executor(rpc_call)
note over EX: Serialized second caller waits at lock
EX-->>TC: result
TC->>TC: release lock
end
TC-->>H: JSON-RPC result
H-->>C: response
Reviews (1): Last reviewed commit: "Add skill lanes for MCP tool serializati..." | Re-trigger Greptile |
| lock = lane_locks.setdefault(lane, asyncio.Lock()) | ||
| async with lock: | ||
| result = await run_rpc_call() |
There was a problem hiding this comment.
Loop-bound
asyncio.Lock in shared global dict
app.state.lane_locks is a module-level dict that persists across the process lifetime. In Python 3.10+, asyncio.Lock inherits from _LoopBoundMixin, which binds the lock to the first event loop that calls acquire() on it — subsequent calls from a different event loop raise RuntimeError: bound to a different event loop. Each asyncio.run() invocation (as used in tests) creates its own event loop, so after test_mcp_module_serializes_same_lane_calls completes, the "motion" lock remains in the dict bound to the now-closed loop. Any future test that also exercises the "motion" lane in a fresh asyncio.run() will immediately fail. The root cause is that lane_locks is not threaded through handle_request the way skills and rpc_calls are. Adding it as an optional parameter (defaulting to app.state.lane_locks) would let tests pass their own empty dict.
| logger.warning("MCP tool not found", tool=name) | ||
| return _jsonrpc_result_text(req_id, f"Tool not found: {name}") | ||
|
|
||
| lane = next((s.lane for s in skills if s.func_name == name), None) |
There was a problem hiding this comment.
The O(n) linear scan for the lane duplicates work already done when the
rpc_calls dict was built. A {s.func_name: s.lane for s in skills}.get(name) dict comprehension or a pre-built lane map alongside rpc_calls would make this O(1).
| lane = next((s.lane for s in skills if s.func_name == name), None) | |
| lane = {s.func_name: s.lane for s in skills}.get(name) |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Problem
Addresses #2204. DimOS skills currently do not expose whether a tool can run concurrently with another tool. That is risky for robot/world-mutating skills such as motion or manipulation: two MCP calls can be dispatched at the same time even when they should share an execution lane.
Closes DIM-XXX
Solution
@skill(lane="motion")form while preserving the existing bare@skilldecorator.SkillInfodiscovery so module skill metadata knows which lane a skill belongs to.tools/listresponses under tool_meta.tools/callrequests that share the same non-empty lane, while leaving unlaned or differently-laned skills concurrent.How to Test
/tmp/dimos/.venv/bin/python -m ruff check dimos/agents/annotation.py dimos/core/module.py dimos/agents/mcp/mcp_server.py dimos/agents/test_skill_result.py dimos/agents/mcp/test_mcp_server.py/tmp/dimos/.venv/bin/python -m pytest dimos/agents/test_skill_result.py dimos/agents/mcp/test_mcp_server.pyNote: I used Python 3.12 through
uv. A fulluv runsync initially hit localpyaudio/PortAudio headers on this Mac, so I provisioned a minimal env for the focused files above.Contributor License Agreement