codebind 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- codebind-0.1.0/PKG-INFO +83 -0
- codebind-0.1.0/README.md +58 -0
- codebind-0.1.0/pyproject.toml +65 -0
- codebind-0.1.0/pyproject.toml.orig +51 -0
- codebind-0.1.0/src/codebind/__init__.py +16 -0
- codebind-0.1.0/src/codebind/cli.py +45 -0
- codebind-0.1.0/src/codebind/execution.py +67 -0
- codebind-0.1.0/src/codebind/py.typed +0 -0
- codebind-0.1.0/src/codebind/rendering.py +54 -0
- codebind-0.1.0/src/codebind/session.py +151 -0
codebind-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Giovanni Gravili
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
codebind-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codebind
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal model loop over a persistent IPython session.
|
|
5
|
+
Keywords: ai,ipython,llm,repl,agents
|
|
6
|
+
Author: Giovanni Gravili
|
|
7
|
+
Author-email: Giovanni Gravili <ghovax@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: ipython>=9.0
|
|
18
|
+
Requires-Dist: langchain-core>=1.0
|
|
19
|
+
Requires-Dist: models-provider>=0.1.0
|
|
20
|
+
Requires-Dist: rich>=14.0
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Project-URL: Repository, https://github.com/ghovax/codebind
|
|
23
|
+
Project-URL: Issues, https://github.com/ghovax/codebind/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Codebind
|
|
27
|
+
|
|
28
|
+
Codebind runs a model with one tool: an IPython cell executed in the session shared with the user. Conversation history belongs to a `Session`; the model is selected independently for every call.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Run Codebind without installing it permanently:
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
uvx codebind
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or install it with any Python package installer:
|
|
39
|
+
|
|
40
|
+
```console
|
|
41
|
+
pip install codebind
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
After installation, start the preconfigured shell from any directory:
|
|
45
|
+
|
|
46
|
+
```console
|
|
47
|
+
codebind
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
It opens IPython with `chat` and `Models` already available and renders a short Markdown usage guide in the terminal. It does not modify the user's global IPython profile.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
models = Models({"openai": "OPENAI_API_KEY"})
|
|
54
|
+
|
|
55
|
+
chat.ask(
|
|
56
|
+
"Inspect this project and tell me what to implement first.",
|
|
57
|
+
models.chat("openai/gpt-5", reasoning_effort="medium"),
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Codebind does not load files or construct a project prompt automatically. The user states what should be loaded as context. Pass `instructions=` to `Session` only when an application needs its own system instructions.
|
|
62
|
+
|
|
63
|
+
## ChatGPT account login
|
|
64
|
+
|
|
65
|
+
Models Provider can start its OpenAI browser sign-in flow directly from IPython:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import webbrowser
|
|
69
|
+
|
|
70
|
+
models = Models()
|
|
71
|
+
authorization = await models.sign_in("openai")
|
|
72
|
+
webbrowser.open(authorization.url)
|
|
73
|
+
await authorization.complete()
|
|
74
|
+
|
|
75
|
+
chat.ask(
|
|
76
|
+
"Inspect this project.",
|
|
77
|
+
models.chat("openai/gpt-5", authorization=authorization),
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The authorization remains in memory for this session. Persistent credential storage belongs to the host application.
|
|
82
|
+
|
|
83
|
+
OpenAI officially supports ChatGPT subscription sign-in for Codex clients. Models Provider reproduces that account-access boundary for this library; it is separate from the public, pay-as-you-go OpenAI API and may require compatibility updates when the Codex account protocol changes.
|
codebind-0.1.0/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Codebind
|
|
2
|
+
|
|
3
|
+
Codebind runs a model with one tool: an IPython cell executed in the session shared with the user. Conversation history belongs to a `Session`; the model is selected independently for every call.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Run Codebind without installing it permanently:
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
uvx codebind
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install it with any Python package installer:
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
pip install codebind
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
After installation, start the preconfigured shell from any directory:
|
|
20
|
+
|
|
21
|
+
```console
|
|
22
|
+
codebind
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It opens IPython with `chat` and `Models` already available and renders a short Markdown usage guide in the terminal. It does not modify the user's global IPython profile.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
models = Models({"openai": "OPENAI_API_KEY"})
|
|
29
|
+
|
|
30
|
+
chat.ask(
|
|
31
|
+
"Inspect this project and tell me what to implement first.",
|
|
32
|
+
models.chat("openai/gpt-5", reasoning_effort="medium"),
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Codebind does not load files or construct a project prompt automatically. The user states what should be loaded as context. Pass `instructions=` to `Session` only when an application needs its own system instructions.
|
|
37
|
+
|
|
38
|
+
## ChatGPT account login
|
|
39
|
+
|
|
40
|
+
Models Provider can start its OpenAI browser sign-in flow directly from IPython:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import webbrowser
|
|
44
|
+
|
|
45
|
+
models = Models()
|
|
46
|
+
authorization = await models.sign_in("openai")
|
|
47
|
+
webbrowser.open(authorization.url)
|
|
48
|
+
await authorization.complete()
|
|
49
|
+
|
|
50
|
+
chat.ask(
|
|
51
|
+
"Inspect this project.",
|
|
52
|
+
models.chat("openai/gpt-5", authorization=authorization),
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The authorization remains in memory for this session. Persistent credential storage belongs to the host application.
|
|
57
|
+
|
|
58
|
+
OpenAI officially supports ChatGPT subscription sign-in for Codex clients. Models Provider reproduces that account-access boundary for this library; it is separate from the public, pay-as-you-go OpenAI API and may require compatibility updates when the Codex account protocol changes.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "codebind"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A minimal model loop over a persistent IPython session."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"ai",
|
|
11
|
+
"ipython",
|
|
12
|
+
"llm",
|
|
13
|
+
"repl",
|
|
14
|
+
"agents",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"ipython>=9.0",
|
|
27
|
+
"langchain-core>=1.0",
|
|
28
|
+
"models-provider>=0.1.0",
|
|
29
|
+
"rich>=14.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "Giovanni Gravili"
|
|
34
|
+
email = "ghovax@users.noreply.github.com"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Repository = "https://github.com/ghovax/codebind"
|
|
38
|
+
Issues = "https://github.com/ghovax/codebind/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
codebind = "codebind.cli:main"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=8.4",
|
|
46
|
+
"ruff>=0.13",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
target-version = "py313"
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = [
|
|
55
|
+
"E4",
|
|
56
|
+
"E7",
|
|
57
|
+
"E9",
|
|
58
|
+
"F",
|
|
59
|
+
"B",
|
|
60
|
+
"RUF",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[build-system]
|
|
64
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
65
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "codebind"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A minimal model loop over a persistent IPython session."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Giovanni Gravili", email = "ghovax@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
keywords = ["ai", "ipython", "llm", "repl", "agents"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"ipython>=9.0",
|
|
24
|
+
"langchain-core>=1.0",
|
|
25
|
+
"models-provider>=0.1.0",
|
|
26
|
+
"rich>=14.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Repository = "https://github.com/ghovax/codebind"
|
|
31
|
+
Issues = "https://github.com/ghovax/codebind/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
codebind = "codebind.cli:main"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8.4",
|
|
39
|
+
"ruff>=0.13",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py313"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E4", "E7", "E9", "F", "B", "RUF"]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
51
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""A minimal model loop over the current IPython session."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .execution import ExecutionReport, IPythonExecutor
|
|
6
|
+
from .session import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__version__ = version("codebind")
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ExecutionReport",
|
|
13
|
+
"IPythonExecutor",
|
|
14
|
+
"Session",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Project-local IPython launcher with Codebind conveniences preloaded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from IPython.core.interactiveshell import InteractiveShell
|
|
9
|
+
from IPython.terminal.ipapp import TerminalIPythonApp
|
|
10
|
+
from models_provider import Models
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .session import Session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
BANNER = """\
|
|
19
|
+
- `models = Models({...})`
|
|
20
|
+
- `chat.ask("...", models.chat("provider/model"))`
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def namespace(shell: InteractiveShell) -> dict[str, Any]:
|
|
25
|
+
"""Build the small namespace exposed by the Codebind shell."""
|
|
26
|
+
return {
|
|
27
|
+
"chat": Session(shell=shell),
|
|
28
|
+
"Models": Models,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
"""Start IPython in the current directory with Codebind preloaded."""
|
|
34
|
+
parser = argparse.ArgumentParser(description="Start a model-enabled IPython session.")
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
36
|
+
parser.parse_args()
|
|
37
|
+
|
|
38
|
+
Console().print(Markdown(BANNER))
|
|
39
|
+
application = TerminalIPythonApp.instance()
|
|
40
|
+
application.display_banner = False
|
|
41
|
+
application.initialize([])
|
|
42
|
+
shell = application.shell
|
|
43
|
+
shell.enable_tip = False
|
|
44
|
+
shell.push(namespace(shell))
|
|
45
|
+
application.start()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Execution of model-authored cells in a shared IPython namespace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from IPython.core.interactiveshell import InteractiveShell
|
|
9
|
+
from IPython.utils.capture import capture_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ExecutionReport:
|
|
14
|
+
"""The model-facing result of one IPython cell."""
|
|
15
|
+
|
|
16
|
+
ok: bool
|
|
17
|
+
stdout: str
|
|
18
|
+
stderr: str
|
|
19
|
+
result: str | None
|
|
20
|
+
displays: tuple[str, ...]
|
|
21
|
+
error: dict[str, str] | None
|
|
22
|
+
|
|
23
|
+
def as_dict(self) -> dict[str, Any]:
|
|
24
|
+
"""Return a JSON-serializable representation."""
|
|
25
|
+
return asdict(self)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IPythonExecutor:
|
|
29
|
+
"""Run cells through one existing IPython shell."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, shell: InteractiveShell) -> None:
|
|
32
|
+
self.shell = shell
|
|
33
|
+
|
|
34
|
+
def execute(self, cell: str) -> ExecutionReport:
|
|
35
|
+
"""Execute a cell, replay its visible output, and capture a structured result."""
|
|
36
|
+
if not isinstance(cell, str) or not cell.strip():
|
|
37
|
+
raise ValueError("cell must be a non-empty string")
|
|
38
|
+
|
|
39
|
+
displayhook = self.shell.displayhook
|
|
40
|
+
original_prompt = displayhook.write_output_prompt
|
|
41
|
+
displayhook.write_output_prompt = lambda: None
|
|
42
|
+
try:
|
|
43
|
+
with capture_output() as captured:
|
|
44
|
+
result = self.shell.run_cell(cell, store_history=False)
|
|
45
|
+
finally:
|
|
46
|
+
displayhook.write_output_prompt = original_prompt
|
|
47
|
+
|
|
48
|
+
displays: list[str] = []
|
|
49
|
+
for output in captured.outputs:
|
|
50
|
+
data = getattr(output, "data", None)
|
|
51
|
+
if isinstance(data, dict) and "text/plain" in data:
|
|
52
|
+
displays.append(str(data["text/plain"]))
|
|
53
|
+
|
|
54
|
+
exception = result.error_before_exec or result.error_in_exec
|
|
55
|
+
error = (
|
|
56
|
+
{"type": type(exception).__name__, "message": str(exception)}
|
|
57
|
+
if exception is not None
|
|
58
|
+
else None
|
|
59
|
+
)
|
|
60
|
+
return ExecutionReport(
|
|
61
|
+
ok=result.success,
|
|
62
|
+
stdout=captured.stdout,
|
|
63
|
+
stderr=captured.stderr,
|
|
64
|
+
result=repr(result.result) if result.result is not None else None,
|
|
65
|
+
displays=tuple(displays),
|
|
66
|
+
error=error,
|
|
67
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Terminal rendering for model responses and IPython tool output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from itertools import chain
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.segment import Segments
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from .execution import ExecutionReport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_MAX_OUTPUT_LINES = 10
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _visible_output(report: ExecutionReport) -> str:
|
|
19
|
+
parts = [part for part in (report.stdout, report.stderr) if part]
|
|
20
|
+
if report.displays:
|
|
21
|
+
parts.extend(report.displays)
|
|
22
|
+
elif not parts and report.result is not None:
|
|
23
|
+
parts.append(report.result)
|
|
24
|
+
if not parts and report.error is not None:
|
|
25
|
+
parts.append(f"{report.error['type']}: {report.error['message']}")
|
|
26
|
+
return "\n".join(part.rstrip("\n") for part in parts if part).strip("\n")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TerminalRenderer:
|
|
30
|
+
"""Render concise tool previews and Markdown answers to the active terminal."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self.console = Console()
|
|
34
|
+
|
|
35
|
+
def tool_output(self, report: ExecutionReport) -> None:
|
|
36
|
+
"""Show a bounded preview while leaving the report itself intact for the model."""
|
|
37
|
+
visible = _visible_output(report)
|
|
38
|
+
if not visible:
|
|
39
|
+
return
|
|
40
|
+
output = Text.from_ansi(visible)
|
|
41
|
+
options = self.console.options
|
|
42
|
+
complete = self.console.render_lines(output, options, pad=False, new_lines=True)
|
|
43
|
+
preview = complete[:_MAX_OUTPUT_LINES]
|
|
44
|
+
self.console.print(Segments(chain.from_iterable(preview)), end="")
|
|
45
|
+
if len(complete) > len(preview):
|
|
46
|
+
self.console.print(
|
|
47
|
+
f"[dim]{len(preview)} of {len(complete)} lines shown; "
|
|
48
|
+
"the complete result was returned to the model.[/dim]"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def assistant(self, text: str) -> None:
|
|
52
|
+
"""Render the final model response as terminal Markdown."""
|
|
53
|
+
self.console.print()
|
|
54
|
+
self.console.print(Markdown(text))
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Conversation state and the single IPython-tool model loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from IPython import get_ipython
|
|
11
|
+
from IPython.core.interactiveshell import InteractiveShell
|
|
12
|
+
from langchain_core.language_models import BaseChatModel
|
|
13
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
|
|
14
|
+
from langchain_core.runnables import Runnable
|
|
15
|
+
|
|
16
|
+
from .execution import ExecutionReport, IPythonExecutor
|
|
17
|
+
from .rendering import TerminalRenderer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
IPYTHON_TOOL = {
|
|
21
|
+
"type": "function",
|
|
22
|
+
"function": {
|
|
23
|
+
"name": "ipython",
|
|
24
|
+
"description": (
|
|
25
|
+
"Execute an IPython cell in the state-persistent session shared with the user."
|
|
26
|
+
),
|
|
27
|
+
"parameters": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"cell": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "A complete IPython cell to execute.",
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"required": ["cell"],
|
|
36
|
+
"additionalProperties": False,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _message_text(message: AIMessage) -> str:
|
|
43
|
+
content = message.content
|
|
44
|
+
if isinstance(content, str):
|
|
45
|
+
return content
|
|
46
|
+
if not isinstance(content, Sequence) or isinstance(content, (bytes, bytearray, str)):
|
|
47
|
+
return str(content)
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
for block in content:
|
|
50
|
+
if isinstance(block, str):
|
|
51
|
+
parts.append(block)
|
|
52
|
+
elif isinstance(block, Mapping) and isinstance(block.get("text"), str):
|
|
53
|
+
parts.append(block["text"])
|
|
54
|
+
return "".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tool_error(error_type: str, message: str) -> ExecutionReport:
|
|
58
|
+
return ExecutionReport(False, "", "", None, (), {"type": error_type, "message": message})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Session:
|
|
62
|
+
"""Keep conversation history while choosing the model independently for every turn."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
shell: InteractiveShell | None = None,
|
|
68
|
+
instructions: str | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
resolved_shell = shell or get_ipython()
|
|
71
|
+
if resolved_shell is None:
|
|
72
|
+
raise RuntimeError("Session must be created inside IPython or given an IPython shell.")
|
|
73
|
+
|
|
74
|
+
self.shell = resolved_shell
|
|
75
|
+
self.executor = IPythonExecutor(resolved_shell)
|
|
76
|
+
self.renderer = TerminalRenderer()
|
|
77
|
+
self.instructions = instructions.strip() if instructions else None
|
|
78
|
+
self.messages: list[BaseMessage] = []
|
|
79
|
+
self.last_response: AIMessage | None = None
|
|
80
|
+
if self.instructions:
|
|
81
|
+
self.messages.append(SystemMessage(self.instructions))
|
|
82
|
+
|
|
83
|
+
def clear(self) -> None:
|
|
84
|
+
"""Clear conversation history without clearing the shared Python namespace."""
|
|
85
|
+
self.messages.clear()
|
|
86
|
+
self.last_response = None
|
|
87
|
+
if self.instructions:
|
|
88
|
+
self.messages.append(SystemMessage(self.instructions))
|
|
89
|
+
|
|
90
|
+
def ask(self, prompt: str, model: BaseChatModel) -> None:
|
|
91
|
+
"""Run one user turn with the explicitly supplied model."""
|
|
92
|
+
text = prompt.strip()
|
|
93
|
+
if not text:
|
|
94
|
+
raise ValueError("prompt cannot be empty")
|
|
95
|
+
|
|
96
|
+
bound_model = self._bind(model)
|
|
97
|
+
self.messages.append(HumanMessage(text))
|
|
98
|
+
|
|
99
|
+
while True:
|
|
100
|
+
response = bound_model.invoke(tuple(self.messages))
|
|
101
|
+
if not isinstance(response, AIMessage):
|
|
102
|
+
raise TypeError("model must return an AIMessage")
|
|
103
|
+
self.messages.append(response)
|
|
104
|
+
self.last_response = response
|
|
105
|
+
|
|
106
|
+
if not response.tool_calls:
|
|
107
|
+
answer = _message_text(response)
|
|
108
|
+
if answer:
|
|
109
|
+
self.renderer.assistant(answer)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
for call in response.tool_calls:
|
|
113
|
+
report = self._execute_call(call)
|
|
114
|
+
identifier = str(call.get("id") or f"ipython-{len(self.messages)}")
|
|
115
|
+
self.messages.append(
|
|
116
|
+
ToolMessage(
|
|
117
|
+
json.dumps(report.as_dict(), ensure_ascii=False),
|
|
118
|
+
tool_call_id=identifier,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _bind(model: BaseChatModel) -> Runnable[Any, BaseMessage]:
|
|
124
|
+
try:
|
|
125
|
+
return model.bind_tools([IPYTHON_TOOL], parallel_tool_calls=False)
|
|
126
|
+
except NotImplementedError:
|
|
127
|
+
return model.bind(tools=[IPYTHON_TOOL], parallel_tool_calls=False)
|
|
128
|
+
|
|
129
|
+
def _execute_call(self, call: Mapping[str, Any]) -> ExecutionReport:
|
|
130
|
+
if call.get("name") != "ipython":
|
|
131
|
+
return _tool_error("UnknownTool", f"unknown tool: {call.get('name')!r}")
|
|
132
|
+
arguments = call.get("args")
|
|
133
|
+
if not isinstance(arguments, Mapping) or not isinstance(arguments.get("cell"), str):
|
|
134
|
+
return _tool_error("InvalidArguments", "ipython requires a string cell argument")
|
|
135
|
+
|
|
136
|
+
cell = arguments["cell"]
|
|
137
|
+
self._show_call(cell)
|
|
138
|
+
try:
|
|
139
|
+
report = self.executor.execute(cell)
|
|
140
|
+
except Exception as error: # The failure must be returned to the model, not end the session.
|
|
141
|
+
report = _tool_error(type(error).__name__, str(error))
|
|
142
|
+
self.renderer.tool_output(report)
|
|
143
|
+
return report
|
|
144
|
+
|
|
145
|
+
def _show_call(self, cell: str) -> None:
|
|
146
|
+
highlighted = self.shell.pycolorize(cell.rstrip())
|
|
147
|
+
sys.stdout.write("\n")
|
|
148
|
+
sys.stdout.write(highlighted)
|
|
149
|
+
if not highlighted.endswith("\n"):
|
|
150
|
+
sys.stdout.write("\n")
|
|
151
|
+
sys.stdout.flush()
|