diff --git a/inc/Engine/AI/MemoryFileRegistry.php b/inc/Engine/AI/MemoryFileRegistry.php index c2beb0fd..e8ce6f60 100644 --- a/inc/Engine/AI/MemoryFileRegistry.php +++ b/inc/Engine/AI/MemoryFileRegistry.php @@ -125,9 +125,7 @@ public static function register( string $filename, int $priority = 50, array $ar $modes = self::MODES_NONE; } $modes = array_values( array_unique( array_map( 'sanitize_key', $modes ) ) ); - $default_retrieval_policy = empty( $modes ) - ? WP_Agent_Context_Injection_Policy::NEVER - : WP_Agent_Context_Injection_Policy::ALWAYS; + $default_retrieval_policy = self::default_retrieval_policy( empty( $modes ) ); // Convention path: relative path from ABSPATH for an additional copy. $convention_path = isset( $args['convention_path'] ) ? ltrim( $args['convention_path'], '/' ) : ''; @@ -143,13 +141,20 @@ public static function register( string $filename, int $priority = 50, array $ar 'modes' => $modes, 'label' => $args['label'] ?? self::filename_to_label( $filename ), 'description' => $args['description'] ?? '', - 'retrieval_policy' => WP_Agent_Context_Injection_Policy::normalize( $args['retrieval_policy'] ?? $default_retrieval_policy ), + 'retrieval_policy' => self::normalize_retrieval_policy( $args['retrieval_policy'] ?? $default_retrieval_policy ), 'authority_tier' => $args['authority_tier'] ?? self::default_authority_tier( $layer, $filename ), 'provenance' => is_array( $args['provenance'] ?? null ) ? $args['provenance'] : self::default_provenance( $filename ), ); self::$files[ $filename ] = $metadata; + // Mirror into the Agents API source registry. When the substrate is + // missing (Homeboy playground bootstrap), DM runs degraded with the + // local self::$files map only — see agents_api_loaded() docblock. + if ( ! self::agents_api_loaded() ) { + return; + } + WP_Agent_Memory_Registry::register( self::source_id_for_filename( $filename ), array( @@ -173,6 +178,41 @@ public static function register( string $filename, int $priority = 50, array $ar ); } + /** + * Resolve the default retrieval policy for a registration. + * + * Mirrors `WP_Agent_Context_Injection_Policy::NEVER` / `::ALWAYS` while + * tolerating the substrate being absent at bootstrap. + * + * @param bool $modes_empty Whether the registration declared no modes. + * @return string Canonical policy slug. + */ + private static function default_retrieval_policy( bool $modes_empty ): string { + if ( ! self::agents_api_loaded() ) { + return $modes_empty ? 'never' : 'always'; + } + return $modes_empty + ? WP_Agent_Context_Injection_Policy::NEVER + : WP_Agent_Context_Injection_Policy::ALWAYS; + } + + /** + * Normalize a retrieval policy string. + * + * Delegates to the substrate when present; otherwise falls back to a + * local allowlist matching `WP_Agent_Context_Injection_Policy::values()`. + * + * @param string $policy Raw policy value. + * @return string Canonical policy slug. + */ + private static function normalize_retrieval_policy( string $policy ): string { + if ( ! self::agents_api_loaded() ) { + $valid = array( 'always', 'on_intent', 'on_tool_need', 'manual', 'never' ); + return in_array( $policy, $valid, true ) ? $policy : 'always'; + } + return WP_Agent_Context_Injection_Policy::normalize( $policy ); + } + /** * Deregister a memory file. * @@ -182,7 +222,9 @@ public static function register( string $filename, int $priority = 50, array $ar public static function deregister( string $filename ): void { $filename = sanitize_file_name( $filename ); unset( self::$files[ $filename ] ); - WP_Agent_Memory_Registry::unregister( self::source_id_for_filename( $filename ) ); + if ( self::agents_api_loaded() ) { + WP_Agent_Memory_Registry::unregister( self::source_id_for_filename( $filename ) ); + } } /** @@ -415,11 +457,17 @@ public static function get_for_mode( string $mode ): array { return self::get_resolved(); } + $agents_api_loaded = self::agents_api_loaded(); + return array_filter( self::get_resolved(), - function ( $meta ) use ( $mode ) { - $retrieval_policy = $meta['retrieval_policy'] ?? WP_Agent_Context_Injection_Policy::ALWAYS; - if ( ! WP_Agent_Context_Injection_Policy::is_always_injected( $retrieval_policy ) ) { + function ( $meta ) use ( $mode, $agents_api_loaded ) { + $default_policy = $agents_api_loaded ? WP_Agent_Context_Injection_Policy::ALWAYS : 'always'; + $retrieval_policy = $meta['retrieval_policy'] ?? $default_policy; + $is_always = $agents_api_loaded + ? WP_Agent_Context_Injection_Policy::is_always_injected( $retrieval_policy ) + : ( 'always' === $retrieval_policy ); + if ( ! $is_always ) { return false; } @@ -507,7 +555,9 @@ public static function get_layer_filenames( string $layer ): array { public static function reset(): void { self::$files = array(); self::$filter_applied = false; - WP_Agent_Memory_Registry::reset(); + if ( self::agents_api_loaded() ) { + WP_Agent_Memory_Registry::reset(); + } } /** @@ -536,7 +586,11 @@ private static function get_resolved(): array { self::$filter_applied = true; } - $files = self::from_agents_api_sources( WP_Agent_Memory_Registry::get_all() ); + // When the Agents API substrate is missing, the local self::$files map + // is the single source of truth. See agents_api_loaded() docblock. + $files = self::agents_api_loaded() + ? self::from_agents_api_sources( WP_Agent_Memory_Registry::get_all() ) + : self::$files; uasort( $files, function ( $a, $b ) { @@ -589,11 +643,83 @@ public static function context_slug_for_filename( string $filename ): string { return sanitize_key( str_replace( '.', '-', strtolower( $filename ) ) ); } + /** + * Whether the Agents API runtime classes are loaded. + * + * DM core declares `agents-api` as a `Requires Plugins` dependency, so under + * normal WordPress activation flows the substrate is always present. The + * Homeboy playground bootstrap (used by `homeboy test` / `homeboy release`) + * loads plugins via `require_once` and bypasses that gate, which means DM + * core can boot in an environment where these classes do not exist. + * + * When that happens, every site that touches `MemoryFileRegistry::register()` + * during `muplugins_loaded` fatals before plugin code runs. To keep the + * registry usable in that degraded mode we mirror the data locally in + * `self::$files` and skip the Agents API mirror calls. Production behavior + * (where the substrate is loaded) is unchanged. + * + * The result is cached per request — class loading state is immutable + * within a single PHP process for our purposes. + * + * @return bool + */ + private static function agents_api_loaded(): bool { + static $loaded = null; + if ( null === $loaded ) { + $loaded = class_exists( WP_Agent_Memory_Layer::class ) + && class_exists( WP_Agent_Memory_Registry::class ) + && class_exists( WP_Agent_Context_Injection_Policy::class ) + && class_exists( '\\AgentsAPI\\AI\\Context\\WP_Agent_Context_Authority_Tier' ); + } + return $loaded; + } + + /** + * Normalize a raw layer string. + * + * Delegates to {@see WP_Agent_Memory_Layer::normalize()} when the Agents API + * substrate is loaded. When the substrate is missing (Homeboy playground + * bootstrap, isolated tests), falls back to a local allowlist of the four + * DM-canonical layers and defaults to {@see self::LAYER_AGENT}. + * + * @param string $layer Raw layer identifier. + * @return string Normalized layer slug. + */ private static function normalize_layer( string $layer ): string { + if ( ! self::agents_api_loaded() ) { + $valid = array( self::LAYER_SHARED, self::LAYER_AGENT, self::LAYER_USER, self::LAYER_NETWORK ); + return in_array( $layer, $valid, true ) ? $layer : self::LAYER_AGENT; + } return WP_Agent_Memory_Layer::normalize( $layer, self::LAYER_AGENT ); } + /** + * Default authority tier for a layer/filename pair. + * + * When the Agents API substrate is missing, returns the canonical string + * literals that `WP_Agent_Context_Authority_Tier` would otherwise resolve + * to ({@see WP_Agent_Context_Authority_Tier::ordered()}). Keeping the + * fallbacks in sync with the substrate's vocabulary is intentional — + * downstream consumers compare these as plain strings. + * + * @param string $layer Normalized layer slug. + * @param string $filename Filename being registered. + * @return string Authority tier slug. + */ private static function default_authority_tier( string $layer, string $filename ): string { + if ( ! self::agents_api_loaded() ) { + if ( self::LAYER_SHARED === $layer || self::LAYER_NETWORK === $layer ) { + return 'workspace_shared'; + } + if ( self::LAYER_USER === $layer ) { + return 'user_global'; + } + if ( 'SOUL.md' === $filename ) { + return 'agent_identity'; + } + return 'agent_memory'; + } + if ( self::LAYER_SHARED === $layer || self::LAYER_NETWORK === $layer ) { return \AgentsAPI\AI\Context\WP_Agent_Context_Authority_Tier::WORKSPACE_SHARED; } diff --git a/tests/Unit/Engine/AI/MemoryFileRegistryTest.php b/tests/Unit/Engine/AI/MemoryFileRegistryTest.php new file mode 100644 index 00000000..ab4cec14 --- /dev/null +++ b/tests/Unit/Engine/AI/MemoryFileRegistryTest.php @@ -0,0 +1,143 @@ + MemoryFileRegistry::LAYER_SHARED, + 'protected' => true, + 'composable' => true, + 'modes' => array( MemoryFileRegistry::MODE_ALL ), + 'label' => 'Site Context', + ) ); + + $all = MemoryFileRegistry::get_all(); + $this->assertArrayHasKey( 'SITE.md', $all ); + $this->assertSame( 'shared', $all['SITE.md']['layer'] ); + $this->assertSame( 'workspace_shared', $all['SITE.md']['authority_tier'] ); + $this->assertSame( 'always', $all['SITE.md']['retrieval_policy'] ); + $this->assertTrue( $all['SITE.md']['protected'] ); + $this->assertTrue( $all['SITE.md']['composable'] ); + $this->assertFalse( $all['SITE.md']['editable'] ); // composable forces editable=false + } + + /** + * Unknown layers must normalize to LAYER_AGENT. This is the contract + * `WP_Agent_Memory_Layer::normalize()` provides when the substrate is + * loaded, and the local fallback when it isn't. + */ + public function test_unknown_layer_normalizes_to_agent_default(): void { + MemoryFileRegistry::register( 'WEIRD.md', 50, array( 'layer' => 'not-a-real-layer' ) ); + + $file = MemoryFileRegistry::get( 'WEIRD.md' ); + $this->assertNotNull( $file ); + $this->assertSame( MemoryFileRegistry::LAYER_AGENT, $file['layer'] ); + } + + /** + * Files without explicit modes must default to retrieval_policy=never, + * meaning they're registered but not auto-injected into prompts. + */ + public function test_no_modes_defaults_to_never_retrieval_policy(): void { + MemoryFileRegistry::register( 'QUIET.md', 50, array() ); + + $file = MemoryFileRegistry::get( 'QUIET.md' ); + $this->assertSame( 'never', $file['retrieval_policy'] ); + } + + /** + * Authority tier defaults must match the + * `WP_Agent_Context_Authority_Tier` vocabulary — string literals + * `workspace_shared`, `user_global`, `agent_identity`, `agent_memory`. + */ + public function test_default_authority_tier_uses_canonical_vocabulary(): void { + MemoryFileRegistry::register( 'SITE.md', 10, array( 'layer' => MemoryFileRegistry::LAYER_SHARED ) ); + MemoryFileRegistry::register( 'NETWORK.md', 10, array( 'layer' => MemoryFileRegistry::LAYER_NETWORK ) ); + MemoryFileRegistry::register( 'USER.md', 10, array( 'layer' => MemoryFileRegistry::LAYER_USER ) ); + MemoryFileRegistry::register( 'SOUL.md', 10, array( 'layer' => MemoryFileRegistry::LAYER_AGENT ) ); + MemoryFileRegistry::register( 'MEMORY.md', 10, array( 'layer' => MemoryFileRegistry::LAYER_AGENT ) ); + + $all = MemoryFileRegistry::get_all(); + $this->assertSame( 'workspace_shared', $all['SITE.md']['authority_tier'] ); + $this->assertSame( 'workspace_shared', $all['NETWORK.md']['authority_tier'] ); + $this->assertSame( 'user_global', $all['USER.md']['authority_tier'] ); + $this->assertSame( 'agent_identity', $all['SOUL.md']['authority_tier'] ); + $this->assertSame( 'agent_memory', $all['MEMORY.md']['authority_tier'] ); + } + + /** + * deregister() removes the entry from get_all(). + */ + public function test_deregister_removes_entry(): void { + MemoryFileRegistry::register( 'TEMP.md', 50, array() ); + $this->assertArrayHasKey( 'TEMP.md', MemoryFileRegistry::get_all() ); + + MemoryFileRegistry::deregister( 'TEMP.md' ); + $this->assertArrayNotHasKey( 'TEMP.md', MemoryFileRegistry::get_all() ); + } + + /** + * get_for_mode() only returns files whose modes match the requested mode + * AND whose retrieval_policy is `always`. + */ + public function test_get_for_mode_filters_by_mode_and_policy(): void { + MemoryFileRegistry::register( 'CHAT_ONLY.md', 10, array( + 'layer' => MemoryFileRegistry::LAYER_AGENT, + 'modes' => array( 'chat' ), + ) ); + MemoryFileRegistry::register( 'PIPELINE_ONLY.md', 20, array( + 'layer' => MemoryFileRegistry::LAYER_AGENT, + 'modes' => array( 'pipeline' ), + ) ); + MemoryFileRegistry::register( 'ALL_MODES.md', 30, array( + 'layer' => MemoryFileRegistry::LAYER_AGENT, + 'modes' => array( MemoryFileRegistry::MODE_ALL ), + ) ); + MemoryFileRegistry::register( 'NO_MODES.md', 40, array( + 'layer' => MemoryFileRegistry::LAYER_AGENT, + ) ); + + $chat_files = MemoryFileRegistry::get_for_mode( 'chat' ); + $this->assertArrayHasKey( 'CHAT_ONLY.md', $chat_files ); + $this->assertArrayHasKey( 'ALL_MODES.md', $chat_files ); + $this->assertArrayNotHasKey( 'PIPELINE_ONLY.md', $chat_files ); + // NO_MODES.md is registered with retrieval_policy=never, so it must + // never appear in mode-filtered injection lists. + $this->assertArrayNotHasKey( 'NO_MODES.md', $chat_files ); + } +} diff --git a/tests/memory-file-registry-missing-agents-api-smoke.php b/tests/memory-file-registry-missing-agents-api-smoke.php new file mode 100644 index 00000000..97fd55e7 --- /dev/null +++ b/tests/memory-file-registry-missing-agents-api-smoke.php @@ -0,0 +1,159 @@ + MemoryFileRegistry::LAYER_SHARED, + 'protected' => true, + 'composable' => true, + 'modes' => array( MemoryFileRegistry::MODE_ALL ), + 'label' => 'Site Context', + ) ); +} catch ( \Throwable $e ) { + $fatal = $e; +} +mfr_smoke_assert_equals( null, $fatal, 'register() completes without throwing', $failures, $passes ); + +echo "\n[2] Valid layers pass through normalize_layer fallback:\n"; +MemoryFileRegistry::register( 'RULES.md', 15, array( 'layer' => 'shared' ) ); +MemoryFileRegistry::register( 'SOUL.md', 20, array( 'layer' => 'agent' ) ); +MemoryFileRegistry::register( 'USER.md', 25, array( 'layer' => 'user' ) ); +MemoryFileRegistry::register( 'NETWORK.md', 5, array( 'layer' => 'network' ) ); + +$resolved = MemoryFileRegistry::get_all(); +mfr_smoke_assert_equals( 'shared', $resolved['RULES.md']['layer'] ?? null, 'shared layer round-trips', $failures, $passes ); +mfr_smoke_assert_equals( 'agent', $resolved['SOUL.md']['layer'] ?? null, 'agent layer round-trips', $failures, $passes ); +mfr_smoke_assert_equals( 'user', $resolved['USER.md']['layer'] ?? null, 'user layer round-trips', $failures, $passes ); +mfr_smoke_assert_equals( 'network', $resolved['NETWORK.md']['layer'] ?? null, 'network layer round-trips', $failures, $passes ); + +echo "\n[3] Unknown layers fall back to LAYER_AGENT:\n"; +MemoryFileRegistry::register( 'BOGUS.md', 99, array( 'layer' => 'not-a-real-layer' ) ); +$resolved = MemoryFileRegistry::get_all(); +mfr_smoke_assert_equals( MemoryFileRegistry::LAYER_AGENT, $resolved['BOGUS.md']['layer'] ?? null, 'unknown layer normalizes to LAYER_AGENT', $failures, $passes ); + +echo "\n[4] default_authority_tier returns documented string literals:\n"; +// Authority tier defaults are computed during register(); inspect via the +// resolved registry. Values must match the substrate's vocabulary +// (WP_Agent_Context_Authority_Tier::ordered()). +mfr_smoke_assert_equals( 'workspace_shared', $resolved['SITE.md']['authority_tier'] ?? null, 'shared layer → workspace_shared', $failures, $passes ); +mfr_smoke_assert_equals( 'workspace_shared', $resolved['NETWORK.md']['authority_tier'] ?? null, 'network layer → workspace_shared', $failures, $passes ); +mfr_smoke_assert_equals( 'user_global', $resolved['USER.md']['authority_tier'] ?? null, 'user layer → user_global', $failures, $passes ); +mfr_smoke_assert_equals( 'agent_identity', $resolved['SOUL.md']['authority_tier'] ?? null, 'SOUL.md → agent_identity', $failures, $passes ); +mfr_smoke_assert_equals( 'agent_memory', $resolved['BOGUS.md']['authority_tier'] ?? null, 'agent layer default → agent_memory', $failures, $passes ); + +echo "\n[5] retrieval_policy default uses canonical string literals:\n"; +// SITE.md was registered with modes=[MODE_ALL] → default policy is 'always'. +// RULES.md was registered with NO modes → default policy is 'never'. +mfr_smoke_assert_equals( 'always', $resolved['SITE.md']['retrieval_policy'] ?? null, 'modes present → always', $failures, $passes ); +mfr_smoke_assert_equals( 'never', $resolved['RULES.md']['retrieval_policy'] ?? null, 'modes empty → never', $failures, $passes ); + +echo "\n[6] get_for_mode() does not fatal without substrate:\n"; +$fatal = null; +try { + $injected = MemoryFileRegistry::get_for_mode( 'chat' ); +} catch ( \Throwable $e ) { + $fatal = $e; + $injected = array(); +} +mfr_smoke_assert_equals( null, $fatal, 'get_for_mode() completes without throwing', $failures, $passes ); +// SITE.md is mode=all + policy=always, so it must appear in chat mode. +mfr_smoke_assert_true( isset( $injected['SITE.md'] ), 'SITE.md is injected in chat mode', $failures, $passes ); + +echo "\n[7] deregister() and reset() do not fatal without substrate:\n"; +$fatal = null; +try { + MemoryFileRegistry::deregister( 'BOGUS.md' ); + MemoryFileRegistry::reset(); +} catch ( \Throwable $e ) { + $fatal = $e; +} +mfr_smoke_assert_equals( null, $fatal, 'deregister + reset complete without throwing', $failures, $passes ); +mfr_smoke_assert_equals( array(), MemoryFileRegistry::get_all(), 'registry is empty after reset', $failures, $passes ); + +echo "\n---\n"; +echo sprintf( "Passes: %d\n", $passes ); +echo sprintf( "Failures: %d\n", count( $failures ) ); + +if ( ! empty( $failures ) ) { + echo "\nFailed assertions:\n"; + foreach ( $failures as $label ) { + echo " - {$label}\n"; + } + exit( 1 ); +} + +echo "\nOK\n"; +exit( 0 );