Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions ipyai/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
contents, mk_tr_details)

from .backend_common import BaseBackend, ConversationSeed, compact_tool, seed_to_flat_history
from .lisette_compat import full_response_sentinel_text, is_full_response, strip_full_response_sentinel


class _BridgeNS(dict):
Expand Down Expand Up @@ -40,6 +41,11 @@ def _emit(self, text):
if text: self.display_text += text
return text or ""

def _tool_result_for_outp(self, o):
content = o.get("content")
if is_full_response(content): return {**o, "content": full_response_sentinel_text(content)}
return o

def format_item(self, o):
if isinstance(o, ModelResponse):
if tcs := getattr(contents(o), "tool_calls", None):
Expand All @@ -48,10 +54,10 @@ def format_item(self, o):
if isinstance(o, dict) and "tool_call_id" in o:
tc = self.tcs.pop(o["tool_call_id"], None)
if tc is not None:
self.outp += mk_tr_details(o, tc, mx=self.mx)
self.outp += mk_tr_details(self._tool_result_for_outp(o), tc, mx=self.mx)
self.final_text = self.outp
args = json.loads(tc.function.arguments or "{}")
return self._emit(compact_tool(tc.function.name, args, o.get("content") or ""))
return self._emit(compact_tool(tc.function.name, args, strip_full_response_sentinel(o.get("content") or "")))
res = super().format_item(o)
self.final_text = self.outp
return self._emit(res)
Expand Down
3 changes: 1 addition & 2 deletions ipyai/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,7 @@ def _kernel_startup_code(self):
def _bootstrap_kernel(self):
async def _go():
await self._bridge._exec(self._kernel_startup_code())
present = set(await self._bridge.present_names(CUSTOM_TOOL_NAMES))
await self._bridge.inject_tools(skip=present)
await self._bridge.inject_tools()
await self._bridge.available_names(force=True)
return await self._bridge.history_db_info()
return asyncio.get_event_loop().run_until_complete(_go())
Expand Down
8 changes: 5 additions & 3 deletions ipyai/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def _tde_on_text(self, context, text):
- use available shell/file tools for repository work
- use web tools when fresh web context matters

Respond concisely and practically. Markdown is rendered in a terminal with Rich."""
Respond concisely and practically. Markdown is rendered in a terminal with Rich.

Run `pyrun` and `bash` as code execution tools. In pyrun use `list_pyskills()` to list pyskills which are available AI tool modules. Import them as needed. Run `doc(sym)` inside pyrun to render docs for a module, class, function, instance, or any other Python object."""
_COMPLETION_SP = "You are a code completion engine for IPython. Return only the completion text to insert at the cursor."

MAGIC_NAME = "ipyai"
Expand Down Expand Up @@ -367,8 +369,8 @@ def load_config(path=None, backend_name=None):
def load_sysp(path=None):
path = Path(path or SYSP_PATH)
_ensure_config_dir(path)
if not path.exists(): path.write_text(DEFAULT_SYSTEM_PROMPT)
return path.read_text()
custom = path.read_text() if path.exists() else ""
return DEFAULT_SYSTEM_PROMPT + ("\n\n" + custom if custom else "")


def _cell_id(): return uuid.uuid4().hex[:8]
Expand Down
24 changes: 8 additions & 16 deletions ipyai/kernel_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
import ast, asyncio, json
from queue import Empty

from .lisette_compat import full_response_sentinel_text

CUSTOM_TOOL_NAMES = ("pyrun", "bash", "start_bgterm", "write_stdin", "close_bgterm", "lnhashview_file", "exhash_file", "list_pyskills")
_INJECT_IMPORTS = dict(bash="from safecmd import bash", start_bgterm="from bgterm import start_bgterm",
write_stdin="from bgterm import write_stdin", close_bgterm="from bgterm import close_bgterm",
lnhashview_file="from exhash import lnhashview_file", exhash_file="from exhash import exhash_file",
list_pyskills="from pyskills import list_pyskills")

CUSTOM_TOOL_NAMES = ("pyrun", "bash")
_INJECT_IMPORTS = ("from pyskills import list_pyskills, doc", "from safecmd import bash", "from safepyrun import *")
_EXEC_TIMEOUT = 20
_TOOL_TIMEOUT = 600

Expand Down Expand Up @@ -71,16 +70,9 @@ async def _exec(self, code, *, expressions=None, capture_stream=False, timeout=_
exprs = {k: _expr_value(v) for k,v in (content.get("user_expressions") or {}).items()}
return exprs, "".join(stream) if stream is not None else ""

async def present_names(self, names):
"Return subset of `names` already defined and callable in the kernel's user_ns."
probe = "[n for n in %r if n in globals() and callable(globals()[n])]" % list(names)
exprs,_ = await self._exec("", expressions={"_r": probe})
return list(exprs.get("_r") or [])

async def inject_tools(self, skip=()):
"Import the custom tool names (other than pyrun, which must come from an extension)."
skip = set(skip)
stmts = [_INJECT_IMPORTS[n] for n in CUSTOM_TOOL_NAMES if n in _INJECT_IMPORTS and n not in skip]
async def inject_tools(self):
"Run the default namespace imports; pyrun comes from an extension."
stmts = list(_INJECT_IMPORTS)
for stmt in stmts:
try: await self._exec(stmt)
except Exception: pass
Expand Down Expand Up @@ -120,7 +112,7 @@ async def call_tool(self, name, args=None):
text = res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, default=str)
if exprs.get("_full"):
from lisette.core import FullResponse
return FullResponse(text)
return FullResponse(full_response_sentinel_text(text))
return text

