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
51 changes: 51 additions & 0 deletions docs/registry-and-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,31 @@ Normalized package fields:

Construct from a manifest with `WP_Agent_Package::from_array( $manifest )`.

Example package artifacts should use neutral package-relative names. A package can describe workspace context that a host may preload later without Agents API knowing how that host stores, renders, or materializes it:

```php
$package = WP_Agent_Package::from_array(
array(
'slug' => 'example-assistant-package',
'version' => '1.2.3',
'agent' => array(
'slug' => 'example-assistant',
'label' => 'Example Assistant',
),
'artifacts' => array(
array(
'type' => 'example/context',
'slug' => 'workspace-context',
'label' => 'Workspace Context',
'description' => 'Context payload available to host-owned workspace preload flows.',
'source' => 'artifacts/workspace/context.md',
'requires' => array( 'workspace.context' ),
),
),
)
);
```

## Package artifacts

`WP_Agent_Package_Artifact` records identity and payload location only. Consumers own interpretation, install/update behavior, and review UI.
Expand All @@ -153,6 +178,32 @@ Core fields:

Package artifacts can describe diff callbacks through registered artifact types, which supports human-reviewable adoption/update flows without tying package review to live runtime pending-action approval.

Artifact type registrations can include `example_artifacts` so hosts, docs, and validators can publish canonical examples for a type. Examples are normalized through `WP_Agent_Package_Artifact`, must use the registered type, and follow the same package-relative source validation as real package artifacts:

```php
add_action(
'wp_agent_package_artifacts_init',
static function (): void {
wp_register_agent_package_artifact_type(
'example/context',
array(
'label' => 'Context Artifact',
'description' => 'Package-relative context payload consumed by host-owned materializers.',
'example_artifacts' => array(
array(
'slug' => 'workspace-context',
'source' => 'artifacts/workspace/context.md',
'requires' => array( 'workspace.context' ),
),
),
)
);
}
);
```

Agents API does not implement a workspace preload primitive. It only preserves package artifact identity, source, requirements, and registered type metadata so a host adapter can decide whether to preload, skip, diff, approve, or materialize the artifact.

## Package lifecycle primitives

Agents API owns storage-neutral primitives for package update planning. They do not create database rows, read package files from disk, approve changes, or apply artifacts. Consumers provide installed/current/target artifact arrays and decide how to store or display the resulting plan.
Expand Down
66 changes: 66 additions & 0 deletions src/Packages/class-wp-agent-package-artifact-type.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ final class WP_Agent_Package_Artifact_Type {
*/
private array $meta = array();

/**
* Example artifact declarations for documentation and host validation.
*
* @var array<int, array<string, mixed>>
*/
private array $example_artifacts = array();

/**
* Constructor.
*
Expand Down Expand Up @@ -104,6 +111,14 @@ public function __construct( string $type, array $args = array() ) {

$this->meta = self::prepare_meta( $args['meta'] );
}

if ( isset( $args['example_artifacts'] ) ) {
if ( ! is_array( $args['example_artifacts'] ) ) {
throw new InvalidArgumentException( 'Agent package artifact type example_artifacts property must be an array.' );
}

$this->example_artifacts = $this->prepare_example_artifacts( $args['example_artifacts'] );
}
}

/**
Expand Down Expand Up @@ -193,6 +208,15 @@ public function get_meta(): array {
return $this->meta;
}

/**
* Retrieves example artifact declarations.
*
* @return array<int, array<string, mixed>>
*/
public function get_example_artifacts(): array {
return $this->example_artifacts;
}

/**
* Exports registration arguments.
*
Expand All @@ -207,8 +231,50 @@ public function to_array(): array {
'diff_callback' => $this->diff_callback,
'import_callback' => $this->import_callback,
'delete_callback' => $this->delete_callback,
'example_artifacts' => $this->example_artifacts,
'meta' => $this->meta,
);
}

/**
* Normalizes example artifact declarations against the generic artifact shape.
*
* @param array<mixed> $examples Raw example declarations.
* @return array<int, array<string, mixed>>
*/
private function prepare_example_artifacts( array $examples ): array {
$prepared = array();
foreach ( $examples as $example ) {
if ( ! is_array( $example ) ) {
throw new InvalidArgumentException( 'Agent package artifact type example_artifacts entries must be arrays.' );
}

$artifact = WP_Agent_Package_Artifact::from_array(
array_merge(
$this->string_keyed_array( $example ),
array( 'type' => $this->type )
)
);

$prepared[] = $artifact->to_array();
}

return $prepared;
}

/**
* @param array<mixed> $values Raw values.
* @return array<string, mixed>
*/
private function string_keyed_array( array $values ): array {
$prepared = array();
foreach ( $values as $key => $value ) {
if ( is_string( $key ) ) {
$prepared[ $key ] = $value;
}
}

return $prepared;
}
}
}
21 changes: 21 additions & 0 deletions tests/no-product-imports-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
'src' => $agents_api_dir . '/src',
);

