Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/specs/004-python-function-calling-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,15 +476,14 @@ that manually replay messages own the equivalent rule: do not resend an approval
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `test_no_approval_no_extra_tool_result` |
| AG-UI `confirm_changes` snapshot | An accepted synthetic confirmation is replaced only when its original function call has a real result; rejection is cleaned explicitly, and missing accepted results remain inert. | `packages/ag-ui/tests/ag_ui/test_confirm_changes_snapshot.py` |
| AG-UI malformed `confirm_changes` metadata | Non-list tool-call metadata and malformed argument JSON are ignored without guessing a target call. | `test_confirm_changes_target_ignores_non_list_tool_calls`, `test_confirm_changes_target_rejects_malformed_arguments_json` |
| Compaction pair integrity | Function call/result groups remain atomic. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group` |
| Compaction pair integrity | Adjacent and non-adjacent pairs, including assistant-embedded results and completed reused-id occurrences, remain atomic without pairing ambiguous or out-of-order ids. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group`, `test_group_annotations_pair_nonadjacent_function_result_by_call_id`, `test_group_annotations_pair_multiple_nonadjacent_results_with_declaration`, `test_group_annotations_pair_completed_reused_call_id_occurrences`, `test_group_annotations_close_assistant_embedded_result_before_reused_call_id`, `test_sliding_window_does_not_retain_orphan_result_after_assistant_embedded_result`, `test_sliding_window_keeps_reused_call_id_occurrences_atomic`, `test_group_annotations_do_not_pair_ambiguous_duplicate_call_ids` |

## Required coverage gaps

These scenarios are required but are not fully covered by merged tests on `main`:

| Gap | Tracking |
|---|---|
| Non-adjacent and reused-id call/result occurrences remain atomic during compaction. | #7212 |
| Service-side storage sends the current approval response while omitting the stored request. | #7125 |
| Service-owned `previous_response_id` continuation cannot execute a terminal approval again on a later turn. | #6851 |
| A provider that ignores `tool_choice="none"` after an invocation limit cannot expose an unanswered call. | #7045 |
Expand Down Expand Up @@ -539,6 +538,7 @@ Before accepting an update, reviewers must confirm:
- #7267 / #7271 and #7304 — replayed calls and reused ids
- #7043 — provider-injected approval execution
- #6828 — AG-UI `confirm_changes` snapshot correlation
- #7212 — non-adjacent and reused-id compaction integrity
- #6851 — duplicate side effects after approval continuation
- #7383 — bind approval responses to framework-issued requests after this foundation merges
- #6963 / #7095 — opaque reasoning-signature replay
Expand Down
3 changes: 3 additions & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ agent_framework/
- Reasoning content or opaque reasoning metadata bound to a function call is part of the same logical group as the
call and terminal result. Function-loop replay and compaction must preserve that group atomically; adapters should
fail before a stateless request when required reasoning cannot be reconstructed.
- Compaction scans assistant function calls and assistant- or tool-role terminal results in transcript/content order,
pairing reused `call_id` values by ordered unambiguous occurrence. A result links only when exactly one preceding
declaration remains unmatched; genuinely ambiguous duplicate declarations stay separate.
### Agent Loop (`_harness/_loop.py`)

