Skip to content
Draft
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
6 changes: 3 additions & 3 deletions DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The test harness keeps setup small: `tools/test.sh` redirects `XDG_CONFIG_HOME`
- [ipyai/claude_client.py](ipyai/claude_client.py): Claude backend that spawns `claude -p` per turn, writes a synthetic session JSONL for context seeding, bridges custom tools through a unix socket + stdio MCP sidecar, and translates stream-json events into canonical backend events
- [ipyai/mcp_server.py](ipyai/mcp_server.py): in-kernel unix socket server that exposes the live `ToolRegistry` (list_tools, call_tool) to the MCP bridge subprocess
- [ipyai/mcp_bridge.py](ipyai/mcp_bridge.py): stdio MCP server subprocess entry point (`ipyai-mcp-bridge`) that `claude -p` spawns; forwards MCP tool calls over the unix socket
- [ipyai/api_client.py](ipyai/api_client.py): shared `_LisetteBackend` plus two backends on top of it — `ClaudeAPIBackend` (Anthropic via `lisette`) and `CodexAPIBackend` (Codex `responses` endpoint via `lisette.CodexChat`); this is the explicit exception to the common canonical-event formatter path and still uses lisette's native formatter
- [ipyai/api_client.py](ipyai/api_client.py): shared `_FastLLMBackend` plus two backends on top of it — `ClaudeAPIBackend` (Anthropic via `fastllm`) and `CodexAPIBackend` (Codex `responses` endpoint via `fastllm.AsyncChat`); this is the explicit exception to the common canonical-event formatter path and still uses fastllm's native formatter
- [ipyai/codex_client.py](ipyai/codex_client.py): Codex app-server backend, thread/session orchestration, and app-server event translation into canonical backend events
- [ipyai/tooling.py](ipyai/tooling.py): shared custom `ToolRegistry`, schema generation, and local tool calling helpers
- [ipyai/cli.py](ipyai/cli.py): `ipyai` console entry point
Expand Down Expand Up @@ -56,7 +56,7 @@ The test harness keeps setup small: `tools/test.sh` redirects `XDG_CONFIG_HOME`
- `core.py` first builds a typed `ConversationSeed`
- each backend then `prepare_turn(...)`s using that seed
- Claude CLI writes a synthetic session JSONL per turn, spawns `claude -p --resume`, and starts a unix-socket MCP bridge for custom tools
- Claude API and Codex API both rebuild flat history from the typed seed through the shared `_LisetteBackend`
- Claude API and Codex API both rebuild flat history from the typed seed through the shared `_FastLLMBackend`
- Codex resumes or bootstraps an app-server thread from the typed seed
5. `astream_to_stdout()` renders the response through Rich in TTY mode and stores the final transcript text locally.

Expand Down Expand Up @@ -109,7 +109,7 @@ The custom tool story is intentionally small:
Provider-specific tool exposure now fans out from the shared `ToolRegistry`:

- Claude CLI: unix-socket MCP bridge (`ipyai-mcp-bridge`) exposes the registry to `claude -p` via `--mcp-config`; allowed tool names use the `mcp__ipy__...` prefix
- Claude API and Codex API: OpenAI-style function schemas through `lisette`
- Claude API and Codex API: OpenAI-style function schemas through `fastllm`
- Codex: app-server `dynamicTools`

The `ipyai` CLI loads `safepyrun` before `ipyai`, so normal terminal sessions get `pyrun` automatically. `ipyai` seeds the other custom tools into `shell.user_ns` directly.
Expand Down
43 changes: 29 additions & 14 deletions ipyai/api_client.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import json, warnings

from litellm.types.utils import ModelResponse
from lisette.core import (AsyncChat as LisetteAsyncChat, AsyncStreamFormatter as LisetteAsyncStreamFormatter,
contents, mk_tr_details)
from fastllm.chat import (AsyncChat as FastLLMAsyncChat, AsyncStreamFormatter as FastLLMAsyncStreamFormatter, FullResponse,
codex53spark, codex54, codex54m, codex55, mk_tr_details)
from fastllm.types import Part, PartType

from .backend_common import BaseBackend, ConversationSeed, compact_tool, seed_to_flat_history
from .response_compat import full_response_sentinel_text, strip_full_response_sentinel


class _BridgeNS(dict):
"Dict-shaped proxy so lisette's ns-based tool-call path routes through the ToolRegistry bridge. Pass-through — any `FullResponse` a tool returns survives, and plain-str results go through lisette's default truncation."
"Dict-shaped proxy so fastllm's ns-based tool-call path routes through the ToolRegistry bridge. Pass-through — any `FullResponse` a tool returns survives, and plain-str results go through fastllm's default truncation."
def __init__(self, registry):
super().__init__()
self._reg = registry
Expand All @@ -29,8 +31,8 @@ def __getitem__(self, name): return self.get(name)
warnings.filterwarnings("ignore", message="Pydantic serializer warnings", category=UserWarning)


