diff --git a/ipyai/api_client.py b/ipyai/api_client.py index 873bf52..95c5289 100644 --- a/ipyai/api_client.py +++ b/ipyai/api_client.py @@ -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): @@ -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): @@ -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) diff --git a/ipyai/app.py b/ipyai/app.py index 3c76374..26a5ef5 100644 --- a/ipyai/app.py +++ b/ipyai/app.py @@ -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()) diff --git a/ipyai/core.py b/ipyai/core.py index ade02ad..89d8464 100644 --- a/ipyai/core.py +++ b/ipyai/core.py @@ -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" @@ -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] diff --git a/ipyai/kernel_bridge.py b/ipyai/kernel_bridge.py index fead9f3..42792de 100644 --- a/ipyai/kernel_bridge.py +++ b/ipyai/kernel_bridge.py @@ -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 @@ -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 @@ -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): diff --git a/ipyai/lisette_compat.py b/ipyai/lisette_compat.py new file mode 100644 index 0000000..a78bc4e --- /dev/null +++ b/ipyai/lisette_compat.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 73dbeb0..e223179 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -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 diff --git a/tests/test_kernel_existing.py b/tests/test_kernel_existing.py index 92befa9..269c14e 100644 --- a/tests/test_kernel_existing.py +++ b/tests/test_kernel_existing.py @@ -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 @@ -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" @@ -77,4 +77,3 @@ async def _go(): km.shutdown_kernel(now=False) try: loop.close() except Exception: pass - diff --git a/tests/test_kernel_lifecycle.py b/tests/test_kernel_lifecycle.py index 4bdff4d..5d4b2e5 100644 --- a/tests/test_kernel_lifecycle.py +++ b/tests/test_kernel_lifecycle.py @@ -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" @@ -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() @@ -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()