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
146 changes: 136 additions & 10 deletions inc/Engine/AI/MemoryFileRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'], '/' ) : '';
Expand All @@ -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(
Expand All @@ -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.
*
Expand All @@ -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 ) );
}
}

/**
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
}
}

/**
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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;
}
Expand Down
143 changes: 143 additions & 0 deletions tests/Unit/Engine/AI/MemoryFileRegistryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php
/**
* MemoryFileRegistry unit tests — regression coverage for issue #2005.
*
* The bootstrap-fatal scenario (Agents API substrate missing) is exercised
* by the pure-PHP smoke at
* `tests/memory-file-registry-missing-agents-api-smoke.php`, which loads
* MemoryFileRegistry without `automattic/agents-api` and asserts every
* code path the playground bootstrap can hit. That smoke is the
* substrate-missing test; this file is the substrate-loaded regression test,
* proving the refactor of `register()` did not change production behavior.
*
* @package DataMachine\Tests\Unit\Engine\AI
*/

namespace DataMachine\Tests\Unit\Engine\AI;

use DataMachine\Engine\AI\MemoryFileRegistry;
use PHPUnit\Framework\TestCase;

/**
* @covers \DataMachine\Engine\AI\MemoryFileRegistry
*/
class MemoryFileRegistryTest extends TestCase {

protected function setUp(): void {
parent::setUp();
MemoryFileRegistry::reset();
}

protected function tearDown(): void {
MemoryFileRegistry::reset();
parent::tearDown();
}

/**
* register() must populate get_all() with the canonical metadata shape
* regardless of whether the Agents API substrate is loaded.
*/
public function test_register_round_trips_through_get_all(): void {
MemoryFileRegistry::register( 'SITE.md', 10, array(
'layer' => 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 );
}
}
Loading
Loading