Skip to content

Runtime: add WP_Agent_Tool_Pair_Validator#176

Merged
lezama merged 2 commits into
mainfrom
add/tool-pair-validator
May 16, 2026
Merged

Runtime: add WP_Agent_Tool_Pair_Validator#176
lezama merged 2 commits into
mainfrom
add/tool-pair-validator

Conversation

@lezama
Copy link
Copy Markdown
Contributor

@lezama lezama commented May 16, 2026

Summary

Adds WP_Agent_Tool_Pair_Validator — a small runtime utility that detects and prunes orphan tool_call / tool_result envelopes in a message list.

Why

Every tool-aware provider request shape (Anthropic-style tool_use/tool_result blocks, OpenAI-style tool_calls + tool messages) requires each call to be paired with its result. An unpaired transcript is a provider 400 waiting to happen, and there are several ways one can show up even on a substrate that "does the right thing" by default:

  • Overflow archiving of a mid-cycle slice (the deterministic archive in WP_Agent_Conversation_Compaction keeps the boundary safe, but a re-archive of older content can split pairs the first pass didn't).
  • Manual transcript edits — message deletes, re-ordering, merging two sessions.
  • Partial transcript restore from an external store that doesn't preserve pair ordering guarantees.
  • Failed tool execution that emits a call but never persisted its matching result (crash / timeout / process kill between persist points).

Pair-aware boundary picking already lives in WP_Agent_Conversation_Compaction::move_boundary_to_safe_index(), but it only protects the cutoff. This class complements it by validating arbitrary message lists, which is the case boundary-picking can't cover.

API

WP_Agent_Tool_Pair_Validator::validate( array \$messages ): array
WP_Agent_Tool_Pair_Validator::is_paired( array \$messages ): bool
WP_Agent_Tool_Pair_Validator::prune( array \$messages ): array
//  -> { messages, removed, events }
  • Pairing is FIFO by payload.tool_name, mirroring how providers resolve tool-use IDs positionally when several pending calls share a name.
  • prune() emits a tool_pair_validated (clean) or tool_pair_pruned lifecycle event in the same { type, metadata } shape used by the compaction lifecycle, so consumers can forward both through a single event sink.
  • No mutations to existing classes. Pure-additive: opt-in helper that consumers can run before dispatch.

Composition

Where Today With this PR
Compaction boundary Safe — preserve_tool_boundaries=true keeps the cutoff between pairs Unchanged
Mid-region orphans after overflow archive Possible prune() recovers a sendable transcript
Imported / restored transcripts Trust the source is_paired() gates dispatch; prune() repairs
Tool-execution crash between call persist and result persist Caller writes a stub result manually prune() removes the dangling call

Test plan

  • php tests/tool-pair-validator-smoke.php — new smoke covering empty, plain, paired, orphan-call, orphan-result, FIFO matching by name, crossed names, prune output + lifecycle events, clean-prune no-op. (33 assertions)
  • composer test — full suite green; no regressions in existing compaction / loop / channels smokes.
  • Reviewer: confirm the FIFO-by-name matching matches expectations (alternative would be strict positional matching ignoring name — see PR comment if you'd prefer that).

🤖 Generated with Claude Code

lezama and others added 2 commits May 16, 2026 08:29
Detects and prunes orphan tool_call / tool_result envelopes in a transcript.
Provider request shapes (Anthropic-style tool_use/tool_result, OpenAI-style
tool_calls + tool messages) require every tool call to be paired with a
result; an unpaired transcript is a 400 waiting to happen. This validator
gives consumers a substrate-level helper they can run before dispatch or
after compaction / archive / session-import operations that can leave
mid-cycle gaps.

Pairing is FIFO by payload.tool_name, mirroring how providers resolve
tool-use IDs positionally when multiple calls share a name. Pair-aware
boundary picking already lives in WP_Agent_Conversation_Compaction; this
class complements it by validating arbitrary message lists (the case that
boundary-picking can't cover, e.g. overflow archive of mid-cycle messages,
manual deletes, partial transcript restores).

API:
- validate( array $messages ): array  -- orphan reports sorted by index
- is_paired( array $messages ): bool
- prune( array $messages ): array     -- { messages, removed, events }
  emits tool_pair_validated (clean) or tool_pair_pruned lifecycle events
  in the same shape used by compaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
phpstan: $messages is typed array<int, array<string, mixed>>, so the
per-element is_array() check always evaluates true. Remove it.

phpcs: align double arrows on the tool_pair_pruned event metadata array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lezama lezama merged commit cec9932 into main May 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant