codebind 0.5.3__py3-none-any.whl → 0.6.0__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.
- codebind/configuration.py +60 -0
- codebind/conversation.py +268 -0
- codebind/extension.py +86 -54
- codebind/instructions.md +11 -0
- codebind/jupyter.py +97 -6
- codebind/session.py +274 -53
- {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/package.json +2 -2
- codebind-0.6.0.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/590.8d95476143320694.js +1 -0
- codebind-0.6.0.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/remoteEntry.4f0137dd98629dab.js +1 -0
- codebind-0.6.0.dist-info/METADATA +112 -0
- codebind-0.6.0.dist-info/RECORD +22 -0
- codebind-0.5.3.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/590.65031998ea066c13.js +0 -1
- codebind-0.5.3.data/data/share/jupyter/labextensions/codebind-jupyterlab/static/remoteEntry.b6b50e099d2c670a.js +0 -1
- codebind-0.5.3.dist-info/METADATA +0 -115
- codebind-0.5.3.dist-info/RECORD +0 -19
- {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/install.json +0 -0
- {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/static/style.js +0 -0
- {codebind-0.5.3.data → codebind-0.6.0.data}/data/share/jupyter/labextensions/codebind-jupyterlab/static/third-party-licenses.json +0 -0
- {codebind-0.5.3.dist-info → codebind-0.6.0.dist-info}/WHEEL +0 -0
- {codebind-0.5.3.dist-info → codebind-0.6.0.dist-info}/entry_points.txt +0 -0
- {codebind-0.5.3.dist-info → codebind-0.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""XDG-backed Codebind configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from models_provider import Models
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configuration_home() -> Path:
|
|
15
|
+
xdg_home = os.environ.get("XDG_CONFIG_HOME")
|
|
16
|
+
if xdg_home:
|
|
17
|
+
path = Path(xdg_home).expanduser()
|
|
18
|
+
if path.is_absolute():
|
|
19
|
+
return path
|
|
20
|
+
return Path.home() / ".config"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class CodebindConfiguration:
|
|
25
|
+
model: str
|
|
26
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_configuration() -> CodebindConfiguration:
|
|
30
|
+
path = configuration_home() / "codebind" / "configuration.json"
|
|
31
|
+
if not path.exists():
|
|
32
|
+
raise FileNotFoundError(
|
|
33
|
+
f"Codebind configuration is missing: {path}. "
|
|
34
|
+
'Create it with a JSON object such as {"model": "openai/gpt-5.6-luna"}.'
|
|
35
|
+
)
|
|
36
|
+
value = json.loads(path.read_text())
|
|
37
|
+
if not isinstance(value, dict) or not isinstance(value.get("model"), str):
|
|
38
|
+
raise ValueError(f"Codebind configuration must contain a string model: {path}")
|
|
39
|
+
parameters = value.get("parameters", {})
|
|
40
|
+
if not isinstance(parameters, dict):
|
|
41
|
+
raise ValueError(f"Codebind model parameters must be a JSON object: {path}")
|
|
42
|
+
return CodebindConfiguration(value["model"], parameters)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_models() -> Models:
|
|
46
|
+
path = configuration_home() / "codebind" / "models.json"
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return Models()
|
|
49
|
+
values = json.loads(path.read_text())
|
|
50
|
+
if not isinstance(values, dict):
|
|
51
|
+
raise ValueError(f"Codebind model configuration must be a JSON object: {path}")
|
|
52
|
+
return Models(values)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"CodebindConfiguration",
|
|
57
|
+
"configuration_home",
|
|
58
|
+
"load_configuration",
|
|
59
|
+
"load_models",
|
|
60
|
+
]
|
codebind/conversation.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Durable conversation records and storage boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from langchain_core.messages import BaseMessage, HumanMessage, message_to_dict, messages_from_dict
|
|
13
|
+
|
|
14
|
+
_VERSION = 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConversationStore(Protocol):
|
|
18
|
+
async def load(self) -> dict[str, Any] | None: ...
|
|
19
|
+
|
|
20
|
+
async def save(self, value: dict[str, Any]) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConversationBridge(Protocol):
|
|
24
|
+
async def load_conversation(self) -> dict[str, Any] | None: ...
|
|
25
|
+
|
|
26
|
+
async def save_conversation(self, value: dict[str, Any]) -> None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class Conversation:
|
|
31
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
32
|
+
revision: int = 0
|
|
33
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
34
|
+
cwd: str = field(default_factory=os.getcwd)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, value: dict[str, Any]) -> Conversation:
|
|
38
|
+
if value.get("version") != _VERSION:
|
|
39
|
+
raise ValueError("Unsupported Codebind conversation version")
|
|
40
|
+
identifier = value.get("id")
|
|
41
|
+
events = value.get("events")
|
|
42
|
+
revision = value.get("revision")
|
|
43
|
+
cwd = value.get("cwd")
|
|
44
|
+
if not isinstance(identifier, str) or not isinstance(events, list):
|
|
45
|
+
raise ValueError("Invalid Codebind conversation")
|
|
46
|
+
if not isinstance(revision, int) or revision != len(events):
|
|
47
|
+
raise ValueError("Invalid Codebind conversation revision")
|
|
48
|
+
if any(
|
|
49
|
+
not isinstance(event, dict) or event.get("sequence") != sequence
|
|
50
|
+
for sequence, event in enumerate(events, start=1)
|
|
51
|
+
):
|
|
52
|
+
raise ValueError("Invalid Codebind conversation sequence")
|
|
53
|
+
if not isinstance(cwd, str):
|
|
54
|
+
cwd = ""
|
|
55
|
+
return cls(identifier, revision, deepcopy(events), cwd)
|
|
56
|
+
|
|
57
|
+
def as_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"version": _VERSION,
|
|
60
|
+
"id": self.id,
|
|
61
|
+
"revision": self.revision,
|
|
62
|
+
"cwd": self.cwd,
|
|
63
|
+
"events": deepcopy(self.events),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def append(self, event_type: str, **data: Any) -> dict[str, Any]:
|
|
67
|
+
event = {"sequence": self.revision + 1, "type": event_type, **data}
|
|
68
|
+
self.events.append(event)
|
|
69
|
+
self.revision += 1
|
|
70
|
+
return event
|
|
71
|
+
|
|
72
|
+
def append_message(self, message: BaseMessage, turn_id: str) -> dict[str, Any]:
|
|
73
|
+
return self.append("message", turn_id=turn_id, message=message_to_dict(message))
|
|
74
|
+
|
|
75
|
+
def append_notebook(
|
|
76
|
+
self,
|
|
77
|
+
cells: list[dict[str, Any]],
|
|
78
|
+
turn_id: str,
|
|
79
|
+
) -> dict[str, Any] | None:
|
|
80
|
+
current = self.notebook()
|
|
81
|
+
incoming = _normalize_notebook(cells)
|
|
82
|
+
has_notebook = any(event.get("type") == "notebook" for event in self.events)
|
|
83
|
+
|
|
84
|
+
if not has_notebook:
|
|
85
|
+
payload: dict[str, Any] = {"mode": "snapshot", "cells": incoming}
|
|
86
|
+
else:
|
|
87
|
+
current_by_id = {cell["id"]: cell for cell in current}
|
|
88
|
+
incoming_by_id = {cell["id"]: cell for cell in incoming}
|
|
89
|
+
upsert = [cell for cell in incoming if current_by_id.get(cell["id"]) != cell]
|
|
90
|
+
remove = [cell["id"] for cell in current if cell["id"] not in incoming_by_id]
|
|
91
|
+
current_order = [cell["id"] for cell in current]
|
|
92
|
+
incoming_order = [cell["id"] for cell in incoming]
|
|
93
|
+
if not upsert and not remove and current_order == incoming_order:
|
|
94
|
+
return None
|
|
95
|
+
payload = {"mode": "delta"}
|
|
96
|
+
if upsert:
|
|
97
|
+
payload["upsert"] = upsert
|
|
98
|
+
if remove:
|
|
99
|
+
payload["remove"] = remove
|
|
100
|
+
if current_order != incoming_order:
|
|
101
|
+
payload["order"] = incoming_order
|
|
102
|
+
|
|
103
|
+
content = (
|
|
104
|
+
"<notebook-context>"
|
|
105
|
+
+ json.dumps(
|
|
106
|
+
payload,
|
|
107
|
+
ensure_ascii=False,
|
|
108
|
+
separators=(",", ":"),
|
|
109
|
+
)
|
|
110
|
+
+ "</notebook-context>"
|
|
111
|
+
)
|
|
112
|
+
return self.append(
|
|
113
|
+
"notebook",
|
|
114
|
+
turn_id=turn_id,
|
|
115
|
+
**payload,
|
|
116
|
+
message=message_to_dict(HumanMessage(content)),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def rollback(self, event: dict[str, Any]) -> None:
|
|
120
|
+
if not self.events or self.events[-1] is not event:
|
|
121
|
+
raise RuntimeError("Only the latest conversation event can be rolled back")
|
|
122
|
+
self.events.pop()
|
|
123
|
+
self.revision -= 1
|
|
124
|
+
|
|
125
|
+
def messages(self) -> list[BaseMessage]:
|
|
126
|
+
serialized = [
|
|
127
|
+
event["message"]
|
|
128
|
+
for event in self.events
|
|
129
|
+
if event.get("type") in {"message", "notebook"}
|
|
130
|
+
and isinstance(event.get("message"), dict)
|
|
131
|
+
]
|
|
132
|
+
return messages_from_dict(serialized)
|
|
133
|
+
|
|
134
|
+
def notebook(self) -> list[dict[str, Any]]:
|
|
135
|
+
cells: list[dict[str, Any]] = []
|
|
136
|
+
for event in self.events:
|
|
137
|
+
if event.get("type") != "notebook":
|
|
138
|
+
continue
|
|
139
|
+
mode = event.get("mode")
|
|
140
|
+
if mode == "snapshot":
|
|
141
|
+
cells = _normalize_notebook(event.get("cells"))
|
|
142
|
+
continue
|
|
143
|
+
if mode != "delta":
|
|
144
|
+
raise ValueError("Invalid Codebind notebook event")
|
|
145
|
+
|
|
146
|
+
by_id = {cell["id"]: cell for cell in cells}
|
|
147
|
+
for identifier in event.get("remove", []):
|
|
148
|
+
if not isinstance(identifier, str):
|
|
149
|
+
raise ValueError("Invalid Codebind notebook removal")
|
|
150
|
+
by_id.pop(identifier, None)
|
|
151
|
+
for cell in _normalize_notebook(event.get("upsert", [])):
|
|
152
|
+
by_id[cell["id"]] = cell
|
|
153
|
+
|
|
154
|
+
order = event.get("order")
|
|
155
|
+
if order is None:
|
|
156
|
+
retained = [cell["id"] for cell in cells if cell["id"] in by_id]
|
|
157
|
+
added = [identifier for identifier in by_id if identifier not in retained]
|
|
158
|
+
order = [*retained, *added]
|
|
159
|
+
if (
|
|
160
|
+
not isinstance(order, list)
|
|
161
|
+
or any(not isinstance(identifier, str) for identifier in order)
|
|
162
|
+
or len(order) != len(set(order))
|
|
163
|
+
or set(order) != set(by_id)
|
|
164
|
+
):
|
|
165
|
+
raise ValueError("Invalid Codebind notebook order")
|
|
166
|
+
cells = [by_id[identifier] for identifier in order]
|
|
167
|
+
return deepcopy(cells)
|
|
168
|
+
|
|
169
|
+
def unfinished_turns(self) -> list[str]:
|
|
170
|
+
started: list[str] = []
|
|
171
|
+
finished: set[str] = set()
|
|
172
|
+
for event in self.events:
|
|
173
|
+
turn_id = event.get("turn_id")
|
|
174
|
+
if not isinstance(turn_id, str):
|
|
175
|
+
continue
|
|
176
|
+
if event.get("type") == "turn_started":
|
|
177
|
+
started.append(turn_id)
|
|
178
|
+
elif event.get("type") == "turn_finished":
|
|
179
|
+
finished.add(turn_id)
|
|
180
|
+
return [turn_id for turn_id in started if turn_id not in finished]
|
|
181
|
+
|
|
182
|
+
def pending_tool_calls(self, turn_ids: set[str]) -> list[tuple[str, str]]:
|
|
183
|
+
pending: dict[str, str] = {}
|
|
184
|
+
for event in self.events:
|
|
185
|
+
turn_id = event.get("turn_id")
|
|
186
|
+
message = event.get("message")
|
|
187
|
+
if turn_id not in turn_ids or not isinstance(message, dict):
|
|
188
|
+
continue
|
|
189
|
+
message_type = message.get("type")
|
|
190
|
+
data = message.get("data")
|
|
191
|
+
if not isinstance(data, dict):
|
|
192
|
+
continue
|
|
193
|
+
if message_type == "ai":
|
|
194
|
+
tool_calls = data.get("tool_calls", [])
|
|
195
|
+
if isinstance(tool_calls, list):
|
|
196
|
+
for call in tool_calls:
|
|
197
|
+
identifier = call.get("id") if isinstance(call, dict) else None
|
|
198
|
+
if isinstance(identifier, str) and identifier:
|
|
199
|
+
pending[identifier] = turn_id
|
|
200
|
+
elif message_type == "tool":
|
|
201
|
+
identifier = data.get("tool_call_id")
|
|
202
|
+
if isinstance(identifier, str):
|
|
203
|
+
pending.pop(identifier, None)
|
|
204
|
+
return list(pending.items())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class MemoryConversationStore:
|
|
208
|
+
def __init__(self) -> None:
|
|
209
|
+
self.value: dict[str, Any] | None = None
|
|
210
|
+
|
|
211
|
+
async def load(self) -> dict[str, Any] | None:
|
|
212
|
+
return self.value
|
|
213
|
+
|
|
214
|
+
async def save(self, value: dict[str, Any]) -> None:
|
|
215
|
+
self.value = value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class NotebookConversationStore:
|
|
219
|
+
def __init__(self, bridge: ConversationBridge):
|
|
220
|
+
self.bridge = bridge
|
|
221
|
+
|
|
222
|
+
async def load(self) -> dict[str, Any] | None:
|
|
223
|
+
return await self.bridge.load_conversation()
|
|
224
|
+
|
|
225
|
+
async def save(self, value: dict[str, Any]) -> None:
|
|
226
|
+
await self.bridge.save_conversation(value)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _normalize_notebook(value: Any) -> list[dict[str, Any]]:
|
|
230
|
+
if not isinstance(value, list):
|
|
231
|
+
raise ValueError("Invalid Codebind notebook snapshot")
|
|
232
|
+
cells: list[dict[str, Any]] = []
|
|
233
|
+
identifiers: set[str] = set()
|
|
234
|
+
for raw_cell in value:
|
|
235
|
+
if not isinstance(raw_cell, dict):
|
|
236
|
+
raise ValueError("Invalid Codebind notebook cell")
|
|
237
|
+
identifier = raw_cell.get("id")
|
|
238
|
+
cell_type = raw_cell.get("type")
|
|
239
|
+
source = raw_cell.get("source")
|
|
240
|
+
if (
|
|
241
|
+
not isinstance(identifier, str)
|
|
242
|
+
or not identifier
|
|
243
|
+
or identifier in identifiers
|
|
244
|
+
or cell_type not in {"code", "markdown", "raw"}
|
|
245
|
+
or not isinstance(source, str)
|
|
246
|
+
):
|
|
247
|
+
raise ValueError("Invalid Codebind notebook cell")
|
|
248
|
+
identifiers.add(identifier)
|
|
249
|
+
cell: dict[str, Any] = {"id": identifier, "type": cell_type, "source": source}
|
|
250
|
+
if cell_type == "code":
|
|
251
|
+
execution_count = raw_cell.get("execution_count")
|
|
252
|
+
outputs = raw_cell.get("outputs", [])
|
|
253
|
+
if not (execution_count is None or isinstance(execution_count, int)) or not isinstance(
|
|
254
|
+
outputs, list
|
|
255
|
+
):
|
|
256
|
+
raise ValueError("Invalid Codebind code cell")
|
|
257
|
+
cell["execution_count"] = execution_count
|
|
258
|
+
cell["outputs"] = deepcopy(outputs)
|
|
259
|
+
cells.append(cell)
|
|
260
|
+
return cells
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
__all__ = [
|
|
264
|
+
"Conversation",
|
|
265
|
+
"ConversationStore",
|
|
266
|
+
"MemoryConversationStore",
|
|
267
|
+
"NotebookConversationStore",
|
|
268
|
+
]
|
codebind/extension.py
CHANGED
|
@@ -2,76 +2,108 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import os
|
|
7
|
-
from pathlib import Path
|
|
5
|
+
import asyncio
|
|
8
6
|
from typing import Any
|
|
9
7
|
|
|
10
8
|
from IPython.core.interactiveshell import InteractiveShell
|
|
11
9
|
from langchain_core.language_models import BaseChatModel
|
|
12
|
-
from models_provider import Models
|
|
13
10
|
|
|
11
|
+
from .configuration import load_configuration, load_models
|
|
12
|
+
from .conversation import MemoryConversationStore, NotebookConversationStore
|
|
14
13
|
from .jupyter import JupyterLabBridge
|
|
15
14
|
from .session import Session
|
|
16
15
|
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _models_path() -> Path:
|
|
22
|
-
configured = os.environ.get("XDG_CONFIG_HOME")
|
|
23
|
-
if configured:
|
|
24
|
-
config_home = Path(configured).expanduser()
|
|
25
|
-
if config_home.is_absolute():
|
|
26
|
-
return config_home / "codebind" / "models.json"
|
|
27
|
-
return Path.home() / ".config" / "codebind" / "models.json"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _load_models() -> Models:
|
|
31
|
-
path = _models_path()
|
|
32
|
-
if not path.exists():
|
|
33
|
-
return Models()
|
|
34
|
-
values = json.loads(path.read_text())
|
|
35
|
-
if not isinstance(values, dict):
|
|
36
|
-
raise ValueError(f"Codebind model configuration must be a JSON object: {path}")
|
|
37
|
-
return Models(values)
|
|
17
|
+
_STATE_ATTRIBUTE = "_codebind_extension_state"
|
|
38
18
|
|
|
39
19
|
|
|
40
20
|
def load_ipython_extension(ipython: InteractiveShell) -> None:
|
|
41
|
-
"""Load Codebind into the active IPython
|
|
42
|
-
|
|
43
|
-
previous = getattr(ipython, _NAMESPACE_ATTRIBUTE, None)
|
|
21
|
+
"""Load Codebind into the active IPython session."""
|
|
22
|
+
previous = getattr(ipython, _STATE_ATTRIBUTE, None)
|
|
44
23
|
if isinstance(previous, dict):
|
|
45
|
-
|
|
46
|
-
if isinstance(
|
|
47
|
-
|
|
48
|
-
|
|
24
|
+
previous_load = previous.get("load_task")
|
|
25
|
+
if isinstance(previous_load, asyncio.Task):
|
|
26
|
+
previous_load.cancel()
|
|
27
|
+
previous_session = previous.get("session")
|
|
28
|
+
if isinstance(previous_session, Session) and previous_session.bridge is not None:
|
|
29
|
+
previous_session.bridge.close()
|
|
30
|
+
|
|
31
|
+
configuration = load_configuration()
|
|
32
|
+
models = load_models()
|
|
33
|
+
model: BaseChatModel | None = None
|
|
34
|
+
|
|
35
|
+
def get_model() -> BaseChatModel:
|
|
36
|
+
nonlocal model
|
|
37
|
+
if model is None:
|
|
38
|
+
model = models.chat(configuration.model, **configuration.parameters)
|
|
39
|
+
return model
|
|
40
|
+
|
|
41
|
+
def discard_model(selected: BaseChatModel) -> None:
|
|
42
|
+
nonlocal model
|
|
43
|
+
if model is selected:
|
|
44
|
+
model = None
|
|
45
|
+
|
|
49
46
|
bridge = JupyterLabBridge.connect(ipython)
|
|
50
|
-
|
|
47
|
+
store = NotebookConversationStore(bridge) if bridge is not None else MemoryConversationStore()
|
|
48
|
+
session = Session(shell=ipython, bridge=bridge, store=store)
|
|
49
|
+
|
|
50
|
+
async def answer_question(question: str, notebook: list[dict[str, Any]]) -> None:
|
|
51
|
+
selected = get_model()
|
|
52
|
+
try:
|
|
53
|
+
await session.asend(question, selected, notebook=notebook)
|
|
54
|
+
except BaseException:
|
|
55
|
+
discard_model(selected)
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
def question_magic(line: str, cell: str | None = None) -> None:
|
|
59
|
+
selected = get_model()
|
|
60
|
+
try:
|
|
61
|
+
session.send(cell if cell is not None else line, selected)
|
|
62
|
+
except BaseException:
|
|
63
|
+
discard_model(selected)
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
load_task: asyncio.Task[None] | None = None
|
|
51
67
|
if bridge is not None:
|
|
52
|
-
|
|
53
|
-
async def answer_question(question: str, model_name: str) -> None:
|
|
54
|
-
model = ipython.user_ns.get(model_name)
|
|
55
|
-
if not isinstance(model, BaseChatModel):
|
|
56
|
-
raise NameError(f"{model_name!r} is not a chat model in the IPython namespace")
|
|
57
|
-
await chat.asend(question, model)
|
|
58
|
-
|
|
59
68
|
bridge.handle_questions(answer_question)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
|
|
70
|
+
async def prepare_session() -> None:
|
|
71
|
+
try:
|
|
72
|
+
await session.aload()
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
raise
|
|
75
|
+
except Exception as error:
|
|
76
|
+
bridge.report_session_ready(error)
|
|
77
|
+
else:
|
|
78
|
+
bridge.report_session_ready()
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
load_task = asyncio.get_running_loop().create_task(prepare_session())
|
|
82
|
+
except RuntimeError:
|
|
83
|
+
pass
|
|
84
|
+
ipython.register_magic_function(question_magic, "line_cell", "question")
|
|
85
|
+
setattr(
|
|
86
|
+
ipython,
|
|
87
|
+
_STATE_ATTRIBUTE,
|
|
88
|
+
{
|
|
89
|
+
"session": session,
|
|
90
|
+
"bridge": bridge,
|
|
91
|
+
"load_task": load_task,
|
|
92
|
+
"get_model": get_model,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
67
95
|
|
|
68
96
|
|
|
69
97
|
def unload_ipython_extension(ipython: InteractiveShell) -> None:
|
|
70
|
-
"""
|
|
71
|
-
|
|
72
|
-
if isinstance(
|
|
73
|
-
|
|
74
|
-
if isinstance(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
"""Unload Codebind without touching the user namespace."""
|
|
99
|
+
state: Any = getattr(ipython, _STATE_ATTRIBUTE, None)
|
|
100
|
+
if isinstance(state, dict):
|
|
101
|
+
load_task = state.get("load_task")
|
|
102
|
+
if isinstance(load_task, asyncio.Task):
|
|
103
|
+
load_task.cancel()
|
|
104
|
+
session = state.get("session")
|
|
105
|
+
if isinstance(session, Session) and session.bridge is not None:
|
|
106
|
+
session.bridge.close()
|
|
107
|
+
delattr(ipython, _STATE_ATTRIBUTE)
|
|
108
|
+
ipython.magics_manager.magics["line"].pop("question", None)
|
|
109
|
+
ipython.magics_manager.magics["cell"].pop("question", None)
|
codebind/instructions.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
You are a coding agent, operating through the user’s live IPython session.
|
|
2
|
+
|
|
3
|
+
- Use the `ipython` tool whenever it can help you answer or act, especially for requests about local files, software, the environment, processes, or hardware.
|
|
4
|
+
- Prefer one batched IPython cell that completes as much of the task as practical instead of giving the user commands to run.
|
|
5
|
+
- Use Python as the primary orchestration language; IPython syntax and Python-launched subprocesses are available.
|
|
6
|
+
- Default to read-only inspection unless the user requests a change.
|
|
7
|
+
- Never claim that local access is unavailable before trying the tool.
|
|
8
|
+
- Keep the user oriented while working. Before meaningful tool work, emit one brief Markdown update saying what you are about to do. Between tool calls, emit another update only for a new phase, an important finding, or a change of plan. Do not perform several meaningful steps silently, but do not narrate trivial operations.
|
|
9
|
+
- Keep responses brief and direct.
|
|
10
|
+
- Treat notebook-context messages as the authoritative notebook document state; later deltas override earlier cell versions.
|
|
11
|
+
- You can freely install libraries like matplotlib, pandas and any others as needed; it's preferrable to use them if you need.
|
codebind/jupyter.py
CHANGED
|
@@ -11,7 +11,9 @@ from IPython.core.interactiveshell import InteractiveShell
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
_TARGET_NAME = "codebind"
|
|
14
|
-
|
|
14
|
+
_HANDSHAKE_TIMEOUT_SECONDS = 30
|
|
15
|
+
_Notebook = list[dict[str, Any]]
|
|
16
|
+
_QuestionHandler = Callable[[str, _Notebook], Awaitable[None]]
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class JupyterLabBridge:
|
|
@@ -21,6 +23,10 @@ class JupyterLabBridge:
|
|
|
21
23
|
self._comm = comm
|
|
22
24
|
self._question_handler: _QuestionHandler | None = None
|
|
23
25
|
self._tasks: set[asyncio.Task[None]] = set()
|
|
26
|
+
self._conversation: dict[str, Any] | None = None
|
|
27
|
+
self._conversation_received = False
|
|
28
|
+
self._conversation_waiters: set[asyncio.Future[dict[str, Any] | None]] = set()
|
|
29
|
+
self._save_waiters: dict[str, asyncio.Future[None]] = {}
|
|
24
30
|
self.ready = False
|
|
25
31
|
comm.on_msg(self._on_message)
|
|
26
32
|
|
|
@@ -42,6 +48,10 @@ class JupyterLabBridge:
|
|
|
42
48
|
for task in self._tasks:
|
|
43
49
|
task.cancel()
|
|
44
50
|
self._tasks.clear()
|
|
51
|
+
for future in (*self._conversation_waiters, *self._save_waiters.values()):
|
|
52
|
+
future.cancel()
|
|
53
|
+
self._conversation_waiters.clear()
|
|
54
|
+
self._save_waiters.clear()
|
|
45
55
|
self._comm.close()
|
|
46
56
|
self.ready = False
|
|
47
57
|
|
|
@@ -49,6 +59,34 @@ class JupyterLabBridge:
|
|
|
49
59
|
"""Handle questions submitted by Codebind cells."""
|
|
50
60
|
self._question_handler = handler
|
|
51
61
|
|
|
62
|
+
async def load_conversation(self) -> dict[str, Any] | None:
|
|
63
|
+
if self._conversation_received:
|
|
64
|
+
return self._conversation
|
|
65
|
+
future = asyncio.get_running_loop().create_future()
|
|
66
|
+
self._conversation_waiters.add(future)
|
|
67
|
+
try:
|
|
68
|
+
return await asyncio.wait_for(future, _HANDSHAKE_TIMEOUT_SECONDS)
|
|
69
|
+
finally:
|
|
70
|
+
self._conversation_waiters.discard(future)
|
|
71
|
+
|
|
72
|
+
async def save_conversation(self, value: dict[str, Any]) -> None:
|
|
73
|
+
if not self.ready:
|
|
74
|
+
await self.load_conversation()
|
|
75
|
+
request_id = str(uuid4())
|
|
76
|
+
future = asyncio.get_running_loop().create_future()
|
|
77
|
+
self._save_waiters[request_id] = future
|
|
78
|
+
self._comm.send(
|
|
79
|
+
{
|
|
80
|
+
"type": "conversation_save",
|
|
81
|
+
"request_id": request_id,
|
|
82
|
+
"conversation": value,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
await asyncio.wait_for(future, _HANDSHAKE_TIMEOUT_SECONDS)
|
|
87
|
+
finally:
|
|
88
|
+
self._save_waiters.pop(request_id, None)
|
|
89
|
+
|
|
52
90
|
def start_code_cell(self, source: str) -> str | None:
|
|
53
91
|
"""Insert a running code cell before its execution begins."""
|
|
54
92
|
if not self.ready:
|
|
@@ -115,30 +153,82 @@ class JupyterLabBridge:
|
|
|
115
153
|
self._comm.send({"type": "markdown_cell", "source": source})
|
|
116
154
|
return True
|
|
117
155
|
|
|
156
|
+
def ensure_instructions_cell(self, source: str) -> bool:
|
|
157
|
+
"""Show the conversation's immutable system instructions in the notebook."""
|
|
158
|
+
if not self.ready:
|
|
159
|
+
return False
|
|
160
|
+
self._comm.send({"type": "instructions_cell", "source": source})
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def report_session_ready(self, error: BaseException | None = None) -> None:
|
|
164
|
+
"""Tell JupyterLab whether persisted conversation state is ready."""
|
|
165
|
+
self._comm.send(
|
|
166
|
+
{
|
|
167
|
+
"type": "session_ready",
|
|
168
|
+
"error": (
|
|
169
|
+
{"type": type(error).__name__, "message": str(error)}
|
|
170
|
+
if error is not None
|
|
171
|
+
else None
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
118
176
|
def _on_message(self, message: dict[str, Any]) -> None:
|
|
119
177
|
data = message.get("content", {}).get("data", {})
|
|
120
178
|
if not isinstance(data, dict):
|
|
121
179
|
return
|
|
122
180
|
if data.get("type") == "ready":
|
|
181
|
+
conversation = data.get("conversation")
|
|
182
|
+
self._conversation = conversation if isinstance(conversation, dict) else None
|
|
183
|
+
self._conversation_received = True
|
|
123
184
|
self.ready = True
|
|
185
|
+
for future in tuple(self._conversation_waiters):
|
|
186
|
+
if not future.done():
|
|
187
|
+
future.set_result(self._conversation)
|
|
188
|
+
return
|
|
189
|
+
if data.get("type") == "conversation_saved":
|
|
190
|
+
request_id = data.get("request_id")
|
|
191
|
+
future = self._save_waiters.get(request_id) if isinstance(request_id, str) else None
|
|
192
|
+
if future is None or future.done():
|
|
193
|
+
return
|
|
194
|
+
error = data.get("error")
|
|
195
|
+
if isinstance(error, str) and error:
|
|
196
|
+
future.set_exception(RuntimeError(error))
|
|
197
|
+
else:
|
|
198
|
+
future.set_result(None)
|
|
199
|
+
return
|
|
200
|
+
if data.get("type") == "cancel":
|
|
201
|
+
for task in tuple(self._tasks):
|
|
202
|
+
task.cancel()
|
|
124
203
|
return
|
|
125
204
|
if data.get("type") != "question":
|
|
126
205
|
return
|
|
127
206
|
cell_id = data.get("cell_id")
|
|
128
207
|
question = data.get("question")
|
|
129
|
-
|
|
130
|
-
if not all(isinstance(value, str) for value in (cell_id, question
|
|
208
|
+
notebook = data.get("notebook")
|
|
209
|
+
if not all(isinstance(value, str) for value in (cell_id, question)) or not isinstance(
|
|
210
|
+
notebook, list
|
|
211
|
+
):
|
|
131
212
|
return
|
|
132
|
-
|
|
213
|
+
self._comm.send({"type": "question_started", "cell_id": cell_id})
|
|
214
|
+
task = asyncio.create_task(self._answer_question(cell_id, question, notebook))
|
|
133
215
|
self._tasks.add(task)
|
|
134
216
|
task.add_done_callback(self._tasks.discard)
|
|
135
217
|
|
|
136
|
-
async def _answer_question(
|
|
218
|
+
async def _answer_question(
|
|
219
|
+
self,
|
|
220
|
+
cell_id: str,
|
|
221
|
+
question: str,
|
|
222
|
+
notebook: _Notebook,
|
|
223
|
+
) -> None:
|
|
137
224
|
error: dict[str, str] | None = None
|
|
225
|
+
cancelled = False
|
|
138
226
|
try:
|
|
139
227
|
if self._question_handler is None:
|
|
140
228
|
raise RuntimeError("Codebind is not ready to receive questions.")
|
|
141
|
-
await self._question_handler(question,
|
|
229
|
+
await self._question_handler(question, notebook)
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
cancelled = True
|
|
142
232
|
except Exception as exception:
|
|
143
233
|
error = {"type": type(exception).__name__, "message": str(exception)}
|
|
144
234
|
self._comm.send(
|
|
@@ -146,5 +236,6 @@ class JupyterLabBridge:
|
|
|
146
236
|
"type": "question_finished",
|
|
147
237
|
"cell_id": cell_id,
|
|
148
238
|
"error": error,
|
|
239
|
+
"cancelled": cancelled,
|
|
149
240
|
}
|
|
150
241
|
)
|