feat(core,backends,frontends,storage): conversations, inject queue, session pool, gateway mcp tools, api frontend
This commit is contained in:
@@ -13,7 +13,13 @@ Concurrency model: an in-memory ``set[Path]`` of files currently in
|
||||
flight. Two concurrent requests for the same file → the second gets
|
||||
409. The set is single-process (one gateway instance) — that's by
|
||||
design; the markdown frontend is the only writer in its vault from
|
||||
the gateway side.
|
||||
the gateway side. The turn itself runs through ``core/conversations``
|
||||
(one turn per conversation, ``running_turn`` in the DB), so a message
|
||||
posted to the same conversation via ``/api`` waits its turn.
|
||||
|
||||
A chat file is a ``deep`` conversation bound as
|
||||
``(markdown, <vault-relative path>)``; frontmatter carries only ``agent``
|
||||
and ``conversation_id`` (§3.10), tool calls are never rendered.
|
||||
|
||||
Cross-frontend logging: when ``log_all_chats=True``, ``configure()``
|
||||
registers a handler on ``runtime.turn_log_handlers`` so every other
|
||||
@@ -24,7 +30,6 @@ shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
@@ -33,7 +38,7 @@ import tempfile
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import aiofile
|
||||
from anthropic.types import RawContentBlockStopEvent
|
||||
@@ -44,29 +49,28 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from beaver_gateway.core import audit
|
||||
from beaver_gateway.core.conversation_store import (
|
||||
diff_and_fork,
|
||||
load_conversation,
|
||||
load_messages,
|
||||
mint_conversation,
|
||||
rewrite_messages,
|
||||
set_session_id,
|
||||
)
|
||||
from beaver_gateway.core.turn_capture import TurnCapture
|
||||
from beaver_gateway.core.turn_record import TurnRecord
|
||||
from beaver_gateway.frontends._accumulate import StreamAccumulator
|
||||
from beaver_gateway.frontends._auth import require_token
|
||||
from beaver_gateway.frontends._sse import (
|
||||
KEEPALIVE,
|
||||
SSE_HEADERS,
|
||||
events_with_heartbeat,
|
||||
sse_pack,
|
||||
)
|
||||
from beaver_gateway.frontends.base import Frontend
|
||||
from beaver_gateway.frontends.markdown import parser, renderer
|
||||
from beaver_gateway.frontends.markdown.crossfront import (
|
||||
CrossFrontendLogger,
|
||||
fingerprint_messages,
|
||||
)
|
||||
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
from anthropic.types import MessageParam
|
||||
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
|
||||
_log = logging.getLogger("beaver_gateway.frontends.markdown")
|
||||
@@ -89,12 +93,7 @@ _STREAM_FLUSH_DEBOUNCE = 0.4
|
||||
# disk round-trip).
|
||||
_SSE_FLUSH_DEBOUNCE = 0.1
|
||||
|
||||
# Interval between SSE comment-frames sent when the backend is silent
|
||||
# (e.g. claude is mid-thinking on a large context). The Obsidian plugin
|
||||
# and any intermediate proxies will hold the connection open as long as
|
||||
# bytes keep flowing; a comment-frame is the cheapest legal SSE keepalive.
|
||||
# Set well under typical proxy/client idle timeouts (60s).
|
||||
_SSE_HEARTBEAT_INTERVAL = 15.0
|
||||
FRONTEND = "markdown"
|
||||
|
||||
|
||||
class MarkdownFrontend(Frontend):
|
||||
@@ -134,6 +133,9 @@ class MarkdownFrontend(Frontend):
|
||||
self._crossfront: CrossFrontendLogger | None = None
|
||||
|
||||
def configure(self, runtime: GatewayRuntime) -> None:
|
||||
if runtime.conversations is None:
|
||||
msg = "MarkdownFrontend needs runtime.conversations"
|
||||
raise RuntimeError(msg)
|
||||
self._runtime = runtime
|
||||
self.vault_path.mkdir(parents=True, exist_ok=True)
|
||||
if self.log_all_chats:
|
||||
@@ -286,17 +288,7 @@ class MarkdownFrontend(Frontend):
|
||||
self._busy.discard(file_path)
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
# nginx default-buffers SSE bodies; this header tells
|
||||
# both nginx and uvicorn-behind-proxy to flush as we
|
||||
# write. Harmless if the deployment has no reverse
|
||||
# proxy in front.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
gen(), media_type="text/event-stream", headers=SSE_HEADERS
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -384,16 +376,19 @@ class MarkdownFrontend(Frontend):
|
||||
# stored history, and feed the aligned messages to the backend
|
||||
# - see ``core/conversation_store.py`` for the full rationale.
|
||||
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
||||
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
|
||||
runtime=runtime,
|
||||
metadata=parsed.metadata,
|
||||
agent_name=agent.name,
|
||||
file_path=file_path,
|
||||
)
|
||||
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
|
||||
capture = TurnCapture()
|
||||
events = backend.complete(
|
||||
agent=agent,
|
||||
events = runtime.conversations.turn(
|
||||
conv,
|
||||
messages=outcome.messages,
|
||||
system=None,
|
||||
origin="user",
|
||||
capture=capture,
|
||||
**_session_options(conv, outcome.divergence_index),
|
||||
use_session=outcome.divergence_index is None,
|
||||
)
|
||||
try:
|
||||
message = await self._stream_to_file(
|
||||
@@ -422,7 +417,7 @@ class MarkdownFrontend(Frontend):
|
||||
|
||||
await self._persist_canonical_history(
|
||||
runtime=runtime,
|
||||
conversation_id=conv.id,
|
||||
conversation_id=cast("int", conv.id),
|
||||
persist_messages=outcome.persist_messages,
|
||||
new_user_text=parsed.turns[-1].text,
|
||||
capture=capture,
|
||||
@@ -487,7 +482,7 @@ class MarkdownFrontend(Frontend):
|
||||
elif content_override is None:
|
||||
file_text = await _read_or_empty(file_path)
|
||||
else:
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"error",
|
||||
{
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
@@ -503,7 +498,7 @@ class MarkdownFrontend(Frontend):
|
||||
default=self.default_agent,
|
||||
)
|
||||
if not agent_name:
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"error",
|
||||
{
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
@@ -516,7 +511,7 @@ class MarkdownFrontend(Frontend):
|
||||
return
|
||||
|
||||
if not parsed.messages:
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"done",
|
||||
{
|
||||
"status": "nothing_to_do",
|
||||
@@ -527,7 +522,7 @@ class MarkdownFrontend(Frontend):
|
||||
return
|
||||
|
||||
if parser.last_role(parsed.messages) == "assistant":
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"done",
|
||||
{
|
||||
"status": "nothing_to_do",
|
||||
@@ -539,7 +534,7 @@ class MarkdownFrontend(Frontend):
|
||||
|
||||
agent = runtime.agents.get(agent_name)
|
||||
if agent is None:
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"error",
|
||||
{
|
||||
"status_code": status.HTTP_404_NOT_FOUND,
|
||||
@@ -549,7 +544,7 @@ class MarkdownFrontend(Frontend):
|
||||
return
|
||||
backend = runtime.backends.get(agent.name)
|
||||
if backend is None:
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"error",
|
||||
{
|
||||
"status_code": status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
@@ -575,7 +570,10 @@ class MarkdownFrontend(Frontend):
|
||||
)
|
||||
|
||||
conv, conv_external_id, stored_msgs = await self._resolve_conversation(
|
||||
runtime=runtime, metadata=parsed.metadata, agent_name=agent.name
|
||||
runtime=runtime,
|
||||
metadata=parsed.metadata,
|
||||
agent_name=agent.name,
|
||||
file_path=file_path,
|
||||
)
|
||||
_log.info(
|
||||
"chat/stream: file=%s conv_external_id=%s conv_id=%d "
|
||||
@@ -602,12 +600,12 @@ class MarkdownFrontend(Frontend):
|
||||
agent.name,
|
||||
conv.session_id,
|
||||
)
|
||||
events = backend.complete(
|
||||
agent=agent,
|
||||
events = runtime.conversations.turn(
|
||||
conv,
|
||||
messages=outcome.messages,
|
||||
system=None,
|
||||
origin="user",
|
||||
capture=capture,
|
||||
**_session_options(conv, outcome.divergence_index),
|
||||
use_session=outcome.divergence_index is None,
|
||||
)
|
||||
|
||||
acc = StreamAccumulator()
|
||||
@@ -624,13 +622,9 @@ class MarkdownFrontend(Frontend):
|
||||
return _reattach_frontmatter(parsed.metadata, new_body)
|
||||
|
||||
try:
|
||||
async for ev in _events_with_heartbeat(events):
|
||||
async for ev in events_with_heartbeat(events):
|
||||
if ev is None:
|
||||
# Backend is quiet (claude mid-thinking, MCP slow,
|
||||
# whatever). SSE comment-frame keeps the TCP socket
|
||||
# warm so the plugin / uvicorn / any reverse proxy
|
||||
# doesn't time the request out before we finish.
|
||||
yield b": keepalive\n\n"
|
||||
yield KEEPALIVE
|
||||
continue
|
||||
acc.feed(ev)
|
||||
now = time.monotonic()
|
||||
@@ -643,7 +637,7 @@ class MarkdownFrontend(Frontend):
|
||||
# render to the same prefix as before they closed
|
||||
# (we don't surface the tool-call args in markdown).
|
||||
if payload is not None and payload != last_payload:
|
||||
yield _sse_pack("delta", {"new_content": payload})
|
||||
yield sse_pack("delta", {"new_content": payload})
|
||||
last_payload = payload
|
||||
last_flush = now
|
||||
except Exception as exc: # noqa: BLE001 — wire any backend failure as an SSE error frame
|
||||
@@ -662,7 +656,7 @@ class MarkdownFrontend(Frontend):
|
||||
await _write_atomic(
|
||||
file_path, _reattach_frontmatter(parsed.metadata, new_body)
|
||||
)
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"error",
|
||||
{
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -684,7 +678,7 @@ class MarkdownFrontend(Frontend):
|
||||
|
||||
await self._persist_canonical_history(
|
||||
runtime=runtime,
|
||||
conversation_id=conv.id,
|
||||
conversation_id=cast("int", conv.id),
|
||||
persist_messages=outcome.persist_messages,
|
||||
new_user_text=parsed.turns[-1].text,
|
||||
capture=capture,
|
||||
@@ -704,7 +698,7 @@ class MarkdownFrontend(Frontend):
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("turn_log_handler raised; continuing")
|
||||
|
||||
yield _sse_pack(
|
||||
yield sse_pack(
|
||||
"done",
|
||||
{
|
||||
"status": "ok",
|
||||
@@ -795,71 +789,53 @@ class MarkdownFrontend(Frontend):
|
||||
rendered = renderer.render_assistant_message(message)
|
||||
new_body = renderer.append_to_body(parsed.body, rendered)
|
||||
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
|
||||
# Recompute fingerprint so a future cross-frontend hit on this
|
||||
# same conversation can find it. Stored as hex string in
|
||||
# frontmatter — only the markdown frontend reads it.
|
||||
assistant_param: MessageParam = {
|
||||
"role": "assistant",
|
||||
"content": _flatten_assistant_text(message),
|
||||
}
|
||||
updated_messages: list[MessageParam] = [*parsed.messages, assistant_param]
|
||||
updated_metadata = dict(parsed.metadata)
|
||||
updated_metadata.pop("fingerprint", None)
|
||||
updated_metadata["agent"] = agent_name
|
||||
updated_metadata["conversation_id"] = conv_external_id
|
||||
updated_metadata["fingerprint"] = fingerprint_messages(updated_messages)
|
||||
new_content = _reattach_frontmatter(updated_metadata, new_body)
|
||||
if write_disk:
|
||||
await _write_atomic(file_path, new_content)
|
||||
return new_content
|
||||
|
||||
async def _resolve_conversation(
|
||||
self, *, runtime: GatewayRuntime, metadata: dict[str, Any], agent_name: str
|
||||
) -> tuple[Any, str, list[dict[str, Any]]]:
|
||||
"""Resolve the conversation row + stored messages for this request.
|
||||
self,
|
||||
*,
|
||||
runtime: GatewayRuntime,
|
||||
metadata: dict[str, Any],
|
||||
agent_name: str,
|
||||
file_path: Path,
|
||||
) -> tuple[Conversation, str, list[dict[str, Any]]]:
|
||||
"""Resolve the ``deep`` conversation for this file + its stored messages.
|
||||
|
||||
Looks up by frontmatter ``conversation_id``, mints a new row if
|
||||
missing, and returns ``(conv, external_id, stored_messages)``.
|
||||
``conv.id`` is guaranteed non-None because both
|
||||
``load_conversation`` (after refresh on a committed row) and
|
||||
``mint_conversation`` (post-commit refresh) populate it. We
|
||||
coerce with a runtime check so the rest of the handler can
|
||||
treat it as ``int``.
|
||||
Frontmatter ``conversation_id`` wins; a file that lost it is found
|
||||
by its visible ``(markdown, path)`` binding; otherwise a new
|
||||
conversation is created. The binding follows the file: a moved
|
||||
chat re-binds to its new path on the next turn.
|
||||
"""
|
||||
conversations = runtime.conversations
|
||||
rel = file_path.relative_to(self.vault_path).as_posix()
|
||||
raw = metadata.get("conversation_id")
|
||||
lookup_id = raw if isinstance(raw, str) and raw else None
|
||||
conv = await conversations.get(raw) if isinstance(raw, str) and raw else None
|
||||
if conv is None:
|
||||
conv = await conversations.find_bound(frontend=FRONTEND, external_id=rel)
|
||||
if conv is None:
|
||||
conv = await conversations.create(
|
||||
kind="deep", agent=agent_name, origin=FRONTEND, title=file_path.stem
|
||||
)
|
||||
_log.info("minted conversation %s for %s", conv.external_id, rel)
|
||||
bound = [
|
||||
b
|
||||
for b in await conversations.bindings(conv)
|
||||
if b.frontend == FRONTEND and b.visible and b.external_id == rel
|
||||
]
|
||||
if not bound:
|
||||
await conversations.bind(conv, frontend=FRONTEND, external_id=rel)
|
||||
await conversations.touch_user(conv)
|
||||
if conv.id is None:
|
||||
msg = "conversation row missing primary key after commit"
|
||||
raise RuntimeError(msg)
|
||||
async with runtime.db.session() as session:
|
||||
conv = None
|
||||
if lookup_id is not None:
|
||||
conv = await load_conversation(
|
||||
session, frontend="markdown", external_id=lookup_id
|
||||
)
|
||||
if conv is None:
|
||||
_log.info(
|
||||
"_resolve_conversation: frontmatter conv_id=%s "
|
||||
"not found in DB, will mint new",
|
||||
lookup_id,
|
||||
)
|
||||
else:
|
||||
_log.info(
|
||||
"_resolve_conversation: LOADED existing conv "
|
||||
"id=%d external_id=%s",
|
||||
conv.id or -1,
|
||||
conv.external_id,
|
||||
)
|
||||
if conv is None:
|
||||
conv = await mint_conversation(
|
||||
session, frontend="markdown", agent_name=agent_name
|
||||
)
|
||||
_log.info(
|
||||
"_resolve_conversation: MINTED new conv "
|
||||
"id=%d external_id=%s agent=%s",
|
||||
conv.id or -1,
|
||||
conv.external_id,
|
||||
agent_name,
|
||||
)
|
||||
if conv.id is None:
|
||||
msg = "conversation row missing primary key after commit"
|
||||
raise RuntimeError(msg)
|
||||
stored = await load_messages(session, conversation_id=conv.id)
|
||||
return conv, conv.external_id, stored
|
||||
|
||||
@@ -894,12 +870,6 @@ class MarkdownFrontend(Frontend):
|
||||
await rewrite_messages(
|
||||
session, conversation_id=conversation_id, messages=canonical
|
||||
)
|
||||
if capture.session_id is not None:
|
||||
await set_session_id(
|
||||
session,
|
||||
conversation_id=conversation_id,
|
||||
session_id=capture.session_id,
|
||||
)
|
||||
_log.info(
|
||||
"_persist_canonical_history: conv_id=%d DB committed", conversation_id
|
||||
)
|
||||
@@ -927,71 +897,6 @@ class MarkdownFrontend(Frontend):
|
||||
# ---- module-level utilities ----------------------------------------------
|
||||
|
||||
|
||||
def _session_options(conv: Any, divergence_index: int | None) -> dict[str, Any]:
|
||||
"""Backend options that pin the turn to the conversation's live session.
|
||||
|
||||
A divergence means the file's history no longer matches what the
|
||||
session saw, so the stored ``session_id`` is withheld and the backend
|
||||
seeds a fresh one from the aligned messages.
|
||||
"""
|
||||
return {
|
||||
"conversation_id": conv.external_id,
|
||||
"session_id": conv.session_id if divergence_index is None else None,
|
||||
}
|
||||
|
||||
|
||||
async def _events_with_heartbeat(
|
||||
events: AsyncIterator[Any], interval: float = _SSE_HEARTBEAT_INTERVAL
|
||||
) -> AsyncIterator[Any]:
|
||||
"""Wrap an async event stream with idle-time heartbeat markers.
|
||||
|
||||
Yields ``None`` every ``interval`` seconds during silence; real
|
||||
events pass through unchanged. When the wrapped iterator is
|
||||
exhausted, this generator returns. Cancellation propagates: if the
|
||||
outer scope is cancelled we cancel the pending ``__anext__`` task
|
||||
instead of leaving it dangling.
|
||||
"""
|
||||
src = events.__aiter__()
|
||||
next_task: asyncio.Task[Any] | None = None
|
||||
try:
|
||||
while True:
|
||||
# Reuse the in-flight task across timeouts. Spawning a fresh
|
||||
# ``__anext__()`` while the previous one is still pending
|
||||
# puts two consumers on the same async generator — that
|
||||
# raises ``RuntimeError: anext(): asynchronous generator is
|
||||
# already running``.
|
||||
if next_task is None:
|
||||
next_task = asyncio.ensure_future(src.__anext__())
|
||||
done, _pending = await asyncio.wait({next_task}, timeout=interval)
|
||||
if not done:
|
||||
yield None
|
||||
continue
|
||||
task = next_task
|
||||
next_task = None
|
||||
try:
|
||||
result = task.result()
|
||||
except StopAsyncIteration:
|
||||
return
|
||||
yield result
|
||||
finally:
|
||||
if next_task is not None and not next_task.done():
|
||||
next_task.cancel()
|
||||
with contextlib.suppress(BaseException):
|
||||
await next_task
|
||||
|
||||
|
||||
def _sse_pack(event: str, data: dict[str, Any]) -> bytes:
|
||||
r"""Format one Server-Sent Event frame.
|
||||
|
||||
Uses named events (``event: <name>``) so the plugin can dispatch on
|
||||
type without parsing JSON discriminators. ``ensure_ascii=False`` so
|
||||
multibyte content rides through verbatim instead of becoming
|
||||
``\uXXXX`` blobs that bloat the wire.
|
||||
"""
|
||||
body = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: {event}\ndata: {body}\n\n".encode()
|
||||
|
||||
|
||||
async def _read_or_empty(path: Path) -> str:
|
||||
"""Return file contents, or empty string if the file doesn't exist."""
|
||||
# ``path.exists()`` here is a metadata stat — microseconds — and
|
||||
@@ -1083,21 +988,6 @@ def _fallback_synthesized(message: Any) -> list[dict[str, Any]]:
|
||||
return [{"role": "assistant", "content": content}]
|
||||
|
||||
|
||||
def _flatten_assistant_text(message: Any) -> str:
|
||||
"""Pull all text blocks from an assistant ``Message`` and join them.
|
||||
|
||||
Used when we need the assistant content as a plain string for
|
||||
fingerprinting / equality with a parser-shaped history (parser
|
||||
already drops thinking + tool_use from assistant turns).
|
||||
"""
|
||||
chunks = [
|
||||
getattr(block, "text", "") or ""
|
||||
for block in getattr(message, "content", ())
|
||||
if getattr(block, "type", None) == "text"
|
||||
]
|
||||
return "\n\n".join(c for c in chunks if c)
|
||||
|
||||
|
||||
def _render_error_block(exc: BaseException) -> str:
|
||||
"""Render a backend failure as an Assistant turn with a ``[!error]-`` callout."""
|
||||
msg = str(exc) or exc.__class__.__name__
|
||||
|
||||
@@ -9,11 +9,10 @@ from other frontends).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock
|
||||
from anthropic.types import Message, TextBlock, ThinkingBlock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
@@ -61,9 +60,8 @@ def render_assistant_message(message: Message) -> str:
|
||||
|
||||
* ``ThinkingBlock`` → ``> [!thinking]-`` collapsed callout
|
||||
* ``TextBlock`` → plain text (the spoken answer)
|
||||
* ``ToolUseBlock`` → ``> [!tool]- <name>`` callout with the ``input``
|
||||
JSON quoted inside. Tool *results* are not persisted — see
|
||||
module docstring on ``parser.py`` for why.
|
||||
* ``ToolUseBlock`` → nothing (§3.10: tool calls never reach the file;
|
||||
"what the agent is doing" is the activity panel fed by SSE)
|
||||
|
||||
Blank lines separate adjacent blocks; trailing newline guarantees
|
||||
the next ``---`` / ``### User:`` marker lands on its own line.
|
||||
@@ -147,10 +145,7 @@ def _render_block(block: object) -> Iterable[str]:
|
||||
if isinstance(block, ThinkingBlock):
|
||||
yield from _render_thinking(block.thinking or "")
|
||||
return
|
||||
if isinstance(block, ToolUseBlock):
|
||||
yield from _render_tool_use(block)
|
||||
return
|
||||
# Unknown block type — skip silently rather than corrupting the file.
|
||||
# Tool-use blocks and unknown block types never reach the file.
|
||||
|
||||
|
||||
def _render_thinking(text: str) -> Iterable[str]:
|
||||
@@ -159,17 +154,6 @@ def _render_thinking(text: str) -> Iterable[str]:
|
||||
yield f"> {line}" if line else ">"
|
||||
|
||||
|
||||
def _render_tool_use(block: ToolUseBlock) -> Iterable[str]:
|
||||
title = summarize_tool_input(block.name, block.input)
|
||||
yield f"> [!tool]- {title}"
|
||||
yield "> **input:**"
|
||||
yield "> ```json"
|
||||
pretty = json.dumps(block.input, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
for line in pretty.splitlines():
|
||||
yield f"> {line}" if line else ">"
|
||||
yield "> ```"
|
||||
|
||||
|
||||
def adaptive_fence(content: str) -> str:
|
||||
"""Return a backtick fence at least one longer than the longest run in ``content``.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user