From 1c9dfc1e1a4178d3ed4ef694bb0d273903b4b6e1 Mon Sep 17 00:00:00 2001 From: WH-2099 Date: Wed, 21 Oct 2026 09:22:09 +0000 Subject: [PATCH] feat(model-runtime): expose incremental LLM result accumulation --- src/graphon/model_runtime/README.md | 3 ++ .../base/large_language_model.py | 6 ++- .../test_llm_result_accumulator.py | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/model_runtime/test_llm_result_accumulator.py diff --git a/src/graphon/model_runtime/README.md b/src/graphon/model_runtime/README.md index 89f752e6..38b96836 100644 --- a/src/graphon/model_runtime/README.md +++ b/src/graphon/model_runtime/README.md @@ -39,6 +39,9 @@ blocks retain their order and block state; see the [SlimLLM](../dsl/slim/llm.py) shares this aggregation for blocking ordinary and structured output calls, covered by the [Slim tests](../../../tests/dsl/test_slim_llm.py). +Hosts can display chunks while calling `consume(chunk)` on one [LLMResultAccumulator](model_providers/base/large_language_model.py) per invocation. +After consuming the stream, call its `to_result(model=..., prompt_messages=...)` and use the returned `message` for replay or [JSON persistence](../../../tests/model_runtime/test_llm_result_accumulator.py). + [Prompt filtering](../nodes/llm/llm_utils.py) preserves state on supported blocks and assistant messages with state but no content; see the [prompt tests](../../../tests/nodes/llm/test_llm_utils.py). diff --git a/src/graphon/model_runtime/model_providers/base/large_language_model.py b/src/graphon/model_runtime/model_providers/base/large_language_model.py index 9a89ee25..ac2eb602 100644 --- a/src/graphon/model_runtime/model_providers/base/large_language_model.py +++ b/src/graphon/model_runtime/model_providers/base/large_language_model.py @@ -118,7 +118,9 @@ def _merge_tool_call_delta( @dataclass(slots=True) -class _LLMChunkAccumulator: +class LLMResultAccumulator: + """Retain a complete result while a caller consumes and displays each chunk.""" + content: str = "" content_list: list[PromptMessageContentUnionTypes] = field(default_factory=list) usage: LLMUsage = field(default_factory=LLMUsage.empty_usage) @@ -242,7 +244,7 @@ def _build_llm_result_from_chunks( A normalized `LLMResult` assembled from the consumed chunks. """ - accumulator = _LLMChunkAccumulator() + accumulator = LLMResultAccumulator() try: accumulator.consume_all(chunks) except Exception: diff --git a/tests/model_runtime/test_llm_result_accumulator.py b/tests/model_runtime/test_llm_result_accumulator.py new file mode 100644 index 00000000..1e7aff98 --- /dev/null +++ b/tests/model_runtime/test_llm_result_accumulator.py @@ -0,0 +1,49 @@ +from graphon.model_runtime.entities.llm_entities import ( + LLMResultChunk, + LLMResultChunkDelta, +) +from graphon.model_runtime.entities.message_entities import ( + AssistantPromptMessage, + TextPromptMessageContent, +) +from graphon.model_runtime.model_providers.base import large_language_model + + +def test_accumulated_result_preserves_message_through_json() -> None: + accumulator = large_language_model.LLMResultAccumulator() + tool_call = AssistantPromptMessage.ToolCall( + id="call-1", + type="function", + function=AssistantPromptMessage.ToolCall.ToolCallFunction( + name="lookup", + arguments='{"id":9007199254740993}', + ), + ) + block = TextPromptMessageContent(data="", opaque_body={"signature": "sig"}) + messages = [ + AssistantPromptMessage(content="before"), + AssistantPromptMessage( + content=[block], tool_calls=[tool_call], opaque_body=False + ), + AssistantPromptMessage(content="after"), + ] + for index, message in enumerate(messages): + accumulator.consume( + LLMResultChunk( + model="test-model", + delta=LLMResultChunkDelta(index=index, message=message), + ) + ) + + result = accumulator.to_result(model="test-model", prompt_messages=[]) + restored_message = AssistantPromptMessage.model_validate_json( + result.message.model_dump_json() + ) + + assert restored_message.content == [ + TextPromptMessageContent(data="before"), + TextPromptMessageContent(data="", opaque_body={"signature": "sig"}), + TextPromptMessageContent(data="after"), + ] + assert restored_message.tool_calls == [tool_call] + assert restored_message.opaque_body is False