From 7c79cf265c329a97d1ac9354dc0298e5e0b56982 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 3 Jun 2026 12:59:54 -0400 Subject: [PATCH] Use ability-native projections for DMC tools --- data-machine-code.php | 2 + inc/Tools/AbilityToolProjections.php | 96 +++++++++++ inc/Tools/GitHubTools.php | 45 +++-- inc/Tools/WorkspaceTools.php | 31 +++- tests/smoke-ability-tool-projections.php | 205 +++++++++++++++++++++++ 5 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 inc/Tools/AbilityToolProjections.php create mode 100644 tests/smoke-ability-tool-projections.php diff --git a/data-machine-code.php b/data-machine-code.php index 3b973b4..b081ce3 100644 --- a/data-machine-code.php +++ b/data-machine-code.php @@ -286,6 +286,8 @@ function datamachine_code_load_chat_tools() { return; } + \DataMachineCode\Tools\AbilityToolProjections::register(); + new \DataMachineCode\Tools\GitHubIssueTool(); new \DataMachineCode\Tools\GitHubPullRequestTool(); new \DataMachineCode\Tools\GitHubTools(); diff --git a/inc/Tools/AbilityToolProjections.php b/inc/Tools/AbilityToolProjections.php new file mode 100644 index 0000000..b4cbeac --- /dev/null +++ b/inc/Tools/AbilityToolProjections.php @@ -0,0 +1,96 @@ + $declaration ) { + datamachine_register_ability_tool($tool_name, $declaration); + } + + return true; + } + + /** + * Whether a tool name is registered through Data Machine's ability projection helper. + */ + public static function is_projected( string $tool_name ): bool { + return function_exists('datamachine_register_ability_tool') && isset(self::projected_tools()[ $tool_name ]); + } + + /** + * Model-facing tool names mapped to canonical ability slugs. + * + * Tool names intentionally preserve the existing DMC model contract while + * schema and execution now come from the registered WordPress abilities. + * + * @return array> + */ + public static function projected_tools(): array { + return array( + 'workspace_path' => self::workspace('datamachine-code/workspace-path'), + 'workspace_capabilities' => self::workspace('datamachine-code/workspace-capabilities'), + 'workspace_list' => self::workspace('datamachine-code/workspace-list'), + 'workspace_show' => self::workspace('datamachine-code/workspace-show'), + 'workspace_ls' => self::workspace('datamachine-code/workspace-ls'), + 'workspace_read' => self::workspace('datamachine-code/workspace-read'), + 'workspace_grep' => self::workspace('datamachine-code/workspace-grep'), + + 'list_github_issues' => self::github('datamachine-code/list-github-issues'), + 'get_github_issue' => self::github('datamachine-code/get-github-issue'), + 'list_github_pulls' => self::github('datamachine-code/list-github-pulls'), + 'get_github_pull' => self::github('datamachine-code/get-github-pull'), + 'get_github_pull_files' => self::github('datamachine-code/list-github-pull-files'), + 'get_github_check_runs' => self::github('datamachine-code/get-github-check-runs'), + 'get_github_commit_statuses' => self::github('datamachine-code/get-github-commit-statuses'), + 'get_github_actions_artifact' => self::github('datamachine-code/get-github-actions-artifact'), + 'get_github_pull_review_context' => self::github('datamachine-code/get-github-pull-review-context'), + 'github_repo_review_profile' => self::github('datamachine-code/get-github-repo-review-profile'), + 'github_pr_documentation_impact' => self::github('datamachine-code/get-github-pr-documentation-impact'), + 'list_github_tree' => self::github('datamachine-code/list-github-tree'), + 'get_github_file' => self::github('datamachine-code/get-github-file'), + 'list_github_repos' => self::github('datamachine-code/list-github-repos'), + ); + } + + /** + * Build a workspace projection declaration. + * + * @return array + */ + private static function workspace( string $ability ): array { + return array( + 'ability' => $ability, + 'modes' => array( 'chat', 'pipeline' ), + ); + } + + /** + * Build a GitHub projection declaration. + * + * @return array + */ + private static function github( string $ability ): array { + return array( + 'ability' => $ability, + 'access_level' => 'editor', + 'modes' => array( 'chat', 'pipeline' ), + ); + } + +} diff --git a/inc/Tools/GitHubTools.php b/inc/Tools/GitHubTools.php index eef7e97..44a85f4 100644 --- a/inc/Tools/GitHubTools.php +++ b/inc/Tools/GitHubTools.php @@ -26,8 +26,8 @@ class GitHubTools extends BaseTool public function __construct() { $contexts = array( 'chat', 'pipeline' ); - $this->registerTool('list_github_issues', array( $this, 'getListIssuesDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-issues' )); - $this->registerTool('get_github_issue', array( $this, 'getGetIssueDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-issue' )); + $this->registerProjectedToolFallback('list_github_issues', array( $this, 'getListIssuesDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-issues' )); + $this->registerProjectedToolFallback('get_github_issue', array( $this, 'getGetIssueDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-issue' )); $this->registerTool('manage_github_issue', array( $this, 'getManageIssueDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/update-github-issue' )); $this->registerTool('add_label_to_issue', array( $this, 'getAddLabelToIssueDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/add-github-labels' )); $this->registerTool('remove_label_from_issue', array( $this, 'getRemoveLabelFromIssueDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/remove-github-label' )); @@ -50,62 +50,62 @@ public function __construct() 'ability' => 'datamachine-code/cleanup-github-pull-request', ) ); - $this->registerTool('list_github_pulls', array( $this, 'getListPullsDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-pulls' )); - $this->registerTool( + $this->registerProjectedToolFallback('list_github_pulls', array( $this, 'getListPullsDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-pulls' )); + $this->registerProjectedToolFallback( 'get_github_pull', array( $this, 'getGetPullDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-pull', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'get_github_pull_files', array( $this, 'getPullFilesDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-pull-files', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'get_github_check_runs', array( $this, 'getCheckRunsDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-check-runs', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'get_github_commit_statuses', array( $this, 'getCommitStatusesDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-commit-statuses', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'get_github_actions_artifact', array( $this, 'getActionsArtifactDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-actions-artifact', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'get_github_pull_review_context', array( $this, 'getPullReviewContextDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-pull-review-context', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'github_repo_review_profile', array( $this, 'getRepoReviewProfileDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-repo-review-profile', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'github_pr_documentation_impact', array( $this, 'getPullDocumentationImpactDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-pr-documentation-impact', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'list_github_tree', array( $this, 'getListTreeDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-tree', ) ); - $this->registerTool( + $this->registerProjectedToolFallback( 'get_github_file', array( $this, 'getGetFileDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/get-github-file', @@ -117,7 +117,24 @@ public function __construct() 'ability' => 'datamachine-code/create-or-update-github-file', ) ); - $this->registerTool('list_github_repos', array( $this, 'getListReposDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-repos' )); + $this->registerProjectedToolFallback('list_github_repos', array( $this, 'getListReposDefinition' ), $contexts, array( 'access_level' => 'editor', 'ability' => 'datamachine-code/list-github-repos' )); + } + + /** + * Register a legacy wrapper only when Data Machine cannot project the ability directly. + * + * @param string $tool_id Model-facing tool name. + * @param callable $definition_callback Definition callback. + * @param array $contexts Tool contexts. + * @param array $options Tool metadata. + */ + private function registerProjectedToolFallback( string $tool_id, callable $definition_callback, array $contexts, array $options ): void + { + if ( class_exists(AbilityToolProjections::class) && AbilityToolProjections::is_projected($tool_id) ) { + return; + } + + $this->registerTool($tool_id, $definition_callback, $contexts, $options); } /** diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php index 95ee8c1..fb9fe7e 100644 --- a/inc/Tools/WorkspaceTools.php +++ b/inc/Tools/WorkspaceTools.php @@ -79,13 +79,13 @@ public function __construct() $contexts = array( 'chat', 'pipeline' ); $policy_contexts = array( 'chat', 'pipeline' ); $policy_meta = array( 'requires_opt_in' => true ); - $this->registerTool('workspace_path', array( $this, 'getPathDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-path' )); - $this->registerTool('workspace_capabilities', array( $this, 'getCapabilitiesDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-capabilities' )); - $this->registerTool('workspace_list', array( $this, 'getListDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-list' )); - $this->registerTool('workspace_show', array( $this, 'getShowDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-show' )); - $this->registerTool('workspace_ls', array( $this, 'getLsDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-ls' )); - $this->registerTool('workspace_read', array( $this, 'getReadDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-read' )); - $this->registerTool('workspace_grep', array( $this, 'getGrepDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-grep' )); + $this->registerProjectedToolFallback('workspace_path', array( $this, 'getPathDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-path' )); + $this->registerProjectedToolFallback('workspace_capabilities', array( $this, 'getCapabilitiesDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-capabilities' )); + $this->registerProjectedToolFallback('workspace_list', array( $this, 'getListDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-list' )); + $this->registerProjectedToolFallback('workspace_show', array( $this, 'getShowDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-show' )); + $this->registerProjectedToolFallback('workspace_ls', array( $this, 'getLsDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-ls' )); + $this->registerProjectedToolFallback('workspace_read', array( $this, 'getReadDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-read' )); + $this->registerProjectedToolFallback('workspace_grep', array( $this, 'getGrepDefinition' ), $contexts, array( 'ability' => 'datamachine-code/workspace-grep' )); $this->registerTool('workspace_write', array( $this, 'getWriteDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-write' )); $this->registerTool('workspace_edit', array( $this, 'getEditDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-edit' )); $this->registerTool('workspace_apply_patch', array( $this, 'getApplyPatchDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-apply-patch' )); @@ -104,6 +104,23 @@ public function __construct() $this->registerTool('workspace_pr_rebase', array( $this, 'getPrRebaseDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine-code/workspace-pr-rebase' )); } + /** + * Register a legacy wrapper only when Data Machine cannot project the ability directly. + * + * @param string $tool_id Model-facing tool name. + * @param callable $definition_callback Definition callback. + * @param array $contexts Tool contexts. + * @param array $options Tool metadata. + */ + private function registerProjectedToolFallback( string $tool_id, callable $definition_callback, array $contexts, array $options ): void + { + if ( class_exists(AbilityToolProjections::class) && AbilityToolProjections::is_projected($tool_id) ) { + return; + } + + $this->registerTool($tool_id, $definition_callback, $contexts, $options); + } + /** * Dispatch tool calls to specific handlers. * diff --git a/tests/smoke-ability-tool-projections.php b/tests/smoke-ability-tool-projections.php new file mode 100644 index 0000000..6c3adc8 --- /dev/null +++ b/tests/smoke-ability-tool-projections.php @@ -0,0 +1,205 @@ + $metadata ); + } + + public static function toolCall( string $content, string $tool_name, array $parameters, int $turn, array $metadata = array() ): array { + return array( + 'role' => 'assistant', + 'type' => 'tool_call', + 'content' => $content, + 'payload' => compact('tool_name', 'parameters', 'turn'), + 'metadata' => $metadata, + ); + } + + public static function toolResult( string $content, string $tool_name, array $payload, array $metadata = array() ): array { + $payload['tool_name'] = $tool_name; + + return array( + 'role' => 'user', + 'type' => 'tool_result', + 'content' => $content, + 'payload' => $payload, + 'metadata' => $metadata, + ); + } + } +} + +namespace { + if ( ! defined('ABSPATH') ) { + define('ABSPATH', __DIR__ . '/'); + } + + $GLOBALS['dmc_projection_registered_tools'] = array(); + + function datamachine_register_ability_tool( string $tool_name, array $declaration ): bool { + $GLOBALS['dmc_projection_registered_tools'][ $tool_name ] = $declaration; + return true; + } + + function dmc_projection_normalize_tool_result( array $ability_result, string $tool_name, string $ability_slug ): array { + $result = $ability_result; + $result['tool_name'] = $result['tool_name'] ?? $tool_name; + $result['metadata'] = array_merge( + array( 'ability' => $ability_slug ), + is_array($result['metadata'] ?? null) ? $result['metadata'] : array() + ); + + if ( ! array_key_exists('success', $result) ) { + $result['success'] = true; + } + + if ( $result['success'] && ! array_key_exists('result', $result) ) { + $payload = $result; + unset($payload['success'], $payload['tool_name'], $payload['metadata']); + $result['result'] = $payload; + } + + return $result; + } + + function dmc_projection_find_data_machine_conversation_manager(): ?string { + $candidates = array_filter( + array( + getenv('DATAMACHINE_CORE_PATH') ?: null, + dirname(__DIR__, 2) . '/data-machine', + dirname(__DIR__) . '/../data-machine', + ) + ); + + foreach ( $candidates as $candidate ) { + $path = rtrim((string) $candidate, '/') . '/inc/Engine/AI/ConversationManager.php'; + if ( is_file($path) ) { + return $path; + } + } + + return null; + } + + include __DIR__ . '/../inc/Tools/AbilityToolProjections.php'; + + $failures = array(); + $passes = 0; + $assert = function ( string $label, bool $condition ) use ( &$failures, &$passes ): void { + if ( $condition ) { + ++$passes; + echo " ok {$label}\n"; + return; + } + + $failures[] = $label; + echo " fail {$label}\n"; + }; + + echo "Ability tool projections - smoke\n"; + + $registered = \DataMachineCode\Tools\AbilityToolProjections::register(); + $tools = $GLOBALS['dmc_projection_registered_tools']; + + $expected = array( + 'workspace_list' => 'datamachine-code/workspace-list', + 'workspace_read' => 'datamachine-code/workspace-read', + 'workspace_grep' => 'datamachine-code/workspace-grep', + 'list_github_issues' => 'datamachine-code/list-github-issues', + 'list_github_pulls' => 'datamachine-code/list-github-pulls', + 'list_github_repos' => 'datamachine-code/list-github-repos', + 'get_github_pull' => 'datamachine-code/get-github-pull', + 'get_github_file' => 'datamachine-code/get-github-file', + 'list_github_tree' => 'datamachine-code/list-github-tree', + 'get_github_pull_review_context' => 'datamachine-code/get-github-pull-review-context', + ); + + $assert('projection helper was used', true === $registered); + + foreach ( $expected as $tool_name => $ability_slug ) { + $declaration = $tools[ $tool_name ] ?? array(); + $assert("{$tool_name} preserves model-facing name", isset($tools[ $tool_name ])); + $assert("{$tool_name} points at canonical ability", $ability_slug === ( $declaration['ability'] ?? '' )); + $assert("{$tool_name} is available to chat", in_array('chat', $declaration['modes'] ?? array(), true)); + $assert("{$tool_name} is available to pipeline", in_array('pipeline', $declaration['modes'] ?? array(), true)); + $assert("{$tool_name} does not duplicate parameter schema", ! array_key_exists('parameters', $declaration)); + } + + $workspace_result = dmc_projection_normalize_tool_result( + array( + 'success' => true, + 'content' => "# Demo\nworkspace_read_projected_visible_anchor\n", + 'path' => 'README.md', + ), + 'workspace_read', + 'datamachine-code/workspace-read' + ); + + $github_result = dmc_projection_normalize_tool_result( + array( + 'success' => true, + 'files' => array( + array( + 'path' => 'README.md', + 'content' => 'github_projected_visible_anchor', + ), + ), + 'count' => 1, + ), + 'get_github_file', + 'datamachine-code/get-github-file' + ); + + $assert('projected workspace execution records model tool name', 'workspace_read' === ( $workspace_result['tool_name'] ?? '' )); + $assert('projected workspace execution records canonical ability', 'datamachine-code/workspace-read' === ( $workspace_result['metadata']['ability'] ?? '' )); + $assert('projected workspace execution exposes payload under result for model visibility', str_contains((string) ( $workspace_result['result']['content'] ?? '' ), 'workspace_read_projected_visible_anchor')); + $assert('projected GitHub execution records model tool name', 'get_github_file' === ( $github_result['tool_name'] ?? '' )); + $assert('projected GitHub execution records canonical ability', 'datamachine-code/get-github-file' === ( $github_result['metadata']['ability'] ?? '' )); + $assert('projected GitHub execution keeps file payload visible', 'github_projected_visible_anchor' === ( $github_result['result']['files'][0]['content'] ?? '' )); + + $conversation_manager = dmc_projection_find_data_machine_conversation_manager(); + if ( null !== $conversation_manager && str_contains((string) file_get_contents($conversation_manager), 'modelFacingToolData') ) { + require_once $conversation_manager; + + $workspace_message = \DataMachine\Engine\AI\ConversationManager::formatToolResultMessage( + 'workspace_read', + $workspace_result, + array( 'repo' => 'demo', 'path' => 'README.md' ), + false, + 1 + ); + $github_message = \DataMachine\Engine\AI\ConversationManager::formatToolResultMessage( + 'get_github_file', + $github_result, + array( 'repo' => 'Extra-Chill/data-machine-code', 'path' => 'README.md' ), + false, + 1 + ); + + $assert('projected workspace transcript includes payload content', str_contains((string) ( $workspace_message['content'] ?? '' ), 'workspace_read_projected_visible_anchor')); + $assert('projected GitHub transcript includes payload content', str_contains((string) ( $github_message['content'] ?? '' ), 'github_projected_visible_anchor')); + $assert('projected transcript payload keeps workspace tool_data', ( $workspace_result['result'] ?? null ) === ( $workspace_message['payload']['tool_data'] ?? null )); + $assert('projected transcript payload keeps GitHub tool_data', ( $github_result['result'] ?? null ) === ( $github_message['payload']['tool_data'] ?? null )); + } else { + echo " skip projected transcript formatter assertions (updated Data Machine formatter not found)\n"; + } + + if ( ! empty($failures) ) { + echo "\nFAIL: " . count($failures) . " assertion(s)\n"; + foreach ( $failures as $failure ) { + echo " - {$failure}\n"; + } + exit(1); + } + + echo "\nOK ({$passes} assertions)\n"; + exit(0); +}