Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions effectful/handlers/llm/harness/durability/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
call_tool,
call_user,
)
from effectful.handlers.llm.harness.serialization import ToolCallID
from effectful.handlers.llm.harness.serialization import (
ToolCallID,
_is_empty_text_block,
)
from effectful.ops.semantics import fwd, handler
from effectful.ops.syntax import ObjectInterpretation, implements
from effectful.ops.types import Operation
Expand All @@ -34,14 +37,11 @@ def get_history(cls) -> collections.abc.MutableSequence[Message]:

@classmethod
def append_message(cls, message: Message) -> None:
"""Append `message` to the ambient history, if it is legal where it lands.

Both checks are about position rather than content, which is why they sit
here: every message the harness records passes through this method,
including the ones a failed attempt records on its way out, and those are
the ones that get a history into a shape no provider will accept.
"""
"""Append `message` to the ambient history, if it is legal where it lands."""
history = cls.get_history()
assert cls._carries_no_empty_block(message), (
f"a message may not carry an empty text block: {message}"
)
if message["role"] == "tool":
assert cls._tool_call_answers_request(message, history)
elif message["role"] == "assistant":
Expand All @@ -51,6 +51,14 @@ def append_message(cls, message: Message) -> None:
)
history.append(message)

@staticmethod
def _carries_no_empty_block(message: Message) -> bool:
"""Whether `message` is free of the empty text blocks Anthropic rejects."""
content = message.get("content")
return not isinstance(content, list) or not any(
_is_empty_text_block(block) for block in content
)

@staticmethod
def _assistant_speaks_in_turn(
history: collections.abc.Sequence[Message],
Expand Down
5 changes: 4 additions & 1 deletion effectful/handlers/llm/harness/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,11 @@ def call_assistant[T](
result = None
if not tool_calls:
serialized_result = message.get("content") or message.get("reasoning_content")
assert isinstance(serialized_result, str)
try:
assert isinstance(serialized_result, str), (
f"the model replied with neither content nor a tool call "
f"(finish_reason={choice.finish_reason!r})"
)
# A text answer is the model's own prose, and anything else is JSON
# shaped like the response format. Both are boxed and validated
# through the same model, so whatever the return annotation carries
Expand Down
58 changes: 39 additions & 19 deletions effectful/handlers/llm/harness/provision/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ResultDecodingError,
completion,
)
from effectful.handlers.llm.harness.serialization import _is_empty_text_block
from effectful.ops.semantics import fwd
from effectful.ops.syntax import ObjectInterpretation, implements

Expand All @@ -28,32 +29,43 @@ def __init__(self, model="gpt-4o", **config):
}

@staticmethod
def _mark(msg: Message) -> Message:
"""`msg`, with a `cache_control` breakpoint on its last content block.
def _mark(msg: Message) -> Message | None:
"""`msg`, with a `cache_control` breakpoint on its last non-empty content
block, or `None` if it has no block to put one on.

A message whose content is a plain string -- which the assembled system
prompt is not, but a hand-written or externally supplied message may be
-- takes the message-level key instead, the only form litellm reads a
breakpoint from for string content.

Empty blocks are skipped: Anthropic answers ``cache_control cannot be
set for empty text blocks``. An already-marked message returns itself.
"""
content = msg.get("content")
if isinstance(content, str):
if not content:
return None
return typing.cast(Message, {**msg, "cache_control": {"type": "ephemeral"}})
if not isinstance(content, list) or not content:
return msg
last_block = content[-1]
if not isinstance(last_block, dict) or "cache_control" in last_block:
return msg
return typing.cast(
Message,
{
**msg,
"content": [
*content[:-1],
{**last_block, "cache_control": {"type": "ephemeral"}},
],
},
)
if not isinstance(content, list):
return None
for i in reversed(range(len(content))):
block = content[i]
if not isinstance(block, dict) or _is_empty_text_block(block):
continue
if "cache_control" in block:
return msg
return typing.cast(
Message,
{
**msg,
"content": [
*content[:i],
{**block, "cache_control": {"type": "ephemeral"}},
*content[i + 1 :],
],
},
)
return None

def _add_cache_control(
self,
Expand Down Expand Up @@ -81,15 +93,23 @@ def _add_cache_control(
Returns a new list, leaving the caller's messages untouched, so these
transport-level annotations never reach the stored history -- and so
never reach an `Agent`'s checkpointed transcript.

If `_mark` declines a message, the scan continues to the one before it.
"""
out = list(messages)
for i in reversed(range(len(out))):
if out[i]["role"] in ("user", "tool"):
out[i] = self._mark(out[i])
marked = self._mark(out[i])
if marked is None:
continue
out[i] = marked
break
for i in range(len(out)):
if out[i]["role"] == "system":
out[i] = self._mark(out[i])
# No earlier system message to fall back to.
marked = self._mark(out[i])
if marked is not None:
out[i] = marked
break
return out

