ExAthena.Loop.FenceFilter (ExAthena v0.17.0)

Copy Markdown View Source

Pure streaming filter that removes ~~~tool_call ... ~~~ and ~~~tool_result ... ~~~ fenced blocks from chunked assistant text.

Non-native-tool-call providers emit tool calls as text fences (the ExAthena.ToolCalls.TextTagged protocol, ~~~tool_call\s*\n ... \n~~~). Some models additionally roleplay the result side, echoing ~~~tool_result fences (often with invented tool output) into their visible text. When this output streams to hosts as {:content, chunk} deltas, the raw fence markup leaks into the transcript — the loop already surfaces real tool activity as {:tool_call, ...} / {:tool_result, ...} events, so both fence kinds must be stripped from the text stream.

Chunks may split a marker at any byte boundary, so the filter holds the longest chunk tail that could still become a marker as pending text:

  • Outside a fence, a tail like "~~", "~~~tool_" (shared by both markers), "~~~tool_c" (only ~~~tool_call), or "~~~tool_r" (only ~~~tool_result) — any strict prefix of either open marker — is held instead of emitted. A complete ~~~tool_call / ~~~tool_result is also held while only optional whitespace follows — the open marker is <marker>\s*\n (mirroring the TextTagged regex), so until the newline arrives we can't tell a fence from literal text like ~~~tool_caller. If a non-whitespace char arrives first, the held text is released as visible.
  • Inside a fence, everything is swallowed until the close marker \n~~~; a tail like "\n~" is held the same way.

flush/1 ends the stream: pending text held outside a fence turned out to be real text and is returned; an unterminated fence is swallowed entirely (half a fence is still tool markup, and showing it is worse).

Summary

Functions

End of stream. Releases pending text that never completed into an open marker; swallows the contents of an unterminated fence.

Create a fresh filter state (outside any fence, nothing pending).

Feed a chunk through the filter. Returns {new_state, visible} where visible is the text safe to show (may be "" while inside a fence or while holding a possible partial marker).

Types

state()

@opaque state()

Functions

flush(arg)

@spec flush(state()) :: binary()

End of stream. Releases pending text that never completed into an open marker; swallows the contents of an unterminated fence.

new()

@spec new() :: state()

Create a fresh filter state (outside any fence, nothing pending).

push(arg, chunk)

@spec push(state(), binary()) :: {state(), binary()}

Feed a chunk through the filter. Returns {new_state, visible} where visible is the text safe to show (may be "" while inside a fence or while holding a possible partial marker).