- **`AgentLoopMiddleware`** - `AgentMiddleware` that re-runs an agent in a loop by calling `call_next()` repeatedly (the pipeline re-reads `context.messages` each time). One configurable class covers two patterns: a required user `should_continue` predicate (sync or async, the first positional/keyword arg), and a chat-client judge built via the `.with_judge(...)` factory (a second chat client decides whether the original request was answered; loops while it is *not*, using a `JudgeVerdict` structured-output response — internally just an async `should_continue` predicate). The constructor covers the predicate pattern directly; only the judge has a convenience classmethod factory (`.with_judge(judge_client, ...)`) that forwards to `__init__`. Supports both streaming and non-streaming runs. By default a non-streaming run returns an aggregated `AgentResponse` containing every iteration's messages plus the injected `next_message` "nudge" messages (as `user` messages); set `return_final_only=True` to return only the last iteration's response. Streaming runs always yield each iteration's updates and emit the injected nudge messages as `user` updates between iterations (the `return_final_only` flag has no effect on streaming, and the final response reflects the last iteration; `MiddlewareTermination` is handled cleanly). `should_continue` is required; other constructor args are optional: `max_iterations` (safety cap; defaults to `DEFAULT_MAX_ITERATIONS`=10, explicit `None`→unbounded, positive int caps; `.with_judge` uses `DEFAULT_JUDGE_MAX_ITERATIONS`=5 as its default), `next_message` (defaults to a short "continue" nudge), `return_final_only`, and `additional_instructions` (an extra `system` message injected ahead of the input before the agent runs — becomes part of the original messages so it survives `fresh_context` resets and persists via a session). The judge is configured only through `.with_judge` (`judge_client`/`instructions`/`criteria`), not the constructor, and its `reasoning` is fed back to the agent as the next iteration's input; the judge forwards the original request messages and the agent's latest response messages verbatim so multi-modal content is preserved. `criteria` (a `list[str]`) is both injected as the agent's `additional_instructions` and rendered into the judge instructions wherever the `{{criteria}}` placeholder (`CRITERIA_PLACEHOLDER`) appears (`DEFAULT_JUDGE_INSTRUCTIONS` ends with it; custom `instructions` may include it, and it is stripped when no criteria are given). The `should_continue`/`next_message` callables are invoked with keyword args (`iteration`, `last_result`, `messages`, `original_messages`, `session`, `agent`, `progress`, `feedback`) and may be sync or async; declare only what you need plus `**kwargs`. `should_continue` may return a plain `bool` or a `(bool, str | None)` tuple whose second item is feedback surfaced to `next_message`/`record_feedback` via the `feedback` kwarg (the judge uses this to relay its `reasoning`). Stop precedence per iteration is `max_iterations` → `should_continue`, evaluated before `record_feedback` so the feedback is available to it.
Expand Down
142 changes: 133 additions & 9 deletions python/packages/core/agent_framework/_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ def _is_reasoning_only_assistant(message: Message) -> bool:
return all(content.type == "text_reasoning" for content in message.contents)


def _unambiguous_function_call_result_pairs(messages: Sequence[Message]) -> list[tuple[int, int]]:
unmatched_declaration_indices: dict[str, list[int]] = {}
pairs: list[tuple[int, int]] = []

for message_index, message in enumerate(messages):
if message.role not in ("assistant", "tool"):
continue
for content in message.contents:
if message.role == "assistant" and content.type == "function_call" and content.call_id:
unmatched_declaration_indices.setdefault(content.call_id, []).append(message_index)
continue
if content.type != "function_result" or not content.call_id:
continue
candidates = unmatched_declaration_indices.get(content.call_id)
if candidates is None or len(candidates) != 1:
continue
pairs.append((candidates.pop(), message_index))
return pairs


def _ensure_message_ids(
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
) -> None:
Expand All @@ -126,6 +146,56 @@ def _group_id_for(message: Message, group_index: int) -> str:
return f"group_index_{group_index}"


def _link_function_call_result_spans(messages: Sequence[Message], spans: list[dict[str, Any]]) -> None:
"""Link non-adjacent function results to unambiguous declaration occurrences."""
if len(spans) < 2:
return

span_by_message_index: dict[int, int] = {}
for span_index, span in enumerate(spans):
start_index = int(span["start_index"])
end_index = int(span["end_index"])
for message_index in range(start_index, end_index + 1):
span_by_message_index[message_index] = span_index

parents = list(range(len(spans)))

def find(span_index: int) -> int:
while parents[span_index] != span_index:
parents[span_index] = parents[parents[span_index]]
span_index = parents[span_index]
return span_index

def union(left: int, right: int) -> bool:
left_root = find(left)
right_root = find(right)
if left_root == right_root:
return False
earlier_root = min(left_root, right_root)
later_root = max(left_root, right_root)
parents[later_root] = earlier_root
return True

linked = False
for declaration_message_index, result_message_index in _unambiguous_function_call_result_pairs(messages):
declaration_span_index = span_by_message_index[declaration_message_index]
result_span_index = span_by_message_index[result_message_index]
if declaration_span_index < result_span_index and union(result_span_index, declaration_span_index):
linked = True
if not linked:
return

has_reasoning_by_root: dict[int, bool] = {}
for span_index, span in enumerate(spans):
root = find(span_index)
has_reasoning_by_root[root] = has_reasoning_by_root.get(root, False) or bool(span["has_reasoning"])

for span_index, span in enumerate(spans):
root = find(span_index)
span["group_id"] = spans[root]["group_id"]
span["has_reasoning"] = has_reasoning_by_root[root]