class AsyncStreamFormatter(LisetteAsyncStreamFormatter):
"Streams via lisette's formatter so `outp` keeps full lisette `<details>` tool blocks (round-trippable through `fmt2hist`), but emits a compact `🔧 ...` line per tool result for the live terminal display."
class AsyncStreamFormatter(FastLLMAsyncStreamFormatter):
"Streams via fastllm's formatter so `outp` keeps full fastllm `<details>` tool blocks (round-trippable through `fmt2hist`), but emits a compact `🔧 ...` line per tool result for the live terminal display."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.final_text = ""
Expand All @@ -40,24 +42,31 @@ def _emit(self, text):
if text: self.display_text += text
return text or ""

def _mk_tool_part(self, o, tc):
content = o.get("content") or ""
if isinstance(content, FullResponse): content = full_response_sentinel_text(content)
return Part(type=PartType.tool_result, text=content,
data=dict(id=tc.id, name=tc.function.name, arguments=json.loads(tc.function.arguments or "{}"), server=False))

def format_item(self, o):
if isinstance(o, ModelResponse):
if tcs := getattr(contents(o), "tool_calls", None):
msg = o.choices[0].message if getattr(o, "choices", None) else None
if tcs := getattr(msg, "tool_calls", None):
self.tcs = {tc.id: tc for tc in tcs}
return ""
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._mk_tool_part(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)


class _LisetteBackend(BaseBackend):
class _FastLLMBackend(BaseBackend):
formatter_cls = AsyncStreamFormatter

def _make_chat(self, **kw): raise NotImplementedError
Expand All @@ -71,12 +80,18 @@ async def prepare_turn(self, *, prompt, model, think="l", provider_session_id=No
return self.prepared_turn(stream, provider_session_id=provider_session_id)


class ClaudeAPIBackend(_LisetteBackend):
def _make_chat(self, **kw): return LisetteAsyncChat(**kw, cache=True)
class ClaudeAPIBackend(_FastLLMBackend):
def _make_chat(self, **kw): return FastLLMAsyncChat(**kw, cache=True)

async def prepare_turn(self, *, prompt, model, think="l", provider_session_id=None, seed=None, tool_mode="on", ephemeral=False):
return await super().prepare_turn(prompt=prompt, model=model, think=None, provider_session_id=provider_session_id,
seed=seed, tool_mode=tool_mode, ephemeral=ephemeral)


class CodexAPIBackend(_LisetteBackend):
"Codex API backend via lisette `AsyncChat` + chatgpt provider aliases (codex54/codex55 resolve to `chatgpt/gpt-5.x`). Short config names like 'gpt-5.4' are auto-prefixed with `chatgpt/` for backward compat."
class CodexAPIBackend(_FastLLMBackend):
"Codex API backend via fastllm `AsyncChat` + chatgpt provider aliases (codex54/codex55 resolve to `chatgpt/gpt-5.x`). Short config names like 'gpt-5.4' are auto-prefixed with `chatgpt/` for backward compat."
def _make_chat(self, model, **kw):
if model in (codex54, codex54m, codex55, codex53spark):
return FastLLMAsyncChat(model=model, vendor_name="codex", **kw)
if "/" not in model: model = f"chatgpt/{model}"
return LisetteAsyncChat(model=model, **kw)
return FastLLMAsyncChat(model=model, vendor_name="codex", **kw)
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
2 changes: 1 addition & 1 deletion ipyai/backend_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
from typing import AsyncIterator, Literal

from lisette.core import trunc_param
from fastllm.chat import _trunc_param as trunc_param

from .tooling import ToolRegistry

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
25 changes: 8 additions & 17 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 .response_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 @@ -119,8 +111,7 @@ async def call_tool(self, name, args=None):
res = exprs.get("_r")
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 full_response_sentinel_text(text)
return text

async def read_var(self, name):
Expand Down
20 changes: 20 additions & 0 deletions ipyai/response_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"Compatibility helpers for full-response preservation across serialization boundaries."

FULL_RESPONSE_SENTINEL = "𝍁"


def full_response_sentinel_text(value):
"Wrap `value` in the 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 the 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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ readme = "README.md"
requires-python = ">=3.10"
license = {text = "Apache-2.0"}
authors = [{name = "Jeremy Howard", email = "info@answer.ai"}]
dependencies = ["fastcore>=1.12.31", "ipython>=9", "jupyter_client>=8", "jupyter_console>=6.6", "ipykernel>=6.29", "rich", "mistletoe", "ipythonng", "mcp", "safepyrun", "lisette>=0.1.20",
"safecmd", "pyskills", "toolslm"]
dependencies = ["fastcore>=1.12.31", "ipython>=9", "jupyter_client>=8", "jupyter_console>=6.6", "ipykernel>=6.29", "rich", "mistletoe", "ipythonng", "mcp", "safepyrun",
"fastllm", "safecmd", "pyskills", "toolslm"]

[project.scripts]
ipyai = "ipyai.cli:main"
Expand Down
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
Loading