diff --git a/README.md b/README.md index 919b2c0..5a7b3f4 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,44 @@ wp_register_agent( - `AgentsAPI\AI\Tools\ToolExecutorInterface` - `AgentsAPI\AI\Tools\ToolExecutionCore` - `AgentsAPI\AI\Tools\ToolExecutionResult` +- `AgentsAPI\Core\Workspace\AgentWorkspaceScope` - `AgentsAPI\Core\Database\Chat\ConversationTranscriptStoreInterface` - `AgentsAPI\Core\FilesRepository\AgentMemoryStoreInterface` and memory value objects +## Workspace Scope + +`AgentsAPI\Core\Workspace\AgentWorkspaceScope` is the generic workspace identity shared by memory, transcript, persistence, and audit adapters. It is deliberately broader than a WordPress site ID: + +```php +$workspace = AgentsAPI\Core\Workspace\AgentWorkspaceScope::from_parts( + 'code_workspace', + 'Automattic/intelligence@contexta8c-read-coverage' +); + +$workspace->to_array(); +// array( +// 'workspace_type' => 'code_workspace', +// 'workspace_id' => 'Automattic/intelligence@contexta8c-read-coverage', +// ) +``` + +Consumers may map WordPress sites, networks, headless runtimes, Studio sites, code workspaces, pull requests, or ephemeral execution environments into that pair. Agents API keeps those mappings in consumer adapters; the generic contracts only depend on `workspace_type` + `workspace_id`. + +Memory scope uses `(layer, workspace_type, workspace_id, user_id, agent_id, filename)` as its identity model: + +```php +$scope = new AgentsAPI\Core\FilesRepository\AgentMemoryScope( + 'user', + $workspace->workspace_type, + $workspace->workspace_id, + 123, + 456, + 'MEMORY.md' +); +``` + +Transcript sessions are also workspace-stamped. `ConversationTranscriptStoreInterface::create_session()` and `::get_recent_pending_session()` both receive an `AgentWorkspaceScope`, and `AgentConversationRequest` can carry a workspace so runtime persisters can stamp the session they materialize. + ## Execution Principals `AgentsAPI\AI\AgentExecutionPrincipal` represents the actor and agent context for one runtime request. It records the acting WordPress user ID, effective agent ID/slug, auth source, request context, optional token ID, and JSON-friendly request metadata. diff --git a/agents-api.php b/agents-api.php index ec7a65d..f23f286 100644 --- a/agents-api.php +++ b/agents-api.php @@ -36,6 +36,7 @@ require_once AGENTS_API_PATH . 'src/Registry/class-wp-agents-registry.php'; require_once AGENTS_API_PATH . 'src/Registry/register-agents.php'; require_once AGENTS_API_PATH . 'src/Packages/register-agent-package-artifacts.php'; +require_once AGENTS_API_PATH . 'src/Workspace/AgentWorkspaceScope.php'; require_once AGENTS_API_PATH . 'src/Identity/AgentIdentityScope.php'; require_once AGENTS_API_PATH . 'src/Identity/MaterializedAgentIdentity.php'; require_once AGENTS_API_PATH . 'src/Identity/MaterializedAgentIdentityStoreInterface.php'; diff --git a/composer.json b/composer.json index 5eccd16..a8cd203 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "php tests/approval-resolver-contract-smoke.php", "php tests/identity-smoke.php", "php tests/approval-action-value-shape-smoke.php", + "php tests/workspace-scope-smoke.php", "php tests/compaction-item-smoke.php", "php tests/conversation-runner-contracts-smoke.php", "php tests/conversation-compaction-smoke.php", diff --git a/src/Memory/AgentMemoryScope.php b/src/Memory/AgentMemoryScope.php index e077441..097efff 100644 --- a/src/Memory/AgentMemoryScope.php +++ b/src/Memory/AgentMemoryScope.php @@ -3,7 +3,7 @@ * Agent Memory Scope * * Immutable value object that uniquely identifies an agent memory record - * across the four-tuple primary key (layer, user_id, agent_id, filename). + * across (layer, workspace_type, workspace_id, user_id, agent_id, filename). * * Same identity model as the on-disk path encoding, just decoupled from * the filesystem so an alternate store (e.g. database-backed, guideline-backed, @@ -15,23 +15,59 @@ namespace AgentsAPI\Core\FilesRepository; +use AgentsAPI\Core\Workspace\AgentWorkspaceScope; + defined( 'ABSPATH' ) || exit; final class AgentMemoryScope { + public readonly string $layer; + + public readonly string $workspace_type; + + public readonly string $workspace_id; + + public readonly int $user_id; + + public readonly int $agent_id; + + public readonly string $filename; + /** - * @param string $layer Memory layer identifier (for example shared, agent, user, or network). - * @param int $user_id Effective WordPress user ID. 0 = legacy shared / no user. - * @param int $agent_id Agent ID for direct resolution. 0 = resolve from user_id. - * @param string $filename Filename or relative path within the layer - * (e.g. 'MEMORY.md', 'contexts/chat.md', 'daily/2026/04/17.md'). + * @param string $layer Memory layer identifier (for example shared, agent, user, or network). + * @param string $workspace_type Generic workspace kind. + * @param string $workspace_id Stable workspace identifier within the workspace type. + * @param int $user_id Effective WordPress user ID. 0 = shared / no user. + * @param int $agent_id Agent ID for direct resolution. 0 = resolve from user_id. + * @param string $filename Filename or relative path within the layer + * (e.g. 'MEMORY.md', 'contexts/chat.md', 'daily/2026/04/17.md'). */ public function __construct( - public readonly string $layer, - public readonly int $user_id, - public readonly int $agent_id, - public readonly string $filename, - ) {} + string $layer, + string $workspace_type, + string $workspace_id, + int $user_id, + int $agent_id, + string $filename, + ) { + $workspace = AgentWorkspaceScope::from_parts( $workspace_type, $workspace_id ); + + $this->layer = trim( $layer ); + $this->workspace_type = $workspace->workspace_type; + $this->workspace_id = $workspace->workspace_id; + $this->user_id = $user_id; + $this->agent_id = $agent_id; + $this->filename = trim( $filename ); + } + + /** + * Return the normalized workspace identity. + * + * @return AgentWorkspaceScope + */ + public function workspace(): AgentWorkspaceScope { + return AgentWorkspaceScope::from_parts( $this->workspace_type, $this->workspace_id ); + } /** * Stable string key for caching / map lookups. @@ -39,6 +75,6 @@ public function __construct( * @return string */ public function key(): string { - return sprintf( '%s:%d:%d:%s', $this->layer, $this->user_id, $this->agent_id, $this->filename ); + return sprintf( '%s:%s:%s:%d:%d:%s', $this->layer, $this->workspace_type, $this->workspace_id, $this->user_id, $this->agent_id, $this->filename ); } } diff --git a/src/Memory/AgentMemoryStoreInterface.php b/src/Memory/AgentMemoryStoreInterface.php index 217df2c..b5365c7 100644 --- a/src/Memory/AgentMemoryStoreInterface.php +++ b/src/Memory/AgentMemoryStoreInterface.php @@ -10,8 +10,8 @@ * - translating an {@see AgentMemoryScope} to a physical key (path, row, URL); * - returning a stable content hash so callers can implement * compare-and-swap concurrency via the `if_match` write parameter; - * - honoring the layer + user_id + agent_id + filename four-tuple as the - * identity model. + * - honoring the layer + workspace_type + workspace_id + user_id + agent_id + * + filename identity model. * * Section parsing, scaffold/default-file creation, editability gating, * ability permissions, prompt-injection policy, and registry-driven @@ -74,7 +74,7 @@ public function delete( AgentMemoryScope $scope ): AgentMemoryWriteResult; * List all top-level files in a single layer for the given identity. * * The $scope_query's `filename` field is ignored — list operations - * return all files matching `(layer, user_id, agent_id)`. The + * return all files matching `(layer, workspace_type, workspace_id, user_id, agent_id)`. The * `layer` field is required. * * Top-level only: subdirectories under the layer (e.g. `daily/`, diff --git a/src/Runtime/AgentConversationRequest.php b/src/Runtime/AgentConversationRequest.php index 5a55852..6d8f2fc 100644 --- a/src/Runtime/AgentConversationRequest.php +++ b/src/Runtime/AgentConversationRequest.php @@ -8,6 +8,7 @@ namespace AgentsAPI\AI; use AgentsAPI\AI\Tools\RuntimeToolDeclaration; +use AgentsAPI\Core\Workspace\AgentWorkspaceScope; defined( 'ABSPATH' ) || exit; @@ -40,6 +41,9 @@ class AgentConversationRequest { /** @var bool Whether to stop after one orchestration turn. */ private bool $single_turn; + /** @var AgentWorkspaceScope|null Workspace scope for persistence/audit adapters. */ + private ?AgentWorkspaceScope $workspace; + /** * @param array $messages Initial conversation messages. * @param array $tools Runtime tool declarations available to the run. @@ -48,6 +52,7 @@ class AgentConversationRequest { * @param array $metadata Caller-owned metadata. * @param int $max_turns Maximum conversation turns. * @param bool $single_turn Single-turn orchestration flag. + * @param AgentWorkspaceScope|null $workspace Workspace scope for persistence/audit adapters. */ public function __construct( array $messages, @@ -56,7 +61,8 @@ public function __construct( array $runtime_context = array(), array $metadata = array(), int $max_turns = self::DEFAULT_MAX_TURNS, - bool $single_turn = false + bool $single_turn = false, + ?AgentWorkspaceScope $workspace = null ) { $this->messages = AgentMessageEnvelope::normalize_many( $messages ); $this->tools = self::normalize_tools( $tools ); @@ -65,6 +71,7 @@ public function __construct( $this->metadata = self::normalize_json_array( $metadata, 'metadata' ); $this->max_turns = max( 1, $max_turns ); $this->single_turn = $single_turn; + $this->workspace = $workspace; } /** @return array> Initial conversation messages. */ @@ -102,6 +109,11 @@ public function singleTurn(): bool { return $this->single_turn; } + /** @return AgentWorkspaceScope|null Workspace scope for persistence/audit adapters. */ + public function workspace(): ?AgentWorkspaceScope { + return $this->workspace; + } + /** * Return a normalized array representation. * @@ -116,6 +128,7 @@ public function to_array(): array { 'metadata' => $this->metadata, 'max_turns' => $this->max_turns, 'single_turn' => $this->single_turn, + 'workspace' => $this->workspace ? $this->workspace->to_array() : null, ); } diff --git a/src/Transcripts/ConversationTranscriptStoreInterface.php b/src/Transcripts/ConversationTranscriptStoreInterface.php index 8a9188b..2c0a972 100644 --- a/src/Transcripts/ConversationTranscriptStoreInterface.php +++ b/src/Transcripts/ConversationTranscriptStoreInterface.php @@ -13,6 +13,8 @@ namespace AgentsAPI\Core\Database\Chat; +use AgentsAPI\Core\Workspace\AgentWorkspaceScope; + defined( 'ABSPATH' ) || exit; interface ConversationTranscriptStoreInterface { @@ -20,19 +22,20 @@ interface ConversationTranscriptStoreInterface { /** * Create a new conversation transcript session and return its ID. * - * @param int $user_id WordPress user ID owning the session. - * @param int $agent_id Agent ID (0 = legacy agent-less session). - * @param array $metadata Arbitrary session metadata (JSON-serializable). - * @param string $context Execution mode ('chat', 'pipeline', 'system'). + * @param AgentWorkspaceScope $workspace Workspace owning the session. + * @param int $user_id WordPress user ID owning the session. + * @param int $agent_id Agent ID (0 = agent-less session). + * @param array $metadata Arbitrary session metadata (JSON-serializable). + * @param string $context Execution mode ('chat', 'pipeline', 'system'). * @return string Session ID (UUIDv4), or empty string on failure. */ - public function create_session( int $user_id, int $agent_id = 0, array $metadata = array(), string $context = 'chat' ): string; + public function create_session( AgentWorkspaceScope $workspace, int $user_id, int $agent_id = 0, array $metadata = array(), string $context = 'chat' ): string; /** * Retrieve a transcript session by ID. * * Returns the session as an associative array with keys: - * session_id, user_id, agent_id, title, messages (decoded array), + * session_id, workspace_type, workspace_id, user_id, agent_id, title, messages (decoded array), * metadata (decoded array), provider, model, context/mode, created_at, * updated_at, last_read_at, expires_at. * @@ -64,18 +67,19 @@ public function delete_session( string $session_id ): bool; /** * Find a recent pending session for deduplication after request timeouts. * - * Returns the most recent session that belongs to $user_id, was created - * within $seconds, and is either empty or actively processing. Used by - * the orchestrator to avoid duplicate sessions when a timeout triggers a - * client retry while PHP keeps executing. + * Returns the most recent session that belongs to $workspace and $user_id, + * was created within $seconds, and is either empty or actively processing. + * Used by the orchestrator to avoid duplicate sessions when a timeout + * triggers a client retry while PHP keeps executing. * - * @param int $user_id WordPress user ID. - * @param int $seconds Lookback window (default 600 = 10 minutes). - * @param string $context Context filter. - * @param int|null $token_id Optional token ID for login-scoped dedup. + * @param AgentWorkspaceScope $workspace Workspace owning the session. + * @param int $user_id WordPress user ID. + * @param int $seconds Lookback window (default 600 = 10 minutes). + * @param string $context Context filter. + * @param int|null $token_id Optional token ID for login-scoped dedup. * @return array|null Session data or null if none. */ - public function get_recent_pending_session( int $user_id, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array; + public function get_recent_pending_session( AgentWorkspaceScope $workspace, int $user_id, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array; /** * Set a transcript session's stored display title. diff --git a/src/Workspace/AgentWorkspaceScope.php b/src/Workspace/AgentWorkspaceScope.php new file mode 100644 index 0000000..6c2e070 --- /dev/null +++ b/src/Workspace/AgentWorkspaceScope.php @@ -0,0 +1,113 @@ +validate(); + } + + /** + * Create a scope from raw values. + * + * @param string $workspace_type Generic workspace kind. + * @param string $workspace_id Stable workspace identifier. + * @return self + */ + public static function from_parts( string $workspace_type, string $workspace_id ): self { + return new self( self::normalize_type( $workspace_type ), self::normalize_id( $workspace_id ) ); + } + + /** + * Create a scope from a JSON-friendly array. + * + * @param array $value Raw workspace scope. + * @return self + */ + public static function from_array( array $value ): self { + return self::from_parts( + (string) ( $value['workspace_type'] ?? '' ), + (string) ( $value['workspace_id'] ?? '' ) + ); + } + + /** + * Return a JSON-friendly representation. + * + * @return array{workspace_type:string, workspace_id:string} + */ + public function to_array(): array { + return array( + 'workspace_type' => $this->workspace_type, + 'workspace_id' => $this->workspace_id, + ); + } + + /** + * Stable string key for caching / map lookups. + * + * @return string + */ + public function key(): string { + return $this->workspace_type . ':' . $this->workspace_id; + } + + /** + * Normalize a workspace type. + * + * @param string $workspace_type Raw workspace type. + * @return string + */ + private static function normalize_type( string $workspace_type ): string { + return strtolower( trim( $workspace_type ) ); + } + + /** + * Normalize a workspace ID. + * + * @param string $workspace_id Raw workspace ID. + * @return string + */ + private static function normalize_id( string $workspace_id ): string { + return trim( $workspace_id ); + } + + /** + * Validate the normalized scope. + * + * @return void + */ + private function validate(): void { + if ( '' === $this->workspace_type ) { + throw new \InvalidArgumentException( 'invalid_agent_workspace_scope: workspace_type must be non-empty' ); + } + + if ( 1 !== preg_match( '/^[a-z][a-z0-9_-]*$/', $this->workspace_type ) ) { + throw new \InvalidArgumentException( 'invalid_agent_workspace_scope: workspace_type must be a lowercase slug' ); + } + + if ( '' === $this->workspace_id ) { + throw new \InvalidArgumentException( 'invalid_agent_workspace_scope: workspace_id must be non-empty' ); + } + } +} diff --git a/tests/bootstrap-smoke.php b/tests/bootstrap-smoke.php index 7f10fab..a971b76 100644 --- a/tests/bootstrap-smoke.php +++ b/tests/bootstrap-smoke.php @@ -40,6 +40,7 @@ 'DataMachine\\Core\\Identity\\AgentIdentityScope' => 'AgentsAPI\\Core\\Identity\\AgentIdentityScope', 'DataMachine\\Core\\Identity\\MaterializedAgentIdentity' => 'AgentsAPI\\Core\\Identity\\MaterializedAgentIdentity', 'DataMachine\\Core\\Identity\\MaterializedAgentIdentityStoreInterface' => 'AgentsAPI\\Core\\Identity\\MaterializedAgentIdentityStoreInterface', + 'DataMachine\\Core\\Workspace\\AgentWorkspaceScope' => 'AgentsAPI\\Core\\Workspace\\AgentWorkspaceScope', 'DataMachine\\Core\\FilesRepository\\AgentMemoryStoreInterface' => 'AgentsAPI\\Core\\FilesRepository\\AgentMemoryStoreInterface', 'DataMachine\\Core\\FilesRepository\\AgentMemoryScope' => 'AgentsAPI\\Core\\FilesRepository\\AgentMemoryScope', ); @@ -118,6 +119,7 @@ 'Runtime', 'Tools', 'Transcripts', + 'Workspace', ); $actual_source_directories = array(); $source_directory_iterator = new DirectoryIterator( AGENTS_API_PATH . 'src' ); diff --git a/tests/conversation-loop-transcript-persister-smoke.php b/tests/conversation-loop-transcript-persister-smoke.php index a67631d..e55bad9 100644 --- a/tests/conversation-loop-transcript-persister-smoke.php +++ b/tests/conversation-loop-transcript-persister-smoke.php @@ -33,6 +33,7 @@ public function persist( array $messages, AgentsAPI\AI\AgentConversationRequest $this->log[] = array( 'message_count' => count( $messages ), 'request_turns' => $request->maxTurns(), + 'workspace' => $request->workspace() ? $request->workspace()->to_array() : null, 'result_keys' => array_keys( $result ), ); @@ -138,7 +139,9 @@ static function ( array $messages ): array { null, array( 'mode' => 'test' ), array(), - 3 + 3, + false, + AgentsAPI\Core\Workspace\AgentWorkspaceScope::from_parts( 'runtime', 'intelligence-chubes4' ) ); $result5 = AgentsAPI\AI\AgentConversationLoop::run( @@ -160,5 +163,15 @@ static function ( array $messages ): array { agents_api_smoke_assert_equals( 1, count( $persister_log ), 'persister was called with original request', $failures, $passes ); agents_api_smoke_assert_equals( 3, $persister_log[0]['request_turns'], 'persister received original request max_turns', $failures, $passes ); +agents_api_smoke_assert_equals( + array( + 'workspace_type' => 'runtime', + 'workspace_id' => 'intelligence-chubes4', + ), + $persister_log[0]['workspace'], + 'persister received original request workspace scope', + $failures, + $passes +); agents_api_smoke_finish( 'Agents API conversation loop transcript persister', $failures, $passes ); diff --git a/tests/conversation-runner-contracts-smoke.php b/tests/conversation-runner-contracts-smoke.php index 5796705..82a9943 100644 --- a/tests/conversation-runner-contracts-smoke.php +++ b/tests/conversation-runner-contracts-smoke.php @@ -48,9 +48,9 @@ agents_api_smoke_assert_equals( 'abc123', $request->metadata()['trace_id'], 'request preserves caller metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'client/lookup', $request->tools()[0]['name'], 'request preserves normalized tool list', $failures, $passes ); agents_api_smoke_assert_equals( - array( 'messages', 'tools', 'principal', 'runtime_context', 'metadata', 'max_turns', 'single_turn' ), + array( 'messages', 'tools', 'principal', 'runtime_context', 'metadata', 'max_turns', 'single_turn', 'workspace' ), array_keys( $request->to_array() ), - 'request array exposes only neutral runner keys', + 'request array exposes neutral runner and workspace keys', $failures, $passes ); diff --git a/tests/workspace-scope-smoke.php b/tests/workspace-scope-smoke.php new file mode 100644 index 0000000..e28b6af --- /dev/null +++ b/tests/workspace-scope-smoke.php @@ -0,0 +1,110 @@ +workspace_type, 'workspace type is normalized', $failures, $passes ); +agents_api_smoke_assert_equals( 'Automattic/intelligence@contexta8c-read-coverage', $workspace->workspace_id, 'workspace ID is trimmed and otherwise preserved', $failures, $passes ); +agents_api_smoke_assert_equals( + array( + 'workspace_type' => 'code_workspace', + 'workspace_id' => 'Automattic/intelligence@contexta8c-read-coverage', + ), + $workspace->to_array(), + 'workspace scope exports JSON-friendly fields', + $failures, + $passes +); +agents_api_smoke_assert_equals( 'code_workspace:Automattic/intelligence@contexta8c-read-coverage', $workspace->key(), 'workspace key includes type and ID', $failures, $passes ); + +echo "\n[2] Memory scope carries workspace identity in its stable key:\n"; + +$memory_scope = new AgentsAPI\Core\FilesRepository\AgentMemoryScope( + 'user', + $workspace->workspace_type, + $workspace->workspace_id, + 123, + 456, + 'daily/2026/05/04.md' +); + +agents_api_smoke_assert_equals( $workspace->to_array(), $memory_scope->workspace()->to_array(), 'memory scope exposes workspace value object', $failures, $passes ); +agents_api_smoke_assert_equals( + 'user:code_workspace:Automattic/intelligence@contexta8c-read-coverage:123:456:daily/2026/05/04.md', + $memory_scope->key(), + 'memory key includes layer, workspace, user, agent, and filename', + $failures, + $passes +); + +echo "\n[3] Conversation requests carry workspace identity for transcript persisters:\n"; + +$request = new AgentsAPI\AI\AgentConversationRequest( + array( array( 'role' => 'user', 'content' => 'hello' ) ), + array(), + null, + array( 'mode' => 'chat' ), + array(), + 2, + false, + $workspace +); + +agents_api_smoke_assert_equals( $workspace->to_array(), $request->workspace()->to_array(), 'request exposes workspace scope', $failures, $passes ); +agents_api_smoke_assert_equals( $workspace->to_array(), $request->to_array()['workspace'], 'request array includes workspace scope', $failures, $passes ); + +echo "\n[4] Transcript store contract requires workspace scope on session creation and pending dedup:\n"; + +$reflection = new ReflectionClass( AgentsAPI\Core\Database\Chat\ConversationTranscriptStoreInterface::class ); +$create_params = $reflection->getMethod( 'create_session' )->getParameters(); +$pending_params = $reflection->getMethod( 'get_recent_pending_session' )->getParameters(); +$workspace_class = AgentsAPI\Core\Workspace\AgentWorkspaceScope::class; + +agents_api_smoke_assert_equals( 'workspace', $create_params[0]->getName(), 'create_session first parameter is workspace', $failures, $passes ); +agents_api_smoke_assert_equals( $workspace_class, $create_params[0]->getType()->getName(), 'create_session workspace parameter is typed', $failures, $passes ); +agents_api_smoke_assert_equals( 'workspace', $pending_params[0]->getName(), 'get_recent_pending_session first parameter is workspace', $failures, $passes ); +agents_api_smoke_assert_equals( $workspace_class, $pending_params[0]->getType()->getName(), 'get_recent_pending_session workspace parameter is typed', $failures, $passes ); + +echo "\n[5] Invalid workspace scopes fail fast:\n"; + +$invalid_type = false; +try { + AgentsAPI\Core\Workspace\AgentWorkspaceScope::from_parts( 'site id', '217002206' ); +} catch ( InvalidArgumentException $e ) { + $invalid_type = true; +} + +$invalid_id = false; +try { + AgentsAPI\Core\Workspace\AgentWorkspaceScope::from_parts( 'site', ' ' ); +} catch ( InvalidArgumentException $e ) { + $invalid_id = true; +} + +agents_api_smoke_assert_equals( true, $invalid_type, 'workspace type must be a slug', $failures, $passes ); +agents_api_smoke_assert_equals( true, $invalid_id, 'workspace ID must be non-empty', $failures, $passes ); + +agents_api_smoke_finish( 'Agents API workspace scope', $failures, $passes );