async def read_var(self, name):
Expand Down
25 changes: 25 additions & 0 deletions ipyai/lisette_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"Compatibility helpers for lisette response formatting contracts."

FULL_RESPONSE_SENTINEL = "𝍁"


def is_full_response(value):
"Return whether `value` is a lisette-style FullResponse without importing lisette."
return any(cls.__name__ == "FullResponse" for cls in type(value).__mro__)


def full_response_sentinel_text(value):
"Wrap `value` in lisette's serialization-safe no-truncation sentinel."
text = str(value)
if len(text) > 2 and text[0] == FULL_RESPONSE_SENTINEL and text[-1] == FULL_RESPONSE_SENTINEL:
return text
return f"{FULL_RESPONSE_SENTINEL}{text}{FULL_RESPONSE_SENTINEL}"


def strip_full_response_sentinel(value):
"Strip lisette's no-truncation sentinel from display text."
if not isinstance(value, str): return value
text = str(value)
if len(text) > 2 and text[0] == FULL_RESPONSE_SENTINEL and text[-1] == FULL_RESPONSE_SENTINEL:
return text[1:-1]
return value
5 changes: 2 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest

import ipyai.core as core
from ipyai.kernel_bridge import CUSTOM_TOOL_NAMES, KernelBridge
from ipyai.kernel_bridge import KernelBridge


_IPYTHONDIR_SESSION = None
Expand Down Expand Up @@ -125,8 +125,7 @@ def test_db():
async def _prepare_kernel_bridge(client):
bridge = KernelBridge(client)
await bridge._exec(_KERNEL_BOOTSTRAP)
present = set(await bridge.present_names(CUSTOM_TOOL_NAMES))
await bridge.inject_tools(skip=present)
await bridge.inject_tools()
await bridge.available_names(force=True)
return bridge

Expand Down
7 changes: 3 additions & 4 deletions tests/test_kernel_existing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from jupyter_client.asynchronous.client import AsyncKernelClient
from jupyter_client.manager import KernelManager

from ipyai.kernel_bridge import CUSTOM_TOOL_NAMES, KernelBridge
from ipyai.kernel_bridge import KernelBridge
from ipyai.shell import IPyAIHistory


Expand Down Expand Up @@ -41,8 +41,8 @@ async def _go():
await secondary.wait_for_ready(timeout=30)
sb = KernelBridge(secondary)

present = set(await sb.present_names(CUSTOM_TOOL_NAMES))
assert "pyrun" in present, "secondary client should see pyrun already present (from primary's bootstrap)"
names = set(await sb.available_names(force=True))
assert "pyrun" in names, "secondary client should see pyrun already present (from primary's bootstrap)"

val = await sb.read_var("hidden")
assert val == "walnut"
Expand Down Expand Up @@ -77,4 +77,3 @@ async def _go():
km.shutdown_kernel(now=False)
try: loop.close()
except Exception: pass

25 changes: 10 additions & 15 deletions tests/test_kernel_lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"Separate kernel process: spawn + bootstrap + skip-existing-on-inject + shutdown."
"Separate kernel process: spawn + bootstrap + authoritative tool injection + shutdown."
import asyncio

from jupyter_client.asynchronous.client import AsyncKernelClient
from jupyter_client.manager import KernelManager

from ipyai.kernel_bridge import CUSTOM_TOOL_NAMES, KernelBridge
from ipyai.kernel_bridge import KernelBridge


_BOOTSTRAP = ("from IPython import get_ipython\n"
Expand All @@ -14,7 +14,7 @@
"_ip.history_manager.db_log_output = True\n")


def test_spawn_bootstrap_skip_inject_shutdown():
def test_spawn_bootstrap_overwrites_existing_tools_shutdown():
km = KernelManager()
km.start_kernel(extra_arguments=["--HistoryManager.enabled=True"])
loop = asyncio.new_event_loop()
Expand All @@ -28,22 +28,17 @@ async def _go():
bridge = KernelBridge(client)

await bridge._exec(_BOOTSTRAP)
present_after_bootstrap = set(await bridge.present_names(CUSTOM_TOOL_NAMES))
assert "pyrun" in present_after_bootstrap, "safepyrun extension should seed pyrun"
await bridge.inject_tools()
names = set(await bridge.available_names(force=True))
assert {"pyrun", "bash"} <= names

await bridge._exec("def bash(**kw): return 'sentinel-preseeded'")
present_with_preseed = set(await bridge.present_names(CUSTOM_TOOL_NAMES))
assert "bash" in present_with_preseed, "preseeded callable should count as present"

await bridge.inject_tools(skip=present_with_preseed)

res = await bridge.call_tool("bash", {})
assert "sentinel-preseeded" in res, f"inject_tools with skip should have preserved preseeded bash; got {res!r}"
assert "sentinel-preseeded" in res, f"preseeded bash should be active before reinjection; got {res!r}"

await bridge._exec("globals().pop('bash', None)")
await bridge.inject_tools(skip=set(await bridge.present_names(CUSTOM_TOOL_NAMES)))
names = set(await bridge.available_names(force=True))
assert "bash" in names, "after removing preseed and re-injecting, real bash should land"
await bridge.inject_tools()
res = await bridge.call_tool("bash", dict(cmd="printf 'real\\n'", as_dict=True))
assert "real" in res and "sentinel-preseeded" not in res, f"inject_tools should overwrite preseeded bash; got {res!r}"

try: await client.stop_channels()
except Exception: client.stop_channels()
Expand Down