$package_example_paths = array(
'README.md' => $agents_api_dir . '/README.md',
'docs/registry-and-packages.md' => $agents_api_dir . '/docs/registry-and-packages.md',
);

$forbidden_namespaces = array(
'ExampleProduct\\Core\\Steps',
'ExampleProduct\\Core\\Database\\Jobs',
Expand Down Expand Up @@ -146,6 +151,22 @@
}

agents_api_smoke_assert_equals( array(), $matches, 'agents-api production source has no product imports, product vocabulary, admin UI registrations, or table ownership', $failures, $passes );

$package_example_matches = array();
foreach ( $package_example_paths as $relative_path => $path ) {
if ( ! is_file( $path ) ) {
continue;
}

$source = (string) file_get_contents( $path );
foreach ( $forbidden_product_vocabulary as $term ) {
if ( preg_match( '/(?<![A-Za-z0-9_])' . preg_quote( $term, '/' ) . '(?![A-Za-z0-9_])/i', $source ) ) {
$package_example_matches[] = $relative_path . ' package examples contain downstream product vocabulary: ' . $term;
}
}
}

agents_api_smoke_assert_equals( array(), $package_example_matches, 'package documentation examples use neutral artifact and package vocabulary', $failures, $passes );
agents_api_smoke_assert_equals( $forbidden_namespaces, array_values( array_unique( $forbidden_namespaces ) ), 'forbidden namespace list has no duplicates', $failures, $passes );
agents_api_smoke_assert_equals( $forbidden_product_vocabulary, array_values( array_unique( $forbidden_product_vocabulary ) ), 'forbidden product vocabulary list has no duplicates', $failures, $passes );
agents_api_smoke_assert_equals( $forbidden_admin_apis, array_values( array_unique( $forbidden_admin_apis ) ), 'forbidden admin API list has no duplicates', $failures, $passes );
Expand Down
32 changes: 32 additions & 0 deletions tests/package-lifecycle-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ static function (): void {
wp_register_agent_package_artifact_type(
'example/prompt',
array(
'example_artifacts' => array(
array(
'slug' => 'workspace-context',
'label' => 'Workspace Context',
'description' => 'Package-relative context artifact for a host-owned workspace preload flow.',
'source' => 'artifacts/workspace/context.md',
'requires' => array( 'workspace.context' ),
),
),
'validate_callback' => static function ( WP_Agent_Package_Artifact $artifact, array $context ): array {
return array( $artifact->get_slug(), $context['phase'] ?? '' );
},
Expand All @@ -206,4 +215,27 @@ static function (): void {
$callback_result = WP_Agent_Package_Artifact_Callbacks::validate( $artifact, array( 'phase' => 'install' ) );
agents_api_smoke_assert_equals( array( 'welcome', 'install' ), $callback_result, 'validate callback receives artifact and context', $failures, $passes );

$registered_type = wp_get_agent_package_artifact_type( 'example/prompt' );
$example_artifacts = null === $registered_type ? array() : $registered_type->get_example_artifacts();
agents_api_smoke_assert_equals( 'artifacts/workspace/context.md', $example_artifacts[0]['source'] ?? '', 'artifact type examples normalize package-relative workspace context sources', $failures, $passes );
agents_api_smoke_assert_equals( array( 'workspace.context' ), $example_artifacts[0]['requires'] ?? array(), 'artifact type examples normalize neutral workspace requirements', $failures, $passes );

$invalid_example_rejected = false;
try {
new WP_Agent_Package_Artifact_Type(
'example/context',
array(
'example_artifacts' => array(
array(
'slug' => 'escape-package-root',
'source' => '../outside.md',
),
),
)
);
} catch ( InvalidArgumentException $e ) {
$invalid_example_rejected = true;
}
agents_api_smoke_assert_equals( true, $invalid_example_rejected, 'artifact type examples reject package-escaping sources', $failures, $passes );

agents_api_smoke_finish( 'Agents API package lifecycle', $failures, $passes );