Skip to content

Commit 3988c65

Browse files
Zsailerclaude
andcommitted
Add collaborative_tool decorator for AI tool awareness
- Add collaborative_tool decorator in utils.py that enables real-time collaborative awareness - Decorator automatically sets user presence in global and notebook-specific awareness systems - Supports automatic file_path detection from function parameters - Graceful error handling ensures tool execution continues if awareness fails - Add comprehensive test suite with 15 tests covering all functionality - Update README with example usage for creating collaborative tools - Fix mypy type errors in notebook.py and utils.py - Add pytest-asyncio dependency for async testing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 74b7d90 commit 3988c65

File tree

7 files changed

+513
-13
lines changed

7 files changed

+513
-13
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,40 @@ These tools are ideal for agents that assist users with code editing, version co
3434

3535
______________________________________________________________________
3636

37+
## 🔧 Creating Collaborative Tools
38+
39+
For developers building AI tools that need collaborative awareness, `jupyter_ai_tools` provides a `collaborative_tool` decorator that automatically enables real-time collaboration features:
40+
41+
```python
42+
from jupyter_ai_tools.utils import collaborative_tool
43+
44+
# Define user information
45+
user_info = {
46+
"name": "Alice",
47+
"color": "var(--jp-collaborator-color1)",
48+
"display_name": "Alice Smith"
49+
}
50+
51+
# Apply collaborative awareness to your tool
52+
@collaborative_tool(user=user_info)
53+
async def my_notebook_tool(file_path: str, content: str):
54+
"""Your tool implementation here"""
55+
# Tool automatically sets user awareness for:
56+
# - Global awareness system (all users can see Alice is active)
57+
# - Notebook-specific awareness (for .ipynb files)
58+
return f"Processed {file_path}"
59+
60+
# For tools without user context, simply omit the user parameter
61+
@collaborative_tool()
62+
async def my_tool_no_user(file_path: str):
63+
"""Tool without collaborative awareness"""
64+
return f"Processed {file_path}"
65+
```
66+
67+
This decorator enables other users in the same Jupyter environment to see when your AI tool is actively working on shared notebooks, improving the collaborative experience.
68+
69+
______________________________________________________________________
70+
3771
## Requirements
3872

3973
- Jupyter Server

jupyter_ai_tools/toolkits/notebook.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pycrdt import Awareness, Doc, Text, Assoc
1010
from jupyter_ydoc import YNotebook
1111

12-
from ..utils import cell_to_md, get_file_id, get_jupyter_ydoc, notebook_json_to_md, get_global_awareness
12+
from ..utils import cell_to_md, get_file_id, get_jupyter_ydoc, notebook_json_to_md, get_global_awareness, collaborative_tool
1313
import re
1414

1515

@@ -161,7 +161,7 @@ async def read_cell_json(file_path: str, cell_id: str) -> Tuple[Dict[str, Any],
161161
raise
162162

163163

164-
async def get_cell_id_from_index(file_path: str, cell_index: int) -> Optional[int]:
164+
async def get_cell_id_from_index(file_path: str, cell_index: int) -> str:
165165
"""Finds the cell_id of the cell at a specific cell index.
166166
167167
This function reads a Jupyter notebook file and returns the UUID of the cell
@@ -250,7 +250,7 @@ async def add_cell(
250250
ydoc.ycells.append(ycell)
251251
else:
252252
ydoc.ycells.insert(insert_index, ycell)
253-
await write_to_cell_collaboratively(ydoc, ycell, content)
253+
await write_to_cell_collaboratively(ydoc, ycell, content or "")
254254
else:
255255
with open(file_path, "r", encoding="utf-8") as f:
256256
notebook = nbformat.read(f, as_version=nbformat.NO_CONVERT)
@@ -316,7 +316,7 @@ async def insert_cell(
316316
ydoc.ycells.append(ycell)
317317
else:
318318
ydoc.ycells.insert(insert_index, ycell)
319-
await write_to_cell_collaboratively(ydoc, ycell, content)
319+
await write_to_cell_collaboratively(ydoc, ycell, content or "")
320320
else:
321321
with open(file_path, "r", encoding="utf-8") as f:
322322
notebook = nbformat.read(f, as_version=nbformat.NO_CONVERT)
@@ -385,7 +385,7 @@ async def delete_cell(file_path: str, cell_id: str):
385385
raise
386386

387387

388-
def get_cursor_details(cell_source, start_index: int, stop_index: int = None) -> dict:
388+
def get_cursor_details(cell_source: Text, start_index: int, stop_index: Optional[int] = None) -> Dict[str, Any]:
389389
"""
390390
Creates cursor details for collaborative notebook cursor positioning.
391391
@@ -410,7 +410,7 @@ def get_cursor_details(cell_source, start_index: int, stop_index: int = None) ->
410410
head_sticky_index_data = head_sticky_index.to_json()
411411

412412
# Initialize cursor details with default values
413-
cursor_details = {"primary": True, "empty": True}
413+
cursor_details: Dict[str, Any] = {"primary": True, "empty": True}
414414

415415
# Set the head position (where cursor starts)
416416
cursor_details["head"] = {
@@ -438,7 +438,7 @@ def get_cursor_details(cell_source, start_index: int, stop_index: int = None) ->
438438
return cursor_details
439439

440440

441-
def set_cursor_in_ynotebook(ynotebook, cell_source, start_index: int, stop_index: int = None) -> None:
441+
def set_cursor_in_ynotebook(ynotebook: YNotebook, cell_source: Text, start_index: int, stop_index: Optional[int] = None) -> None:
442442
"""
443443
Sets the cursor position in a collaborative notebook environment.
444444
@@ -468,7 +468,8 @@ def set_cursor_in_ynotebook(ynotebook, cell_source, start_index: int, stop_index
468468
details = get_cursor_details(cell_source, start_index, stop_index=stop_index)
469469

470470
# Update the awareness system with the cursor position
471-
ynotebook.awareness.set_local_state_field("cursors", [details])
471+
if ynotebook.awareness:
472+
ynotebook.awareness.set_local_state_field("cursors", [details])
472473
except Exception:
473474
# Silently ignore cursor setting errors to avoid breaking main operations
474475
# This is intentional - cursor positioning is a visual enhancement, not critical
@@ -704,7 +705,7 @@ async def _handle_replace_operation(ynotebook, cell_source, cursor_position: int
704705
return cursor_position
705706

706707

707-
def _safe_set_cursor(ynotebook, cell_source, cursor_position: int, stop_cursor: int = None) -> None:
708+
def _safe_set_cursor(ynotebook: YNotebook, cell_source: Text, cursor_position: int, stop_cursor: Optional[int] = None) -> None:
708709
"""
709710
Safely set cursor position with error handling.
710711

jupyter_ai_tools/utils.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
from jupyter_server.serverapp import ServerApp
2+
from pycrdt import Awareness
3+
import inspect
4+
import functools
5+
import typing
6+
from typing import Optional
27

38

49
async def get_serverapp():
@@ -27,6 +32,19 @@ async def get_jupyter_ydoc(file_id: str):
2732
return notebook
2833

2934

35+
async def get_global_awareness() -> Optional[Awareness]:
36+
serverapp = await get_serverapp()
37+
yroom_manager = serverapp.web_app.settings["yroom_manager"]
38+
39+
room_id = "JupyterLab:globalAwareness"
40+
if yroom_manager.has_room(room_id):
41+
yroom = yroom_manager.get_room(room_id)
42+
return yroom.get_awareness()
43+
44+
# Return None if room doesn't exist
45+
return None
46+
47+
3048
async def get_file_id(file_path: str) -> str:
3149
"""Returns the file_id for the document
3250
@@ -45,6 +63,94 @@ async def get_file_id(file_path: str) -> str:
4563
return file_id
4664

4765

66+
def collaborative_tool(user: typing.Optional[typing.Dict[str, typing.Any]] = None):
67+
"""
68+
Decorator factory to enable collaborative awareness for toolkit functions.
69+
70+
This decorator automatically sets up user awareness in the global
71+
and notebook-specific awareness systems when functions are called.
72+
It enables real-time collaborative features by making the user's
73+
presence visible to other users in the same Jupyter environment.
74+
75+
Args:
76+
user: Optional user dictionary with user information. If None, no awareness is set.
77+
Should contain keys like 'name', 'color', 'display_name', etc.
78+
79+
Returns:
80+
Decorator function that wraps the target function with collaborative awareness.
81+
82+
Example:
83+
>>> user_info = {
84+
... "name": "Alice",
85+
... "color": "var(--jp-collaborator-color1)",
86+
... "display_name": "Alice Smith"
87+
... }
88+
>>>
89+
>>> @collaborative_tool(user=user_info)
90+
... async def my_notebook_tool(file_path: str, content: str):
91+
... # Your tool implementation here
92+
... return f"Processed {file_path}"
93+
"""
94+
def decorator(tool_func):
95+
@functools.wraps(tool_func)
96+
async def wrapper(*args, **kwargs):
97+
# Skip awareness if no user provided
98+
if user is None:
99+
return await tool_func(*args, **kwargs)
100+
101+
# Extract file_path from tool function arguments for notebook-specific awareness
102+
file_path = None
103+
try:
104+
# Try to find file_path in kwargs first
105+
if 'file_path' in kwargs:
106+
file_path = kwargs['file_path']
107+
else:
108+
# Try to find file_path in positional args by inspecting the function signature
109+
sig = inspect.signature(tool_func)
110+
param_names = list(sig.parameters.keys())
111+
112+
# Look for file_path parameter
113+
if 'file_path' in param_names and len(args) > param_names.index('file_path'):
114+
file_path = args[param_names.index('file_path')]
115+
116+
# Set notebook-specific collaborative awareness if we have a file_path
117+
if file_path and file_path.endswith('.ipynb'):
118+
try:
119+
file_id = await get_file_id(file_path)
120+
ydoc = await get_jupyter_ydoc(file_id)
121+
122+
if ydoc:
123+
# Set the local user field in the notebook's awareness
124+
ydoc.awareness.set_local_state_field("user", user)
125+
126+
except Exception:
127+
# Log but don't block tool execution
128+
pass
129+
130+
except Exception:
131+
# Catch any errors in file_path detection
132+
pass
133+
134+
# Set global awareness
135+
try:
136+
g_awareness = await get_global_awareness()
137+
if g_awareness:
138+
g_awareness.set_local_state({
139+
"user": user,
140+
"current": file_path or "",
141+
"documents": [file_path] if file_path else []
142+
})
143+
except Exception:
144+
# Log but don't block tool execution
145+
pass
146+
147+
# Execute the original tool function
148+
return await tool_func(*args, **kwargs)
149+
150+
return wrapper
151+
return decorator
152+
153+
48154
def notebook_json_to_md(notebook_json: dict, include_outputs: bool = True) -> str:
49155
"""Converts a notebook json dict to markdown string using a custom format.
50156

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ dependencies = [
3030
[project.optional-dependencies]
3131
test = [
3232
"pytest>=7.0",
33-
"pytest-jupyter[server]>=0.6"
33+
"pytest-jupyter[server]>=0.6",
34+
"pytest-asyncio"
3435
]
3536
lint = [
3637
"black>=22.6.0",

0 commit comments

Comments
 (0)