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/kernel_bridge.py b/ipyai/kernel_bridge.py index fead9f3..1f21006 100644 --- a/ipyai/kernel_bridge.py +++ b/ipyai/kernel_bridge.py @@ -2,6 +2,8 @@ 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", @@ -120,7 +122,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