Skip to content

symfonic.capabilities.memory.families

families

Reading one model reply, whatever provider family produced it.

The legacy extractor normalises a reply with response.content if hasattr(response, "content") else str(response) (symfonic.memory._internal.llm.response_parser). That is right for exactly one family — a LangChain message whose content is a string — and wrong in a way that is hard to see for the rest:

  • An Anthropic reply is a list of blocks. str(list) renders Python reprs, so the JSON scraper downstream mines the transport rather than the answer, and a thinking block's speculation is indistinguishable from the model's conclusion.
  • An OpenAI or Gemini envelope has no content attribute at all, so the fallback stringifies the whole envelope — ids, usage counters, finish reasons — and the first { the scraper finds belongs to none of them.

So this module reads each family's own shape, and refuses the shapes it does not recognise rather than stringifying them. An unreadable reply produces no text and says so; a reply mined out of an envelope produces memories nobody said, which is worse than producing none.

Block kinds that are not the answer (thinking, tool_use, images) are skipped and named in :attr:ProviderReply.ignored, because "the model answered nothing" and "the model answered in a block we dropped" are different diagnoses.

ProviderFamily

Bases: StrEnum

The reply shapes this capability knows how to read.

ProviderReply dataclass

ProviderReply(family: ProviderFamily, text: str = '', ignored: tuple[str, ...] = ())

One model reply, read down to the text the model actually emitted.

readable property

readable: bool

Whether the shape was recognised at all.

json_payload

json_payload(text: str) -> dict[str, Any] | None

The first JSON object in text, or None.

Same brace-matching heuristic the legacy parser uses, with one addition: a payload that parses to something other than an object is refused rather than returned. json.loads on [1, 2] succeeds, and a caller that then asks it for ops gets an AttributeError from inside a post-response stage instead of "the model did not answer in our format".

Source code in src/symfonic/capabilities/memory/families.py
def json_payload(text: str) -> dict[str, Any] | None:
    """The first JSON *object* in ``text``, or ``None``.

    Same brace-matching heuristic the legacy parser uses, with one addition:
    a payload that parses to something other than an object is refused rather
    than returned. ``json.loads`` on ``[1, 2]`` succeeds, and a caller that
    then asks it for ``ops`` gets an ``AttributeError`` from inside a
    post-response stage instead of "the model did not answer in our format".
    """
    start = text.find("{")
    end = text.rfind("}") + 1
    if start < 0 or end <= start:
        return None
    try:
        payload = json.loads(text[start:end])
    except (json.JSONDecodeError, ValueError):
        return None
    return payload if isinstance(payload, dict) else None

read_reply

read_reply(response: Any) -> ProviderReply

Read response into text, or report that its shape is unknown.

Source code in src/symfonic/capabilities/memory/families.py
def read_reply(response: Any) -> ProviderReply:
    """Read ``response`` into text, or report that its shape is unknown."""
    if isinstance(response, str):
        return ProviderReply(family=ProviderFamily.TEXT, text=response)
    if isinstance(response, Mapping):
        return _read_mapping(response)
    content = getattr(response, "content", None)
    if content is None:
        return ProviderReply(family=ProviderFamily.UNKNOWN)
    if isinstance(content, str):
        return ProviderReply(family=ProviderFamily.LANGCHAIN, text=content)
    if _is_block_list(content):
        text, ignored = _read_blocks(content)
        return ProviderReply(family=ProviderFamily.LANGCHAIN, text=text, ignored=ignored)
    return ProviderReply(family=ProviderFamily.UNKNOWN)