def group_messages(
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
) -> list[dict[str, Any]]:
Expand All @@ -145,6 +215,7 @@ def group_messages(
Returns:
Ordered list of lightweight span dicts with keys:
``group_id``, ``kind``, ``start_index``, ``end_index``, ``has_reasoning``.
Non-contiguous function-call declaration and result spans share a group id.
"""
_ensure_message_ids(messages, id_offset=id_offset, reserved_ids=reserved_ids)
spans: list[dict[str, Any]] = []
Expand Down Expand Up @@ -249,6 +320,7 @@ def group_messages(
i += 1
group_index += 1

_link_function_call_result_spans(messages, spans)
return spans


Expand Down Expand Up @@ -434,6 +506,39 @@ def _reannotation_start(messages: Sequence[Message], index: int) -> int:
return previous_index


def _function_pair_reannotation_start(messages: Sequence[Message], start_index: int) -> int:
unmatched_declaration_indices: dict[str, list[int]] = {}
matching_indices: list[int] = []
for message_index, message in enumerate(messages):
if message.role not in ("assistant", "tool"):
continue
for content in message.contents:
if message.role == "assistant" and content.type == "function_call" and content.call_id:
unmatched_declaration_indices.setdefault(content.call_id, []).append(message_index)
continue
if content.type != "function_result" or not content.call_id:
continue
candidates = unmatched_declaration_indices.get(content.call_id)
if not candidates:
continue
if message_index >= start_index:
# Keep every earlier candidate in the re-annotation slice. Otherwise an ambiguous result can
# appear unambiguous when an older declaration is hidden outside the slice.
matching_indices.extend(index for index in candidates if index < start_index)
if len(candidates) == 1:
candidates.pop()
if not matching_indices:
return start_index

earliest_index = min(matching_indices)
declaration_group_id = _group_id(messages[earliest_index])
if declaration_group_id is None:
return earliest_index
while earliest_index > 0 and _group_id(messages[earliest_index - 1]) == declaration_group_id:
earliest_index -= 1
return earliest_index


def annotate_message_groups(
messages: list[Message],
*,
Expand All @@ -444,7 +549,8 @@ def annotate_message_groups(
"""Annotate message groups while reusing existing annotations when possible.

By default, the function re-annotates only the suffix that contains new
messages and keeps previously annotated prefixes untouched. When a
messages and keeps previously annotated prefixes untouched. A newly added
function result expands that suffix back to its unique declaration. When a
``tokenizer`` is provided, token-count annotations are also populated
incrementally.
"""
Expand All @@ -466,18 +572,29 @@ def annotate_message_groups(
start_index = min(candidate_starts)

start_index = _reannotation_start(messages, start_index)
start_index = _function_pair_reannotation_start(messages, start_index)

# Continue group indices from the preserved prefix when only re-annotating a suffix.
group_index_offset = 0
if start_index > 0:
previous_group_index = _group_index(messages[start_index - 1])
if previous_group_index is not None:
group_index_offset = previous_group_index + 1
# Linked groups can be non-contiguous, so the last prefix message does not
# necessarily carry the highest group index.
prefix_group_indices = [
group_index for message in messages[:start_index] if (group_index := _group_index(message)) is not None
]
group_index_offset = max(prefix_group_indices, default=-1) + 1

reserved_ids = {message.message_id for message in messages[:start_index] if message.message_id}
spans = group_messages(messages[start_index:], id_offset=start_index, reserved_ids=reserved_ids)
for span_index, span in enumerate(spans):
span_counts_by_group_id: dict[str, int] = {}
for span in spans:
group_id = str(span["group_id"])
span_counts_by_group_id[group_id] = span_counts_by_group_id.get(group_id, 0) + 1
linked_group_ids = {group_id for group_id, count in span_counts_by_group_id.items() if count > 1}

group_indices: dict[str, int] = {}
grouped_messages: dict[str, list[Message]] = {}
for span in spans:
group_id = str(span["group_id"])
if group_id not in group_indices:
group_indices[group_id] = group_index_offset + len(group_indices)
kind = _coerce_group_kind(span["kind"])
if kind is None:
raise ValueError(f"Unexpected group kind in span: {span['kind']}")
Expand All @@ -490,12 +607,19 @@ def annotate_message_groups(
message,
group_id=group_id,
kind=kind,
index=group_index_offset + span_index,
index=group_indices[group_id],
has_reasoning=has_reasoning,
)
message.additional_properties.setdefault(EXCLUDED_KEY, False)
if group_id in linked_group_ids:
grouped_messages.setdefault(group_id, []).append(message)
if tokenizer is not None and _token_count(message) is None:
_write_token_count(message, tokenizer.count_tokens(_serialize_message(message)))

for group in grouped_messages.values():
if any(not message.additional_properties.get(EXCLUDED_KEY, False) for message in group):
for message in group:
message.additional_properties[EXCLUDED_KEY] = False
return _ordered_group_ids_from_annotations(messages)


Expand Down
Loading
Loading