Skip to content

Commit c365119

Browse files
authored
feat(loop): expose turn_count, final_content, usage, request_metadata in result (#136)
The conversation loop's result contract previously surfaced only `messages`, `tool_execution_results`, `events`, and the optional `status`/`budget` pair. Consumers building real agents on top of the substrate need four additional pieces of information: * `turn_count` — how many turns actually executed * `final_content` — text of the last assistant message * `usage` — accumulated token counts across all turns * `request_metadata` — most recent turn's provider request descriptor The substrate already had access to all four — `turn_count` lives in the loop counter, `final_content` is computable from `messages`, and turn runners were already returning `usage` and `request_metadata` per turn (the latter was even tested for passthrough). They just weren't formalized as part of the result contract. Without these fields, consumers like data-machine resort to passing mutable state by reference through their turn runner closure so the outer caller can read accumulators after the loop returns: $turn_runner = build_turn_runner( ... &$last_request_metadata, &$total_usage, &$turns_run, &$final_content, ); WP_Agent_Conversation_Loop::run( $messages, $turn_runner, $options ); // Now read the by-reference accumulators back into the result. This is gross and a sign the substrate is missing universal-consumer observability. Other future consumers will hit the same gap. ## What changes * The loop accumulates `turn_count` (existing internal counter, just surfaced now), `final_content` (extracted from messages at result time), `total_usage` (summed from each turn runner's optional `usage` field), and `request_metadata` (overwritten by each turn runner's optional `request_metadata` field). * `WP_Agent_Conversation_Result::normalize()` validates the four new fields when present (int, string, array, array respectively). * Two new private helpers: `accumulate_usage()` sums numeric token counts while preserving provider-specific extras like `cache_creation_input_tokens` or `reasoning_tokens`; `extract_final_content()` walks messages backward for the last non-empty assistant text content. ## Compatibility All four fields are **additive** to the result shape — existing consumers reading `messages`/`tool_execution_results`/`status`/`budget` continue to work unchanged. The turn-runner contract gains two optional return fields (`usage`, `request_metadata`); turn runners that don't return them get the empty default. Turn runners that already return `request_metadata` (the existing `conversation-loop-transcript-persister-smoke` test does this) continue to round-trip correctly. ## Verification All 30 substrate smoke tests pass: * conversation-loop-smoke (6 assertions) * conversation-loop-tool-execution-smoke (19) * conversation-loop-completion-policy-smoke (6) * conversation-loop-events-smoke (17) * conversation-loop-budgets-smoke (24) * conversation-loop-transcript-persister-smoke (18 — including the pre-existing request_metadata round-trip test) * conversation-runner-contracts-smoke (18) * iteration-budget-smoke (22) * plus the broader substrate suites (compaction, identity, guidelines, workflows, routines, subagents, etc.) Closes #135
1 parent 2cf47ae commit c365119

2 files changed

Lines changed: 103 additions & 6 deletions

File tree

src/Runtime/class-wp-agent-conversation-loop.php

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,19 @@ public static function run( array $messages, callable $turn_runner, array $optio
8989
$tool_results = array();
9090
$conversation_complete = false;
9191
$exceeded_budget = null;
92-
$last_request_metadata = array();
92+
93+
// Universal observability accumulators. Turn runners may report
94+
// `usage` (token counts) and `request_metadata` (last provider request
95+
// descriptor) in their per-turn return value; the loop accumulates
96+
// these and exposes them in the final result so consumers don't have
97+
// to track them out-of-band via mutable state.
98+
$turns_run = 0;
99+
$total_usage = array(
100+
'prompt_tokens' => 0,
101+
'completion_tokens' => 0,
102+
'total_tokens' => 0,
103+
);
104+
$request_metadata = array();
93105

94106
if ( null !== $transcript_lock && '' !== $lock_session_id ) {
95107
$lock_token = $transcript_lock->acquire_session_lock( $lock_session_id, $lock_ttl );
@@ -109,6 +121,7 @@ public static function run( array $messages, callable $turn_runner, array $optio
109121

110122
try {
111123
for ( $turn = 1; $turn <= $max_turns; ++$turn ) {
124+
$turns_run = $turn;
112125
$turn_context = $context;
113126
$turn_context['turn'] = $turn;
114127

@@ -157,6 +170,18 @@ public static function run( array $messages, callable $turn_runner, array $optio
157170
throw $error;
158171
}
159172

173+
// Accumulate optional observability fields from the turn runner.
174+
// `usage` is a per-turn token-count array that gets summed into
175+
// `$total_usage`. `request_metadata` is the most recent provider
176+
// request descriptor and overwrites on each turn — consumers
177+
// typically only care about the last one.
178+
if ( isset( $result['usage'] ) && is_array( $result['usage'] ) ) {
179+
$total_usage = self::accumulate_usage( $total_usage, $result['usage'] );
180+
}
181+
if ( isset( $result['request_metadata'] ) && is_array( $result['request_metadata'] ) ) {
182+
$request_metadata = $result['request_metadata'];
183+
}
184+
160185
// When mediation is enabled, the turn runner returns tool_calls
161186
// and the loop handles execution. Otherwise, the caller-managed path applies.
162187
if ( $mediation_enabled && isset( $result['tool_calls'] ) && is_array( $result['tool_calls'] ) ) {
@@ -237,12 +262,12 @@ public static function run( array $messages, callable $turn_runner, array $optio
237262
'messages' => $messages,
238263
'tool_execution_results' => $tool_results,
239264
'events' => $events,
265+
'turn_count' => $turns_run,
266+
'final_content' => self::extract_final_content( $messages ),
267+
'usage' => $total_usage,
268+
'request_metadata' => $request_metadata,
240269
);
241270

242-
if ( ! empty( $last_request_metadata ) ) {
243-
$final_result_data['request_metadata'] = $last_request_metadata;
244-
}
245-
246271
if ( null !== $exceeded_budget ) {
247272
$final_result_data['status'] = 'budget_exceeded';
248273
$final_result_data['budget'] = $exceeded_budget;
@@ -253,7 +278,7 @@ public static function run( array $messages, callable $turn_runner, array $optio
253278
self::persist_transcript( $transcript_persister, $messages, $options, $final_result );
254279

255280
self::emit_event( $on_event, 'completed', array(
256-
'turn' => $turn,
281+
'turn' => $turns_run,
257282
'message_count' => count( $messages ),
258283
'tool_results' => count( $tool_results ),
259284
) );
@@ -752,6 +777,61 @@ private static function maybe_compact( array $messages, array $options ): array
752777
);
753778
}
754779

780+
/**
781+
* Accumulate per-turn usage into the running total.
782+
*
783+
* Sums the canonical `prompt_tokens`/`completion_tokens`/`total_tokens`
784+
* fields and preserves any provider-specific keys from the latest turn
785+
* (e.g. `cache_creation_input_tokens`, `reasoning_tokens`) so consumers
786+
* can read provider extensions without the loop having to know about
787+
* each one. Numeric fields are summed; non-numeric fields are taken
788+
* from the latest turn.
789+
*
790+
* @param array<string, mixed> $running Current accumulated usage.
791+
* @param array<string, mixed> $turn Per-turn usage.
792+
* @return array<string, mixed> Accumulated usage.
793+
*/
794+
private static function accumulate_usage( array $running, array $turn ): array {
795+
foreach ( $turn as $key => $value ) {
796+
if ( is_int( $value ) || is_float( $value ) ) {
797+
$running[ $key ] = (float) ( $running[ $key ] ?? 0 ) + (float) $value;
798+
if ( is_int( $value ) && (float) (int) $running[ $key ] === (float) $running[ $key ] ) {
799+
$running[ $key ] = (int) $running[ $key ];
800+
}
801+
continue;
802+
}
803+
$running[ $key ] = $value;
804+
}
805+
return $running;
806+
}
807+
808+
/**
809+
* Extract the text of the last assistant message from a transcript.
810+
*
811+
* Returns an empty string when no assistant message exists or the
812+
* latest assistant message has no text content (e.g. tool-call-only
813+
* turns at the tail).
814+
*
815+
* @param array $messages Normalized transcript messages.
816+
* @return string Final assistant text content.
817+
*/
818+
private static function extract_final_content( array $messages ): string {
819+
for ( $i = count( $messages ) - 1; $i >= 0; --$i ) {
820+
$message = $messages[ $i ];
821+
if ( ! is_array( $message ) ) {
822+
continue;
823+
}
824+
if ( ( $message['role'] ?? '' ) !== 'assistant' ) {
825+
continue;
826+
}
827+
$content = $message['content'] ?? '';
828+
if ( is_string( $content ) && '' !== $content ) {
829+
return $content;
830+
}
831+
}
832+
return '';
833+
}
834+
755835
/**
756836
* Normalize the max turn option.
757837
*

src/Runtime/class-wp-agent-conversation-result.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,23 @@ public static function normalize( array $result ): array {
9797
throw self::invalid( 'budget', 'must be a string when present' );
9898
}
9999

100+
// Validate optional observability fields surfaced by the loop.
101+
if ( array_key_exists( 'turn_count', $result ) && ! is_int( $result['turn_count'] ) ) {
102+
throw self::invalid( 'turn_count', 'must be an integer when present' );
103+
}
104+
105+
if ( array_key_exists( 'final_content', $result ) && ! is_string( $result['final_content'] ) ) {
106+
throw self::invalid( 'final_content', 'must be a string when present' );
107+
}
108+
109+
if ( array_key_exists( 'usage', $result ) && ! is_array( $result['usage'] ) ) {
110+
throw self::invalid( 'usage', 'must be an array when present' );
111+
}
112+
113+
if ( array_key_exists( 'request_metadata', $result ) && ! is_array( $result['request_metadata'] ) ) {
114+
throw self::invalid( 'request_metadata', 'must be an array when present' );
115+
}
116+
100117
return $result;
101118
}
102119

0 commit comments

Comments
 (0)