jupyter-ai-acp-client 0.0.1__py3-none-any.whl
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.
- jupyter_ai_acp_client/__init__.py +25 -0
- jupyter_ai_acp_client/_version.py +4 -0
- jupyter_ai_acp_client/acp_personas/claude.py +30 -0
- jupyter_ai_acp_client/acp_personas/test.py +26 -0
- jupyter_ai_acp_client/base_acp_persona.py +163 -0
- jupyter_ai_acp_client/default_acp_client.py +368 -0
- jupyter_ai_acp_client/extension_app.py +29 -0
- jupyter_ai_acp_client/routes.py +79 -0
- jupyter_ai_acp_client/static/claude.svg +7 -0
- jupyter_ai_acp_client/static/test.svg +40 -0
- jupyter_ai_acp_client/terminal_manager.py +334 -0
- jupyter_ai_acp_client/tests/__init__.py +1 -0
- jupyter_ai_acp_client/tests/test_routes.py +13 -0
- jupyter_ai_acp_client-0.0.1.data/data/etc/jupyter/jupyter_server_config.d/jupyter_ai_acp_client.json +7 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/install.json +5 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/package.json +222 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/728.f69b40505cc5a8669c1e.js +1 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/750.a29148656dc2b5c07d11.js +1 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/remoteEntry.e615ae2e4254ce11d925.js +1 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/style.js +4 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/third-party-licenses.json +16 -0
- jupyter_ai_acp_client-0.0.1.dist-info/METADATA +304 -0
- jupyter_ai_acp_client-0.0.1.dist-info/RECORD +26 -0
- jupyter_ai_acp_client-0.0.1.dist-info/WHEEL +4 -0
- jupyter_ai_acp_client-0.0.1.dist-info/entry_points.txt +3 -0
- jupyter_ai_acp_client-0.0.1.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
except ImportError:
|
|
4
|
+
# Fallback when using the package in dev mode without installing
|
|
5
|
+
# in editable mode with pip. It is highly recommended to install
|
|
6
|
+
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
|
|
7
|
+
import warnings
|
|
8
|
+
warnings.warn("Importing 'jupyter_ai_acp_client' outside a proper installation.")
|
|
9
|
+
__version__ = "dev"
|
|
10
|
+
|
|
11
|
+
from .extension_app import JaiAcpClientExtension
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _jupyter_labextension_paths():
|
|
15
|
+
return [{
|
|
16
|
+
"src": "labextension",
|
|
17
|
+
"dest": "@jupyter-ai/acp-client"
|
|
18
|
+
}]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _jupyter_server_extension_points():
|
|
22
|
+
return [{
|
|
23
|
+
"module": "jupyter_ai_acp_client",
|
|
24
|
+
"app": JaiAcpClientExtension
|
|
25
|
+
}]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from jupyter_ai_persona_manager import PersonaRequirementsUnmet
|
|
3
|
+
if shutil.which("claude-code-acp") is None:
|
|
4
|
+
raise PersonaRequirementsUnmet(
|
|
5
|
+
"This persona requires the Claude Code ACP adapter to be installed."
|
|
6
|
+
" Install it via `npm install -g @zed-industries/claude-code-acp`"
|
|
7
|
+
" then restart."
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from ..base_acp_persona import BaseAcpPersona
|
|
12
|
+
from jupyter_ai_persona_manager import PersonaDefaults
|
|
13
|
+
|
|
14
|
+
class ClaudeAcpPersona(BaseAcpPersona):
|
|
15
|
+
def __init__(self, *args, **kwargs):
|
|
16
|
+
executable = ["claude-code-acp"]
|
|
17
|
+
super().__init__(*args, executable=executable, **kwargs)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def defaults(self) -> PersonaDefaults:
|
|
21
|
+
avatar_path = str(os.path.abspath(
|
|
22
|
+
os.path.join(os.path.dirname(__file__), "..", "static", "claude.svg")
|
|
23
|
+
))
|
|
24
|
+
|
|
25
|
+
return PersonaDefaults(
|
|
26
|
+
name="Claude-ACP",
|
|
27
|
+
description="Claude Code as an ACP agent persona.",
|
|
28
|
+
avatar_path=avatar_path,
|
|
29
|
+
system_prompt="unused"
|
|
30
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from ..base_acp_persona import BaseAcpPersona
|
|
4
|
+
from jupyter_ai_persona_manager import PersonaDefaults
|
|
5
|
+
|
|
6
|
+
class TestAcpPersona(BaseAcpPersona):
|
|
7
|
+
def __init__(self, *args, **kwargs):
|
|
8
|
+
# Get absolute path to agent.py
|
|
9
|
+
agent_path = os.path.abspath(
|
|
10
|
+
os.path.join(os.path.dirname(__file__), "..", "..", "examples", "agent.py")
|
|
11
|
+
)
|
|
12
|
+
executable = [sys.executable, agent_path]
|
|
13
|
+
super().__init__(*args, executable=executable, **kwargs)
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def defaults(self) -> PersonaDefaults:
|
|
17
|
+
avatar_path = str(os.path.abspath(
|
|
18
|
+
os.path.join(os.path.dirname(__file__), "..", "static", "test.svg")
|
|
19
|
+
))
|
|
20
|
+
|
|
21
|
+
return PersonaDefaults(
|
|
22
|
+
name="Test-ACP",
|
|
23
|
+
description="A test ACP persona",
|
|
24
|
+
avatar_path=avatar_path,
|
|
25
|
+
system_prompt="unused"
|
|
26
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from jupyter_ai_persona_manager import BasePersona
|
|
2
|
+
from jupyterlab_chat.models import Message
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from asyncio.subprocess import Process
|
|
6
|
+
from typing import Awaitable, ClassVar
|
|
7
|
+
from acp import NewSessionResponse
|
|
8
|
+
from acp.schema import AvailableCommand
|
|
9
|
+
|
|
10
|
+
from .default_acp_client import JaiAcpClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseAcpPersona(BasePersona):
|
|
14
|
+
_subprocess_future: ClassVar[Awaitable[Process] | None] = None
|
|
15
|
+
"""
|
|
16
|
+
The task that yields the agent subprocess once complete. This is a class
|
|
17
|
+
attribute because multiple instances of the same ACP persona may share an
|
|
18
|
+
ACP agent subprocess.
|
|
19
|
+
|
|
20
|
+
Developers should always use `self.get_agent_subprocess()`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_client_future: ClassVar[Awaitable[JaiAcpClient] | None] = None
|
|
24
|
+
"""
|
|
25
|
+
The future that yields the ACP Client once complete. This is a class
|
|
26
|
+
attribute because multiple instances of the same ACP persona may share an
|
|
27
|
+
ACP client as well. ACP agent subprocesses and clients map 1-to-1.
|
|
28
|
+
|
|
29
|
+
Developers should always use `self.get_client()`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_client_session_future: Awaitable[NewSessionResponse]
|
|
33
|
+
"""
|
|
34
|
+
The future that yields the ACP client session info. Each instance of an ACP
|
|
35
|
+
persona has a unique session ID, i.e. each chat reserves a unique session.
|
|
36
|
+
|
|
37
|
+
Developers should always call `self.get_session()` or `self.get_session_id()`.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_acp_slash_commands: list[AvailableCommand]
|
|
41
|
+
|
|
42
|
+
def __init__(self, *args, executable: list[str], **kwargs):
|
|
43
|
+
super().__init__(*args, **kwargs)
|
|
44
|
+
|
|
45
|
+
self._executable = executable
|
|
46
|
+
|
|
47
|
+
# Ensure each subclass has its own subprocess and client by checking if the
|
|
48
|
+
# class variable is defined directly on this class (not inherited)
|
|
49
|
+
if '_subprocess_future' not in self.__class__.__dict__ or self.__class__._subprocess_future is None:
|
|
50
|
+
self.__class__._subprocess_future = self.event_loop.create_task(
|
|
51
|
+
self._init_agent_subprocess()
|
|
52
|
+
)
|
|
53
|
+
if '_client_future' not in self.__class__.__dict__ or self.__class__._client_future is None:
|
|
54
|
+
self.__class__._client_future = self.event_loop.create_task(
|
|
55
|
+
self._init_client()
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self._client_session_future = self.event_loop.create_task(
|
|
59
|
+
self._init_client_session()
|
|
60
|
+
)
|
|
61
|
+
self._acp_slash_commands = []
|
|
62
|
+
|
|
63
|
+
async def _init_agent_subprocess(self) -> Process:
|
|
64
|
+
process = await asyncio.create_subprocess_exec(
|
|
65
|
+
*self._executable,
|
|
66
|
+
stdin=asyncio.subprocess.PIPE,
|
|
67
|
+
stdout=asyncio.subprocess.PIPE,
|
|
68
|
+
stderr=sys.stderr,
|
|
69
|
+
)
|
|
70
|
+
self.log.info(f"Spawned ACP agent subprocess for '{self.__class__.__name__}'.")
|
|
71
|
+
return process
|
|
72
|
+
|
|
73
|
+
async def _init_client(self) -> JaiAcpClient:
|
|
74
|
+
agent_subprocess = await self.get_agent_subprocess()
|
|
75
|
+
client = JaiAcpClient(agent_subprocess=agent_subprocess, event_loop=self.event_loop)
|
|
76
|
+
self.log.info(f"Initialized ACP client for '{self.__class__.__name__}'.")
|
|
77
|
+
return client
|
|
78
|
+
|
|
79
|
+
async def _init_client_session(self) -> NewSessionResponse:
|
|
80
|
+
client = await self.get_client()
|
|
81
|
+
session = await client.create_session(persona=self)
|
|
82
|
+
self.log.info(
|
|
83
|
+
f"Initialized new ACP client session for '{self.__class__.__name__}'"
|
|
84
|
+
f" with ID '{session.session_id}'."
|
|
85
|
+
)
|
|
86
|
+
return session
|
|
87
|
+
|
|
88
|
+
async def get_agent_subprocess(self) -> asyncio.subprocess.Process:
|
|
89
|
+
"""
|
|
90
|
+
Safely returns the ACP agent subprocess for this persona.
|
|
91
|
+
"""
|
|
92
|
+
return await self.__class__._subprocess_future
|
|
93
|
+
|
|
94
|
+
async def get_client(self) -> JaiAcpClient:
|
|
95
|
+
"""
|
|
96
|
+
Safely returns the ACP client for this persona.
|
|
97
|
+
"""
|
|
98
|
+
return await self.__class__._client_future
|
|
99
|
+
|
|
100
|
+
async def get_session(self) -> NewSessionResponse:
|
|
101
|
+
"""
|
|
102
|
+
Safely returns the ACP client session for this chat.
|
|
103
|
+
"""
|
|
104
|
+
return await self._client_session_future
|
|
105
|
+
|
|
106
|
+
async def get_session_id(self) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Safely returns the ACP client ID assigned to this chat.
|
|
109
|
+
"""
|
|
110
|
+
session = await self._client_session_future
|
|
111
|
+
return session.session_id
|
|
112
|
+
|
|
113
|
+
async def process_message(self, message: Message) -> None:
|
|
114
|
+
"""
|
|
115
|
+
A default implementation for the `BasePersona.process_message()` method
|
|
116
|
+
for ACP agents.
|
|
117
|
+
|
|
118
|
+
This method may be overriden by child classes.
|
|
119
|
+
"""
|
|
120
|
+
client = await self.get_client()
|
|
121
|
+
session_id = await self.get_session_id()
|
|
122
|
+
|
|
123
|
+
# TODO: add attachments!
|
|
124
|
+
prompt = message.body.replace("@" + self.as_user().mention_name, "").strip()
|
|
125
|
+
await client.prompt_and_reply(
|
|
126
|
+
session_id=session_id,
|
|
127
|
+
prompt=prompt,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def acp_slash_commands(self) -> list[AvailableCommand]:
|
|
132
|
+
"""
|
|
133
|
+
Returns the list of slash commands advertised by the ACP agent in the
|
|
134
|
+
current session.
|
|
135
|
+
|
|
136
|
+
This initializes to an empty list, and should be updated **only** by the
|
|
137
|
+
ACP client upon receiving a `session/update` request containing an
|
|
138
|
+
`AvailableCommandsUpdate` payload from the ACP agent.
|
|
139
|
+
"""
|
|
140
|
+
return self._acp_slash_commands
|
|
141
|
+
|
|
142
|
+
@acp_slash_commands.setter
|
|
143
|
+
def acp_slash_commands(self, commands: list[AvailableCommand]):
|
|
144
|
+
self.log.info(
|
|
145
|
+
f"Setting {len(commands)} slash commands for '{self.name}' in room '{self.parent.room_id}'."
|
|
146
|
+
)
|
|
147
|
+
self._acp_slash_commands = commands
|
|
148
|
+
|
|
149
|
+
def shutdown(self):
|
|
150
|
+
# TODO: allow shutdown() to be async
|
|
151
|
+
self.event_loop.create_task(self._shutdown())
|
|
152
|
+
|
|
153
|
+
async def _shutdown(self):
|
|
154
|
+
self.log.info(f"Closing ACP agent and client for '{self.__class__.__name__}'.")
|
|
155
|
+
client = await self.get_client()
|
|
156
|
+
conn = await client.get_connection()
|
|
157
|
+
await conn.close()
|
|
158
|
+
subprocess = await self.get_agent_subprocess()
|
|
159
|
+
try:
|
|
160
|
+
subprocess.kill()
|
|
161
|
+
except ProcessLookupError:
|
|
162
|
+
pass
|
|
163
|
+
self.log.info(f"Completed closed ACP agent and client for '{self.__class__.__name__}'.")
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, AsyncIterator
|
|
8
|
+
|
|
9
|
+
from acp import (
|
|
10
|
+
PROTOCOL_VERSION,
|
|
11
|
+
Client,
|
|
12
|
+
RequestError,
|
|
13
|
+
connect_to_agent,
|
|
14
|
+
text_block,
|
|
15
|
+
)
|
|
16
|
+
from acp.core import ClientSideConnection
|
|
17
|
+
from acp.schema import (
|
|
18
|
+
AgentMessageChunk,
|
|
19
|
+
AgentPlanUpdate,
|
|
20
|
+
AgentThoughtChunk,
|
|
21
|
+
AudioContentBlock,
|
|
22
|
+
AvailableCommandsUpdate,
|
|
23
|
+
ClientCapabilities,
|
|
24
|
+
CreateTerminalResponse,
|
|
25
|
+
CurrentModeUpdate,
|
|
26
|
+
EmbeddedResourceContentBlock,
|
|
27
|
+
EnvVariable,
|
|
28
|
+
FileSystemCapability,
|
|
29
|
+
ImageContentBlock,
|
|
30
|
+
Implementation,
|
|
31
|
+
KillTerminalCommandResponse,
|
|
32
|
+
NewSessionResponse,
|
|
33
|
+
PermissionOption,
|
|
34
|
+
PromptResponse,
|
|
35
|
+
ReadTextFileResponse,
|
|
36
|
+
ReleaseTerminalResponse,
|
|
37
|
+
RequestPermissionResponse,
|
|
38
|
+
ResourceContentBlock,
|
|
39
|
+
TerminalOutputResponse,
|
|
40
|
+
TextContentBlock,
|
|
41
|
+
ToolCall,
|
|
42
|
+
ToolCallProgress,
|
|
43
|
+
ToolCallStart,
|
|
44
|
+
UserMessageChunk,
|
|
45
|
+
WaitForTerminalExitResponse,
|
|
46
|
+
WriteTextFileResponse,
|
|
47
|
+
AllowedOutcome
|
|
48
|
+
)
|
|
49
|
+
from jupyter_ai_persona_manager import BasePersona
|
|
50
|
+
from typing import Awaitable
|
|
51
|
+
from asyncio.subprocess import Process
|
|
52
|
+
|
|
53
|
+
from .terminal_manager import TerminalManager
|
|
54
|
+
|
|
55
|
+
async def queue_to_iterator(queue: asyncio.Queue[str], sentinel: str = "__end__") -> AsyncIterator[str]:
|
|
56
|
+
"""Convert an asyncio queue to an async iterator."""
|
|
57
|
+
while True:
|
|
58
|
+
item = await queue.get()
|
|
59
|
+
if item == sentinel:
|
|
60
|
+
break
|
|
61
|
+
yield item
|
|
62
|
+
|
|
63
|
+
class JaiAcpClient(Client):
|
|
64
|
+
"""
|
|
65
|
+
The default ACP client. The client should be stored as a class attribute on each
|
|
66
|
+
ACP persona, such that each ACP agent subprocess is communicated through
|
|
67
|
+
exactly one ACP client (an instance of this class).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
agent_subprocess: Process
|
|
71
|
+
_connection_future: Awaitable[ClientSideConnection]
|
|
72
|
+
event_loop: asyncio.AbstractEventLoop
|
|
73
|
+
_personas_by_session: dict[str, BasePersona]
|
|
74
|
+
_queues_by_session: dict[str, asyncio.Queue[str]]
|
|
75
|
+
_terminal_manager: TerminalManager
|
|
76
|
+
|
|
77
|
+
def __init__(self, *args, agent_subprocess: Awaitable[Process], event_loop: asyncio.AbstractEventLoop, **kwargs):
|
|
78
|
+
"""
|
|
79
|
+
:param agent_subprocess: The ACP agent subprocess
|
|
80
|
+
(`asyncio.subprocess.Process`) assigned to this client.
|
|
81
|
+
|
|
82
|
+
:param event_loop: The `asyncio` event loop running this process.
|
|
83
|
+
"""
|
|
84
|
+
self.agent_subprocess = agent_subprocess
|
|
85
|
+
# Each client instance needs its own connection to its own subprocess
|
|
86
|
+
self._connection_future = event_loop.create_task(
|
|
87
|
+
self._init_connection()
|
|
88
|
+
)
|
|
89
|
+
self.event_loop = event_loop
|
|
90
|
+
# Each client instance maintains its own session mappings
|
|
91
|
+
self._personas_by_session = {}
|
|
92
|
+
self._queues_by_session = {}
|
|
93
|
+
self._terminal_manager = TerminalManager(event_loop)
|
|
94
|
+
super().__init__(*args, **kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _init_connection(self) -> ClientSideConnection:
|
|
98
|
+
proc = self.agent_subprocess
|
|
99
|
+
conn = connect_to_agent(self, proc.stdin, proc.stdout)
|
|
100
|
+
await conn.initialize(
|
|
101
|
+
protocol_version=PROTOCOL_VERSION,
|
|
102
|
+
client_capabilities=ClientCapabilities(
|
|
103
|
+
fs=FileSystemCapability(read_text_file=True, write_text_file=True),
|
|
104
|
+
terminal=True,
|
|
105
|
+
),
|
|
106
|
+
client_info=Implementation(name="Jupyter AI", title="Jupyter AI ACP Client", version="0.1.0"),
|
|
107
|
+
)
|
|
108
|
+
return conn
|
|
109
|
+
|
|
110
|
+
async def get_connection(self) -> ClientSideConnection:
|
|
111
|
+
return await self._connection_future
|
|
112
|
+
|
|
113
|
+
async def create_session(self, persona: BasePersona) -> NewSessionResponse:
|
|
114
|
+
"""
|
|
115
|
+
Create an ACP agent session through this client scoped to a
|
|
116
|
+
`BasePersona` instance.
|
|
117
|
+
"""
|
|
118
|
+
conn = await self.get_connection()
|
|
119
|
+
# TODO: change this to Jupyter preferred dir
|
|
120
|
+
session = await conn.new_session(mcp_servers=[], cwd=os.getcwd())
|
|
121
|
+
self._personas_by_session[session.session_id] = persona
|
|
122
|
+
return session
|
|
123
|
+
|
|
124
|
+
async def prompt_and_reply(self, session_id: str, prompt: str, attachments: list[dict] = []) -> PromptResponse:
|
|
125
|
+
"""
|
|
126
|
+
A helper method that sends a prompt with an optional list of attachments
|
|
127
|
+
to the assigned ACP server. This method writes back to the chat by
|
|
128
|
+
calling methods on the persona corresponding to this session ID.
|
|
129
|
+
"""
|
|
130
|
+
assert session_id in self._personas_by_session
|
|
131
|
+
conn = await self.get_connection()
|
|
132
|
+
|
|
133
|
+
# ensure an asyncio Queue exists for this session
|
|
134
|
+
# the `session_update()` method will push chunks to this queue
|
|
135
|
+
queue = self._queues_by_session.get(session_id, None)
|
|
136
|
+
if queue is None:
|
|
137
|
+
queue: asyncio.Queue[str] = asyncio.Queue()
|
|
138
|
+
self._queues_by_session[session_id] = queue
|
|
139
|
+
|
|
140
|
+
# create async iterator that yields until the response is complete
|
|
141
|
+
aiter = queue_to_iterator(queue)
|
|
142
|
+
|
|
143
|
+
# create background task to stream message back to client using the
|
|
144
|
+
# dedicated persona method
|
|
145
|
+
persona = self._personas_by_session[session_id]
|
|
146
|
+
self.event_loop.create_task(
|
|
147
|
+
persona.stream_message(aiter)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# call the model and await
|
|
151
|
+
# TODO: add attachments!
|
|
152
|
+
response = await conn.prompt(
|
|
153
|
+
prompt=[
|
|
154
|
+
TextContentBlock(text=prompt, type="text"),
|
|
155
|
+
],
|
|
156
|
+
session_id=session_id
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# push sentinel value to queue to close the async iterator
|
|
160
|
+
queue.put_nowait("__end__")
|
|
161
|
+
|
|
162
|
+
return response
|
|
163
|
+
|
|
164
|
+
async def session_update(
|
|
165
|
+
self,
|
|
166
|
+
session_id: str,
|
|
167
|
+
update: UserMessageChunk
|
|
168
|
+
| AgentMessageChunk
|
|
169
|
+
| AgentThoughtChunk
|
|
170
|
+
| ToolCallStart
|
|
171
|
+
| ToolCallProgress
|
|
172
|
+
| AgentPlanUpdate
|
|
173
|
+
| AvailableCommandsUpdate
|
|
174
|
+
| CurrentModeUpdate,
|
|
175
|
+
**kwargs: Any,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Handles `session/update` requests from the ACP agent. There must be an
|
|
179
|
+
`asyncio.Queue` corresponding to this session ID - this should be set by
|
|
180
|
+
the `prompt_and_reply()` method.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
if isinstance(update, AvailableCommandsUpdate):
|
|
184
|
+
if not update.available_commands:
|
|
185
|
+
return
|
|
186
|
+
persona = self._personas_by_session.get(session_id)
|
|
187
|
+
if persona and hasattr(persona, 'acp_slash_commands'):
|
|
188
|
+
persona.acp_slash_commands = update.available_commands
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
if not isinstance(update, AgentMessageChunk):
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if session_id not in self._queues_by_session:
|
|
195
|
+
logging.error(f"No queue found for session_id: {session_id}")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
content = update.content
|
|
199
|
+
text: str
|
|
200
|
+
if isinstance(content, TextContentBlock):
|
|
201
|
+
text = content.text
|
|
202
|
+
elif isinstance(content, ImageContentBlock):
|
|
203
|
+
text = "<image>"
|
|
204
|
+
elif isinstance(content, AudioContentBlock):
|
|
205
|
+
text = "<audio>"
|
|
206
|
+
elif isinstance(content, ResourceContentBlock):
|
|
207
|
+
text = content.uri or "<resource>"
|
|
208
|
+
elif isinstance(content, EmbeddedResourceContentBlock):
|
|
209
|
+
text = "<resource>"
|
|
210
|
+
else:
|
|
211
|
+
text = "<content>"
|
|
212
|
+
|
|
213
|
+
queue = self._queues_by_session[session_id]
|
|
214
|
+
queue.put_nowait(text)
|
|
215
|
+
|
|
216
|
+
async def request_permission(
|
|
217
|
+
self, options: list[PermissionOption], session_id: str, tool_call: ToolCall, **kwargs: Any
|
|
218
|
+
) -> RequestPermissionResponse:
|
|
219
|
+
"""
|
|
220
|
+
Handles `session/request_permission` requests from the ACP agent.
|
|
221
|
+
|
|
222
|
+
TODO: This currently always gives the agent permission. We will need to
|
|
223
|
+
add some tool call approval UI and handle permission requests properly.
|
|
224
|
+
"""
|
|
225
|
+
option_id = ""
|
|
226
|
+
for o in options:
|
|
227
|
+
if "allow" in o.option_id.lower():
|
|
228
|
+
option_id = o.option_id
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
return RequestPermissionResponse(
|
|
232
|
+
outcome=AllowedOutcome(option_id=option_id, outcome='selected')
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def write_text_file(
|
|
236
|
+
self, content: str, path: str, session_id: str, **kwargs: Any
|
|
237
|
+
) -> WriteTextFileResponse | None:
|
|
238
|
+
# Validate path parameter
|
|
239
|
+
if not path or not path.strip():
|
|
240
|
+
raise RequestError.invalid_params({"path": "path cannot be empty"})
|
|
241
|
+
|
|
242
|
+
file_path = Path(path)
|
|
243
|
+
|
|
244
|
+
# Check if path is a directory
|
|
245
|
+
if file_path.is_dir():
|
|
246
|
+
raise RequestError.invalid_params({"path": "path cannot be a directory"})
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
await asyncio.to_thread(file_path.write_text, content, encoding="utf-8")
|
|
251
|
+
except PermissionError as e:
|
|
252
|
+
raise RequestError.internal_error({"path": path, "error": f"Permission denied: {e}"})
|
|
253
|
+
except OSError as e:
|
|
254
|
+
raise RequestError.internal_error({"path": path, "error": str(e)})
|
|
255
|
+
|
|
256
|
+
return WriteTextFileResponse()
|
|
257
|
+
|
|
258
|
+
async def read_text_file(
|
|
259
|
+
self, path: str, session_id: str, limit: int | None = None, line: int | None = None, **kwargs: Any
|
|
260
|
+
) -> ReadTextFileResponse:
|
|
261
|
+
# Validate path parameter
|
|
262
|
+
if not path or not path.strip():
|
|
263
|
+
raise RequestError.invalid_params({"path": "path cannot be empty"})
|
|
264
|
+
|
|
265
|
+
# Validate line parameter (must be >= 1 if provided)
|
|
266
|
+
if line is not None and line < 1:
|
|
267
|
+
raise RequestError.invalid_params({"line": "line must be >= 1 (1-indexed)"})
|
|
268
|
+
|
|
269
|
+
# Validate limit parameter (must be >= 1 if provided)
|
|
270
|
+
if limit is not None and limit < 1:
|
|
271
|
+
raise RequestError.invalid_params({"limit": "limit must be >= 1"})
|
|
272
|
+
|
|
273
|
+
file_path = Path(path)
|
|
274
|
+
|
|
275
|
+
# Check if file exists
|
|
276
|
+
if not file_path.exists():
|
|
277
|
+
raise RequestError.resource_not_found(path)
|
|
278
|
+
|
|
279
|
+
# Check if path is a directory
|
|
280
|
+
if file_path.is_dir():
|
|
281
|
+
raise RequestError.invalid_params({"path": "path cannot be a directory"})
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
text = await asyncio.to_thread(file_path.read_text, encoding="utf-8")
|
|
285
|
+
except PermissionError as e:
|
|
286
|
+
raise RequestError.internal_error({"path": path, "error": f"Permission denied: {e}"})
|
|
287
|
+
except OSError as e:
|
|
288
|
+
raise RequestError.internal_error({"path": path, "error": str(e)})
|
|
289
|
+
|
|
290
|
+
lines = text.splitlines(keepends=True)
|
|
291
|
+
|
|
292
|
+
# line is 1-indexed; default to line 1 if not specified
|
|
293
|
+
start_index = (line - 1) if line is not None else 0
|
|
294
|
+
|
|
295
|
+
if limit is not None:
|
|
296
|
+
lines = lines[start_index : start_index + limit]
|
|
297
|
+
else:
|
|
298
|
+
lines = lines[start_index:]
|
|
299
|
+
|
|
300
|
+
content = "".join(lines)
|
|
301
|
+
return ReadTextFileResponse(content=content)
|
|
302
|
+
|
|
303
|
+
##############################
|
|
304
|
+
# Terminal methods
|
|
305
|
+
##############################
|
|
306
|
+
|
|
307
|
+
async def create_terminal(
|
|
308
|
+
self,
|
|
309
|
+
command: str,
|
|
310
|
+
session_id: str,
|
|
311
|
+
args: list[str] | None = None,
|
|
312
|
+
cwd: str | None = None,
|
|
313
|
+
env: list[EnvVariable] | None = None,
|
|
314
|
+
output_byte_limit: int | None = None,
|
|
315
|
+
**kwargs: Any,
|
|
316
|
+
) -> CreateTerminalResponse:
|
|
317
|
+
return await self._terminal_manager.create_terminal(
|
|
318
|
+
command=command,
|
|
319
|
+
session_id=session_id,
|
|
320
|
+
args=args,
|
|
321
|
+
cwd=cwd,
|
|
322
|
+
env=env,
|
|
323
|
+
output_byte_limit=output_byte_limit,
|
|
324
|
+
**kwargs,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def terminal_output(
|
|
328
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
329
|
+
) -> TerminalOutputResponse:
|
|
330
|
+
return await self._terminal_manager.terminal_output(
|
|
331
|
+
session_id=session_id,
|
|
332
|
+
terminal_id=terminal_id,
|
|
333
|
+
**kwargs,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def release_terminal(
|
|
337
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
338
|
+
) -> ReleaseTerminalResponse | None:
|
|
339
|
+
return await self._terminal_manager.release_terminal(
|
|
340
|
+
session_id=session_id,
|
|
341
|
+
terminal_id=terminal_id,
|
|
342
|
+
**kwargs,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
async def wait_for_terminal_exit(
|
|
346
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
347
|
+
) -> WaitForTerminalExitResponse:
|
|
348
|
+
return await self._terminal_manager.wait_for_terminal_exit(
|
|
349
|
+
session_id=session_id,
|
|
350
|
+
terminal_id=terminal_id,
|
|
351
|
+
**kwargs,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
async def kill_terminal(
|
|
355
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
356
|
+
) -> KillTerminalCommandResponse | None:
|
|
357
|
+
return await self._terminal_manager.kill_terminal(
|
|
358
|
+
session_id=session_id,
|
|
359
|
+
terminal_id=terminal_id,
|
|
360
|
+
**kwargs,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
async def ext_method(self, method: str, params: dict) -> dict:
|
|
364
|
+
raise RequestError.method_not_found(method)
|
|
365
|
+
|
|
366
|
+
async def ext_notification(self, method: str, params: dict) -> None:
|
|
367
|
+
raise RequestError.method_not_found(method)
|
|
368
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from jupyter_server.extension.application import ExtensionApp
|
|
4
|
+
from .routes import AcpSlashCommandsHandler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JaiAcpClientExtension(ExtensionApp):
|
|
8
|
+
"""
|
|
9
|
+
Jupyter AI ACP client extension.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
name = "jupyter_ai_acp_client"
|
|
13
|
+
handlers = [
|
|
14
|
+
(r"ai/acp/slash_commands/?([^/]*)?", AcpSlashCommandsHandler),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
def initialize_settings(self):
|
|
18
|
+
"""Initialize router settings and event listeners."""
|
|
19
|
+
# # Ensure 'jupyter-ai' dictionary is in `self.settings`, which gets
|
|
20
|
+
# # copied to `self.serverapp.web_app.settings` after this method returns
|
|
21
|
+
# if 'jupyter-ai' not in self.settings:
|
|
22
|
+
# self.settings['jupyter-ai'] = {}
|
|
23
|
+
|
|
24
|
+
# self.settings['jupyter-ai']['acp-client']
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
async def stop_extension(self):
|
|
28
|
+
"""Clean up router when extension stops."""
|
|
29
|
+
return
|