diff --git a/aidialog/_modidx.py b/aidialog/_modidx.py index 8d93651..c86cd09 100644 --- a/aidialog/_modidx.py +++ b/aidialog/_modidx.py @@ -250,6 +250,7 @@ 'aidialog.msg_parts.InputFile': ('msg_parts.html#inputfile', 'aidialog/msg_parts.py'), 'aidialog.msg_parts.InputImage': ('msg_parts.html#inputimage', 'aidialog/msg_parts.py'), 'aidialog.msg_parts.InputVideo': ('msg_parts.html#inputvideo', 'aidialog/msg_parts.py'), + 'aidialog.msg_parts.MdStr': ('msg_parts.html#mdstr', 'aidialog/msg_parts.py'), 'aidialog.msg_parts.Media': ('msg_parts.html#media', 'aidialog/msg_parts.py'), 'aidialog.msg_parts.Media.__init__': ('msg_parts.html#media.__init__', 'aidialog/msg_parts.py'), 'aidialog.msg_parts.Media.ctext': ('msg_parts.html#media.ctext', 'aidialog/msg_parts.py'), diff --git a/aidialog/dialog.py b/aidialog/dialog.py index f809dba..7732e25 100644 --- a/aidialog/dialog.py +++ b/aidialog/dialog.py @@ -687,7 +687,7 @@ def _code_span(txt): _code_args = {'py':'code', 'python':'code', 'bash':'cmd'} def tool_md(d): - "Display markdown for one parsed `{.tool}` block: a folded details div labeled `func(params)→result`, code tools shown as code" + "Display markdown for one parsed `{.tool}` block: a folded details div labeled `func(params)→result`, code shown as code, the result fenced unless the block marks it `md`" params = ', '.join(f"{k}={_fmt_param(v)}" for k,v in (d.get('args') or {}).items()) res = d.get('result') tail = f"→{_fmt_param(res)}" if res not in (None, '') else '' @@ -696,7 +696,9 @@ def tool_md(d): if code is not None: body = f"Code:\n{fenced(str(code), d.get('name'))}" if str(res or '').strip(): body += f"\n\nOutput:\n\n{fenced(str(res))}" - else: body = fenced(dumps(d, indent=2, ensure_ascii=False), 'json') + else: + body = f"Args:\n{fenced(dumps(d.get('args') or {}, indent=2, ensure_ascii=False), 'json')}" + if str(res or '').strip(): body += f"\n\n{res}" if d.get('md') else f"\n\nOutput:\n\n{fenced(str(res))}" return fenced(f"## {label}\n\n{body}", ' {.details .tool-usage-details}', ch=':') def usage_md(d): diff --git a/aidialog/msg_parts.py b/aidialog/msg_parts.py index ffba55e..50448dc 100644 --- a/aidialog/msg_parts.py +++ b/aidialog/msg_parts.py @@ -10,7 +10,7 @@ 'msg2dict', 'dict2msg', 'ToolUse', 'ToolResult', 'display_list', 'Completion', 'mk_tool_res_msg', 'sys_text', 'part_txt', 'data_url', 'url_mime', 'MediaUrl', 'mk_content', 'parse_tools', 'strip_tools', 'conv_tools', 'extract_fence_call', 'mk_result_fence', 'split_fence_msgs', 'tool_text', 'fmt2hist', 'ToolResponse', - 'StopResponse', 'FullResponse', 'trunc_str', 'mk_tr_details', 'hist2fmt', 'mk_msg', 'mk_msgs'] + 'StopResponse', 'FullResponse', 'MdStr', 'trunc_str', 'mk_tr_details', 'hist2fmt', 'mk_msg', 'mk_msgs'] # %% ../nbs/00_msg_parts.ipynb #a616b4f5 import base64, json, copy @@ -46,7 +46,7 @@ def replace(self, **kw): # %% ../nbs/00_msg_parts.ipynb #48496c08 class Text(Part, tag=PartType.text): - "Plain text content." + "Plain text content; `citations` lists the sources it rests on as `url_citation` dicts (`url`, `title`, optional `start_index`/`end_index`)" def __init__(self, text=None, citations=None, **kw): super().__init__(**kw) store_attr('text,citations') @@ -268,7 +268,7 @@ def mk_content(o): return o # %% ../nbs/00_msg_parts.ipynb #ee9c825e -tool_info = 'json {.tool}' # fence info string of a tool block: {id, name, args, result} (+server; `error` reserved) +tool_info = 'json {.tool}' # fence info string of a tool block: {id, name, args, result} (+server, md; `error` reserved) usage_info = 'json {.usage}' # fence info string of a usage block: UsageStats fields def parse_tools(s): @@ -384,7 +384,8 @@ def _extract_tool_parts(d:dict): "Build (tool_use_part, tool_result_part) from a parsed `{.tool}` block" if not d or d.get('id') is None: return None tu = ToolUse (id=d['id'], name=d['name'], arguments=d.get('args') or {}, server=d.get('server', False)) - tr = ToolResult(id=d['id'], name=d['name'], text=tool_text(d.get('result')), server=d.get('server', False)) + text = tool_text(d.get('result')) + tr = ToolResult(id=d['id'], name=d['name'], text=MdStr(text) if d.get('md') else text, server=d.get('server', False)) return tu, tr # %% ../nbs/00_msg_parts.ipynb #916f8df0 @@ -428,6 +429,7 @@ def __hash__(self): return hash(str(self.content)) # %% ../nbs/00_msg_parts.ipynb #86265805 class StopResponse(str): pass class FullResponse(str): pass +class MdStr(str): pass # %% ../nbs/00_msg_parts.ipynb #4f105e4d def trunc_str(s, mx=2000, skip=10, replace="TRUNCATED"): @@ -470,6 +472,7 @@ def mk_tr_details(tr, mx=2000): args = {k:trunc_str(v, mx=None if mx is None else mx*5) if isinstance(v, str) else v for k,v in tr.arguments.items()} res = dict(id=tr.id, name=tr.name, args=args, result=trunc_str(tool_text(tr.text), mx=mx)) if tr.server: res['server'] = True + if isinstance(tr.text, MdStr): res['md'] = True return "\n\n" + fenced(dumps(res, indent=2, ensure_ascii=False), tool_info) + "\n\n" # %% ../nbs/00_msg_parts.ipynb #70d1e8c3 diff --git a/nbs/00_msg_parts.ipynb b/nbs/00_msg_parts.ipynb index d115940..a78538d 100644 --- a/nbs/00_msg_parts.ipynb +++ b/nbs/00_msg_parts.ipynb @@ -185,7 +185,7 @@ "source": [ "#| export\n", "class Text(Part, tag=PartType.text):\n", - " \"Plain text content.\"\n", + " \"Plain text content; `citations` lists the sources it rests on as `url_citation` dicts (`url`, `title`, optional `start_index`/`end_index`)\"\n", " def __init__(self, text=None, citations=None, **kw):\n", " super().__init__(**kw)\n", " store_attr('text,citations')\n", @@ -1066,7 +1066,7 @@ "outputs": [], "source": [ "#| export\n", - "tool_info = 'json {.tool}' # fence info string of a tool block: {id, name, args, result} (+server; `error` reserved)\n", + "tool_info = 'json {.tool}' # fence info string of a tool block: {id, name, args, result} (+server, md; `error` reserved)\n", "usage_info = 'json {.usage}' # fence info string of a usage block: UsageStats fields\n", "\n", "def parse_tools(s):\n", @@ -1635,7 +1635,8 @@ " \"Build (tool_use_part, tool_result_part) from a parsed `{.tool}` block\"\n", " if not d or d.get('id') is None: return None\n", " tu = ToolUse (id=d['id'], name=d['name'], arguments=d.get('args') or {}, server=d.get('server', False))\n", - " tr = ToolResult(id=d['id'], name=d['name'], text=tool_text(d.get('result')), server=d.get('server', False))\n", + " text = tool_text(d.get('result'))\n", + " tr = ToolResult(id=d['id'], name=d['name'], text=MdStr(text) if d.get('md') else text, server=d.get('server', False))\n", " return tu, tr" ] }, @@ -1774,7 +1775,7 @@ "id": "6a1e64c0", "metadata": {}, "source": [ - "`StopResponse` and `FullResponse` are `str` subclasses that mark a string's handling downstream: a `StopResponse` tool result ends a tool loop, and a `FullResponse` must never be truncated. `_trunc_str` honors the latter (along with fastcore's `Safe` and `PrettyString`, checked by class name so no import is needed), and the `𝍁...𝍁` marker is the same contract for strings that crossed a serialization boundary:" + "`StopResponse`, `FullResponse`, and `MdStr` are `str` subclasses that mark a string's handling downstream: a `StopResponse` tool result ends a tool loop, a `FullResponse` must never be truncated, and an `MdStr` is markdown a renderer may show as such rather than fenced (a tool that returns one has opted in, since untrusted text rendered as markdown can mislead). `_trunc_str` honors `FullResponse` (along with fastcore's `Safe` and `PrettyString`, checked by class name so no import is needed), and the `𝍁...𝍁` marker is the same contract for strings that crossed a serialization boundary:\n" ] }, { @@ -1786,7 +1787,8 @@ "source": [ "#| export\n", "class StopResponse(str): pass\n", - "class FullResponse(str): pass" + "class FullResponse(str): pass\n", + "class MdStr(str): pass" ] }, { @@ -1876,6 +1878,7 @@ " args = {k:trunc_str(v, mx=None if mx is None else mx*5) if isinstance(v, str) else v for k,v in tr.arguments.items()}\n", " res = dict(id=tr.id, name=tr.name, args=args, result=trunc_str(tool_text(tr.text), mx=mx))\n", " if tr.server: res['server'] = True\n", + " if isinstance(tr.text, MdStr): res['md'] = True\n", " return \"\\n\\n\" + fenced(dumps(res, indent=2, ensure_ascii=False), tool_info) + \"\\n\\n\"" ] }, @@ -2291,7 +2294,7 @@ "id": "bf492d17", "metadata": {}, "source": [ - "A server call, one the provider ran itself, has no result message of its own, so it renders as a completed block, with the call's `text` as the result when the provider gave one. Re-parsing gives a call and result pair with `server` kept, so a stored conversation replays the call as an ordinary tool call, and nothing tries to run it again:" + "A server call, one the provider ran itself, has no result message of its own, so it renders as a completed block, with the call's `text` as the result when the provider gave one. Re-parsing gives a call and result pair with `server` kept, so a stored conversation replays the call as an ordinary tool call, and nothing tries to run it again. A result that is markdown by construction, an `MdStr`, is marked `md` on the block and comes back as one:" ] }, { @@ -2301,12 +2304,12 @@ "metadata": {}, "outputs": [], "source": [ - "srv = Msg('assistant', [Text('Let me check.'), ToolUse(id='s1', name='web_search', arguments={'query': 'otters'}, server=True, text='Otter: https://example.com/otter')])\n", + "srv = Msg('assistant', [Text('Let me check.'), ToolUse(id='s1', name='web_search', arguments={'query': 'otters'}, server=True, text=MdStr('Otter: https://example.com/otter'))])\n", "s = hist2fmt([srv])\n", "h3 = fmt2hist(s)\n", "test_eq([m.role for m in h3[:2]], ['assistant', 'tool'])\n", "test_eq((h3[0].content[1].server, h3[1].content[0].server), (True, True))\n", - "test_eq(h3[1].content[0].text, 'Otter: https://example.com/otter')\n", + "test_eq((h3[1].content[0].text, type(h3[1].content[0].text)), ('Otter: https://example.com/otter', MdStr))\n", "test_eq(hist2fmt(h3[:2]), s)\n", "Markdown(s)" ] diff --git a/nbs/01_dialog.ipynb b/nbs/01_dialog.ipynb index 566f4b6..9cb4c84 100644 --- a/nbs/01_dialog.ipynb +++ b/nbs/01_dialog.ipynb @@ -559,10 +559,10 @@ { "data": { "text/markdown": [ - "59c74047:n:in ⇒ out(3)" + "64ace59d:n:in ⇒ out(3)" ], "text/plain": [ - "Message(id='59c74047', content='in', output='out', msg_type='note')" + "Message(id='64ace59d', content='in', output='out', msg_type='note')" ] }, "execution_count": null, @@ -584,9 +584,9 @@ { "data": { "text/plain": [ - "ac71fcba:p:q\n", + "4fdd721b:p:q\n", "> Edited reply\n", - "c398e29c:c:x=1 ⇒ out(601)" + "95575dfa:c:x=1 ⇒ out(601)" ] }, "execution_count": null, @@ -771,10 +771,10 @@ { "data": { "text/markdown": [ - "76fab414:c:8*8 ⇒ out(103)" + "d6b8bf64:c:8*8 ⇒ out(103)" ], "text/plain": [ - "Message(id='76fab414', content='8*8', output=[{'output_type': 'execute_result', 'metadata': {}, 'data': {'text/plain': '64'}, 'execution_count': 1}], msg_type='code')" + "Message(id='d6b8bf64', content='8*8', output=[{'output_type': 'execute_result', 'metadata': {}, 'data': {'text/plain': '64'}, 'execution_count': 1}], msg_type='code')" ] }, "execution_count": null, @@ -2325,7 +2325,7 @@ "id": "7e642860", "metadata": {}, "source": [ - "Solveit replies carry tool calls and usage stats as fenced JSON wire blocks (fastllm's format: a ```` ```json {.tool} ```` fence holding `{id, name, args, result}`, and ```` ```json {.usage} ```` holding usage fields). `fmt_tools` rewrites those blocks into folded `::: details` display markdown with a code-span label derived from the data, for renderers (solveit, viewmd) whose html exporter lowers `div.details` to a native `
` element. It is a pure formatter: parsing stays with the emitting library, and unparseable blocks are left untouched." + "Solveit replies carry tool calls and usage stats as fenced JSON wire blocks (fastllm's format: a ```` ```json {.tool} ```` fence holding `{id, name, args, result}`, and ```` ```json {.usage} ```` holding usage fields). `fmt_tools` rewrites those blocks into folded `::: details` display markdown with a code-span label derived from the data, for renderers (solveit, viewmd) whose html exporter lowers `div.details` to a native `
` element. A code tool shows its code and its output in fences. Any other tool shows its args as JSON and its result fenced, unless the block carries `\"md\": true`, the mark a tool result that is markdown by construction (a page fetch, a search) travels with as an `MdStr`; only then is it rendered as markdown. It is a pure formatter: parsing stays with the emitting library, and unparseable blocks are left untouched.\n" ] }, { @@ -2354,7 +2354,7 @@ "_code_args = {'py':'code', 'python':'code', 'bash':'cmd'}\n", "\n", "def tool_md(d):\n", - " \"Display markdown for one parsed `{.tool}` block: a folded details div labeled `func(params)→result`, code tools shown as code\"\n", + " \"Display markdown for one parsed `{.tool}` block: a folded details div labeled `func(params)→result`, code shown as code, the result fenced unless the block marks it `md`\"\n", " params = ', '.join(f\"{k}={_fmt_param(v)}\" for k,v in (d.get('args') or {}).items())\n", " res = d.get('result')\n", " tail = f\"→{_fmt_param(res)}\" if res not in (None, '') else ''\n", @@ -2363,7 +2363,9 @@ " if code is not None:\n", " body = f\"Code:\\n{fenced(str(code), d.get('name'))}\"\n", " if str(res or '').strip(): body += f\"\\n\\nOutput:\\n\\n{fenced(str(res))}\"\n", - " else: body = fenced(dumps(d, indent=2, ensure_ascii=False), 'json')\n", + " else:\n", + " body = f\"Args:\\n{fenced(dumps(d.get('args') or {}, indent=2, ensure_ascii=False), 'json')}\"\n", + " if str(res or '').strip(): body += f\"\\n\\n{res}\" if d.get('md') else f\"\\n\\nOutput:\\n\\n{fenced(str(res))}\"\n", " return fenced(f\"## {label}\\n\\n{body}\", ' {.details .tool-usage-details}', ch=':')\n", "\n", "def usage_md(d):\n", @@ -2403,7 +2405,9 @@ "assert 'Code:\\n```py\\n1+1\\n```' in disp # code tools show code + output fences\n", "assert 'Output:' in disp\n", "other = fmt_tools('```json {.tool}\\n{\"id\": \"t3\", \"name\": \"web\", \"args\": {\"url\": \"x\"}, \"result\": \"ok\"}\\n```')\n", - "assert '```json' in other # non-code tools show the JSON\n", + "assert '```json\\n{\\n \"url\": \"x\"\\n}\\n```' in other and 'Output:\\n\\n```\\nok\\n```' in other # other tools show their args as JSON and the result fenced\n", + "md = fmt_tools('```json {.tool}\\n{\"id\": \"t5\", \"name\": \"read_url\", \"args\": {\"url\": \"x\"}, \"result\": \"# Title\\\\n\\\\nBody\", \"md\": true}\\n```')\n", + "assert '\\n\\n# Title\\n\\nBody\\n' in md and '```\\n# Title' not in md # a result marked `md` renders as markdown\n", "test_eq(fmt_tools(disp), disp) # idempotent: display form has no wire blocks\n", "uwire = '```json {.usage}\\n{\"model\": \"m\", \"total_tokens\": 150, \"cost\": 0.0123}\\n```'\n", "udisp = fmt_tools(uwire)\n", @@ -2798,7 +2802,7 @@ { "data": { "text/plain": [ - "c2599acf: ok\n", + "d9705aed: ok\n", "run: 1 msg ok" ] }, @@ -2833,7 +2837,7 @@ { "data": { "text/plain": [ - "c2599acf: NameError: name 'y' is not defined\n", + "d9705aed: NameError: name 'y' is not defined\n", "run: 0 msgs ok, 1 failed" ] }, @@ -2866,7 +2870,7 @@ "data": { "text/plain": [ "a9a03f99: ok\n", - "009b926b: ZeroDivisionError: division by zero\n", + "197f3ee9: ZeroDivisionError: division by zero\n", "run: 1 msg ok, 1 failed" ] }, @@ -2901,9 +2905,9 @@ "data": { "text/plain": [ "a9a03f99: ok\n", - "009b926b: ZeroDivisionError: division by zero\n", + "197f3ee9: ZeroDivisionError: division by zero\n", "9ed1a7ce: ok\n", - "c2599acf: ok\n", + "d9705aed: ok\n", "run: 3 msgs ok, 1 failed" ] },