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
8 changes: 5 additions & 3 deletions src/Runtime/class-wp-agent-conversation-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -863,8 +863,10 @@ private static function mediate_tool_calls(
break;
}

// Consult completion policy.
if ( null !== $policy ) {
// Consult completion policy. A complete decision stops future model turns,
// but the current provider turn may contain more tool calls that still
// require paired tool results before the transcript can be replayed.
if ( null !== $policy && ! $complete ) {
$decision = $policy->recordToolResult(
$tool_name,
$tool_definition,
Expand All @@ -884,7 +886,7 @@ private static function mediate_tool_calls(
'context' => $decision->context(),
),
);
break;
continue;
}

$continuation = self::completion_policy_continuation( $decision, $tool_name, $turn );
Expand Down
48 changes: 28 additions & 20 deletions src/Runtime/class-wp-agent-provider-turn-result.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,29 +182,37 @@ private static function extract_structured_tool_calls( $result, array &$texts ):
return array();
}

$message = self::call_no_args( reset( $candidates ), 'getMessage' );
$parts = self::call_no_args( $message, 'getParts' );
if ( ! is_array( $parts ) ) {
return array();
}
$tool_calls = array();
$scanned_parts = 0;
foreach ( $candidates as $candidate ) {
$message = self::call_no_args( $candidate, 'getMessage' );
$parts = self::call_no_args( $message, 'getParts' );
if ( ! is_array( $parts ) ) {
continue;
}

$tool_calls = array();
foreach ( array_slice( $parts, 0, 64 ) as $part ) {
$function_call = self::call_no_args( $part, 'getFunctionCall' );
if ( null !== $function_call ) {
$name = self::call_no_args( $function_call, 'getName' );
if ( is_string( $name ) && '' !== $name ) {
$tool_calls[] = array(
'name' => $name,
'parameters' => self::normalize_function_args( self::call_no_args( $function_call, 'getArgs' ) ),
'id' => self::call_no_args( $function_call, 'getId' ),
);
foreach ( $parts as $part ) {
if ( $scanned_parts >= 64 ) {
break 2;
}
}

$text = self::call_no_args( $part, 'getText' );
if ( is_string( $text ) ) {
$texts[] = $text;
++$scanned_parts;
$function_call = self::call_no_args( $part, 'getFunctionCall' );
if ( null !== $function_call ) {
$name = self::call_no_args( $function_call, 'getName' );
if ( is_string( $name ) && '' !== $name ) {
$tool_calls[] = array(
'name' => $name,
'parameters' => self::normalize_function_args( self::call_no_args( $function_call, 'getArgs' ) ),
'id' => self::call_no_args( $function_call, 'getId' ),
);
}
}

$text = self::call_no_args( $part, 'getText' );
if ( is_string( $text ) ) {
$texts[] = $text;
}
}
}

Expand Down
18 changes: 13 additions & 5 deletions src/Tools/class-wp-agent-tool-declaration.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ public static function normalize( array $declaration ): array {

$name = is_string( $declaration['name'] ?? null ) ? $declaration['name'] : '';
$source = self::sourceFromName( $name );
if ( '' === $source && is_string( $declaration['source'] ?? null ) ) {
$source = trim( $declaration['source'] );
}
if ( '' === $source ) {
$source = self::SOURCE_CLIENT;
}
$description = $declaration['description'] ?? '';

$normalized = array(
Expand Down Expand Up @@ -206,15 +212,17 @@ public static function validate( array $declaration ): array {
$errors = array();

$name = $declaration['name'] ?? null;
if (
! is_string( $name )
|| '' === $name
|| ! preg_match( '/^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$/', $name )
) {
if ( ! is_string( $name ) || '' === $name || ! self::isValidHostToolName( $name ) ) {
$errors[] = 'name';
}

$source = is_string( $name ) ? self::sourceFromName( $name ) : '';
if ( '' === $source && is_string( $declaration['source'] ?? null ) ) {
$source = trim( $declaration['source'] );
}
if ( '' === $source ) {
$source = self::SOURCE_CLIENT;
}
if ( self::SOURCE_CLIENT !== $source ) {
$errors[] = 'source';
}
Expand Down
163 changes: 163 additions & 0 deletions tests/conversation-loop-completion-policy-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,104 @@ static function ( array $messages ): array {
agents_api_smoke_assert_equals( 1, count( $policy_log ), 'policy was called once', $failures, $passes );
agents_api_smoke_assert_equals( 0, $continue_called, 'should_continue was not called when policy stopped the loop', $failures, $passes );

echo "\n[2b] Structured provider extraction preserves multi-candidate tool calls:\n";

$function_call_part = static function ( string $id, string $name, array $args ) {
$function_call = new class( $id, $name, $args ) {
private string $id;
private string $name;
private array $args;

public function __construct( string $id, string $name, array $args ) {
$this->id = $id;
$this->name = $name;
$this->args = $args;
}

public function getId(): string {
return $this->id;
}

public function getName(): string {
return $this->name;
}

public function getArgs(): array {
return $this->args;
}
};

return new class( $function_call ) {
private object $function_call;

public function __construct( object $function_call ) {
$this->function_call = $function_call;
}

public function getFunctionCall(): object {
return $this->function_call;
}

public function getText(): string {
return '';
}
};
};

$candidate = static function ( object $part ) {
$message = new class( $part ) {
private object $part;

public function __construct( object $part ) {
$this->part = $part;
}

public function getParts(): array {
return array( $this->part );
}
};

return new class( $message ) {
private object $message;

public function __construct( object $message ) {
$this->message = $message;
}

public function getMessage(): object {
return $this->message;
}
};
};

$multi_candidate_result = new class( $candidate, $function_call_part ) {
/** @var callable */
private $candidate;
/** @var callable */
private $function_call_part;

public function __construct( callable $candidate, callable $function_call_part ) {
$this->candidate = $candidate;
$this->function_call_part = $function_call_part;
}

public function getCandidates(): array {
$candidate = $this->candidate;
$function_call_part = $this->function_call_part;

return array(
$candidate( $function_call_part( 'call_one', 'client/work', array( 'path' => 'index.html' ) ) ),
$candidate( $function_call_part( 'call_two', 'client/finish', array( 'path' => 'styles.css' ) ) ),
);
}
};

$extracted_calls = AgentsAPI\AI\WP_Agent_Provider_Turn_Result::extract_tool_calls( $multi_candidate_result );
agents_api_smoke_assert_equals( 2, count( $extracted_calls ), 'structured extraction scans all candidates', $failures, $passes );
agents_api_smoke_assert_equals( 'client/work', $extracted_calls[0]['name'] ?? '', 'first candidate tool call is preserved', $failures, $passes );
agents_api_smoke_assert_equals( 'client/finish', $extracted_calls[1]['name'] ?? '', 'second candidate tool call is preserved', $failures, $passes );
agents_api_smoke_assert_equals( 'call_two', $extracted_calls[1]['id'] ?? '', 'second candidate tool call id is preserved', $failures, $passes );

echo "\n[3] Non-empty incomplete decisions append continuation messages and events:\n";

$continue_events = array();
Expand Down Expand Up @@ -320,4 +418,69 @@ static function ( array $messages, array $context ) use ( &$caller_managed_turns

agents_api_smoke_assert_equals( 1, $caller_managed_turns, 'caller-managed loop stopped after one turn via completion policy', $failures, $passes );

echo "\n[6] Completion policy does not truncate same-turn tool results for Responses API pairing:\n";
$policy_log = array();
$same_turn_executor_calls = 0;
$same_turn_executor = new class( $same_turn_executor_calls ) implements AgentsAPI\AI\Tools\WP_Agent_Tool_Executor {
private int $calls;

public function __construct( int &$calls ) {
$this->calls = &$calls;
}

public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array {
++$this->calls;
return array(
'success' => true,
'tool_name' => $tool_call['tool_name'],
'result' => array( 'path' => $tool_call['parameters']['path'] ?? '' ),
);
}
};

$same_turn_result = AgentsAPI\AI\WP_Agent_Conversation_Loop::run(
array( array( 'role' => 'user', 'content' => 'write files' ) ),
static function ( array $messages ): array {
return array(
'messages' => $messages,
'tool_calls' => array(
array( 'id' => 'call_one', 'name' => 'client/work', 'parameters' => array( 'path' => 'one.html' ) ),
array( 'id' => 'call_two', 'name' => 'client/work', 'parameters' => array( 'path' => 'two.html' ) ),
),
);
},
array(
'max_turns' => 5,
'tool_executor' => $same_turn_executor,
'tool_declarations' => $tools,
'completion_policy' => $always_complete_policy,
'should_continue' => static function (): bool {
return true;
},
)
);

$same_turn_tool_results = array_values(
array_filter(
$same_turn_result['messages'],
static function ( array $message ): bool {
return ( $message['type'] ?? '' ) === AgentsAPI\AI\WP_Agent_Message::TYPE_TOOL_RESULT;
}
)
);
$same_turn_stop_events = array_values(
array_filter(
$same_turn_result['events'],
static function ( array $event ): bool {
return 'completion_policy_stop' === ( $event['type'] ?? '' );
}
)
);

agents_api_smoke_assert_equals( 2, $same_turn_executor_calls, 'completion policy still executes every same-turn tool call', $failures, $passes );
agents_api_smoke_assert_equals( 2, count( $same_turn_result['tool_execution_results'] ), 'completion policy preserves every same-turn tool execution result', $failures, $passes );
agents_api_smoke_assert_equals( 2, count( $same_turn_tool_results ), 'transcript contains every same-turn tool result message', $failures, $passes );
agents_api_smoke_assert_equals( array( 'call_one', 'call_two' ), array_column( array_column( $same_turn_tool_results, 'metadata' ), 'tool_call_id' ), 'tool result messages keep every provider call id', $failures, $passes );
agents_api_smoke_assert_equals( 1, count( $same_turn_stop_events ), 'complete decision records one stop event while draining same-turn calls', $failures, $passes );

agents_api_smoke_finish( 'Agents API conversation loop completion policy', $failures, $passes );
13 changes: 13 additions & 0 deletions tests/runtime-tool-policy-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,20 @@
)
);

$provider_safe_runtime_tool = WP_Agent_Tool_Declaration::normalize(
array(
'name' => 'filesystem_write',
'source' => WP_Agent_Tool_Declaration::SOURCE_CLIENT,
'description' => 'Write one generated website artifact file.',
'parameters' => array( 'type' => 'object' ),
'executor' => WP_Agent_Tool_Declaration::EXECUTOR_CLIENT,
'scope' => WP_Agent_Tool_Declaration::SCOPE_RUN,
)
);

agents_api_smoke_assert_equals( WP_Agent_Runtime_Tool_Policy::SCHEMA, $policy['schema'] ?? '', 'policy exposes canonical schema', $failures, $passes );
agents_api_smoke_assert_equals( 'filesystem_write', $provider_safe_runtime_tool['name'] ?? '', 'provider-safe runtime tool name is accepted', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Tool_Declaration::SOURCE_CLIENT, $provider_safe_runtime_tool['source'] ?? '', 'provider-safe runtime tool keeps client source', $failures, $passes );
agents_api_smoke_assert_equals( WP_Agent_Runtime_Tool_Policy::VERSION, $policy['version'] ?? 0, 'policy exposes canonical version', $failures, $passes );
agents_api_smoke_assert_equals( 3, count( $policy['tools'] ?? array() ), 'policy includes all declared tools', $failures, $passes );
agents_api_smoke_assert_equals( array( 'runtime_type' => 'wordpress-playground', 'session_id' => 'abc123' ), $policy['context'] ?? array(), 'policy context keeps only scalar metadata', $failures, $passes );
Expand Down
Loading