Expand Down
52 changes: 39 additions & 13 deletions effectful/handlers/llm/harness/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@
)


def _is_empty_text_block(block: typing.Any) -> bool:
"""Whether `block` is a text block with no text."""
return (
isinstance(block, dict)
and block.get("type") == "text"
and not block.get("text")
)


def _text_blocks(text: str) -> list[OpenAIMessageContentListBlock]:
"""`text` as one content block, or no block at all when `_is_empty_text_block`
would reject it."""
block = ChatCompletionTextObject(type="text", text=text)
return [] if _is_empty_text_block(block) else [block]


@pydantic.validate_call(validate_return=True)
def to_content_blocks(
value: typing.Any,
Expand All @@ -73,17 +89,19 @@ def to_content_blocks(
Inside JSON structures, separators match ``json.dumps`` defaults so that
the linearization law holds for non-string encoded values:
``linearize(to_content_blocks(v)) == json.dumps(v)``.

Every text block goes through `_text_blocks`, so none of them is empty;
Anthropic rejects a request containing one.
"""
if isinstance(value, str):
return [ChatCompletionTextObject(type="text", text=value)]
return _text_blocks(value)

buf: list[str] = []
blocks: list[OpenAIMessageContentListBlock] = []

def flush() -> None:
if buf:
blocks.append(ChatCompletionTextObject(type="text", text="".join(buf)))
buf.clear()
blocks.extend(_text_blocks("".join(buf)))
buf.clear()

def walk(v: typing.Any) -> None:
if isinstance(v, dict) and v.get("type") in CONTENT_BLOCK_TYPES:
Expand Down Expand Up @@ -120,16 +138,18 @@ def format_as_content_blocks(
Format a template applied to arguments into a list of content blocks.
This is similar to str.format() but produces a list of content blocks
instead of a single string, so that non-text content is preserved.

A conversion or format spec runs even on a value that encodes to ``""``, so
``{x!r}`` renders ``''``. Text that formats to nothing produces no block.
"""
formatter = string.Formatter()
parts: list[OpenAIMessageContentListBlock] = []

buf: list[str] = []

def flush_text() -> None:
if buf:
parts.append(ChatCompletionTextObject(type="text", text="".join(buf)))
buf.clear()
parts.extend(_text_blocks("".join(buf)))
buf.clear()

for literal, field_name, format_spec, conversion in formatter.parse(
textwrap.dedent(template)
Expand All @@ -140,19 +160,25 @@ def flush_text() -> None:
if field_name is None:
continue

def formatted(text: str, conversion=conversion, spec=format_spec) -> str:
if conversion:
text = formatter.convert_field(text, conversion)
return formatter.format_field(text, spec or "")

obj, _ = formatter.get_field(field_name, (), env)
encoder: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter(
Encodable[nested_type(obj).value] # type: ignore[misc]
)
encoded_obj = encoder.dump_python(obj, mode="json", context=env)
if isinstance(encoded_obj, str):
# Formatted here rather than through `to_content_blocks`, which
# drops an empty string before a conversion or format spec could
# turn it into something.
buf.append(formatted(encoded_obj))
continue
for part in to_content_blocks(encoded_obj):
if part["type"] == "text":
text = (
formatter.convert_field(part["text"], conversion)
if conversion
else part["text"]
)
buf.append(formatter.format_field(text, format_spec or ""))
buf.append(formatted(part["text"]))
else:
flush_text()
parts.append(part)
Expand Down
Loading
Loading