From e46edb3ad55bd15dc95b3914b95add3e2fd4cad8 Mon Sep 17 00:00:00 2001 From: timothymarois Date: Tue, 27 Jan 2026 14:18:22 -0500 Subject: [PATCH 1/2] Add AgentContext serialization for queue processing Introduces toArray() and fromArray() methods to AgentContext for serializing and restoring context data, enabling safe transport through Laravel queues. Updates PendingAgentRequest with a withContext() method for applying deserialized contexts. Documentation and comprehensive tests are added to clarify and verify the serialization process, including handling of runtime-only properties. --- docs/core-concepts/agents.md | 84 ++++++++++++ src/Agents/Support/AgentContext.php | 47 +++++++ src/Agents/Support/PendingAgentRequest.php | 38 ++++++ tests/Unit/Agents/AgentContextTest.php | 146 +++++++++++++++++++++ 4 files changed, 315 insertions(+) diff --git a/docs/core-concepts/agents.md b/docs/core-concepts/agents.md index 840b766..1d16592 100644 --- a/docs/core-concepts/agents.md +++ b/docs/core-concepts/agents.md @@ -965,6 +965,90 @@ $registry->decoratorCount(); // number of registered decorators $registry->clearDecorators(); // remove all decorators ``` +## Queue Processing + +AgentContext supports serialization for queue-based async processing. This enables dispatching agent jobs to Laravel queues while Atlas handles only the context serialization—consumers manage all persistence. + +### Dispatching to Queue + +```php +// Build context and serialize for queue transport +$context = new AgentContext( + variables: [ + 'user_name' => $user->name + ], + metadata: [ + 'task_id' => $task->id, + 'user_id' => $user->id + ], +); + +// You create a job that accepts AgentContext as a constructor argument +ProcessAgentJob::dispatch( + agentKey: 'my-agent', + input: 'Generate a report', + context: $context->toArray(), +); +``` + +### Processing in Job + +Create a processing job + +```php +use Atlasphp\Atlas\Agents\Support\AgentContext; + +class ProcessAgentJob implements ShouldQueue +{ + public function __construct( + public string $agentKey, + public string $input, + public array $context, + ) {} + + public function handle(): void + { + $context = AgentContext::fromArray($this->context); + + $response = Atlas::agent($this->agentKey) + ->withContext($context) + ->chat($this->input); + + // Handle response... + } +} +``` + +### Serialization Notes + +The following properties are fully serialized: +- `messages` — Conversation history in array format +- `variables` — System prompt variable bindings +- `metadata` — Pipeline metadata +- `providerOverride` / `modelOverride` — Provider and model overrides +- `prismCalls` — Captured Prism method calls +- `tools` — Atlas tool class names + +Runtime-only properties (not serialized): +- `prismMedia` — Media attachments (must be re-attached via `withMedia()`) +- `prismMessages` — Prism message objects (rebuilt at runtime) +- `mcpTools` — MCP tools (must be resolved at runtime) + +For media attachments, store the file path in metadata and re-attach in your job: + +```php +public function handle(): void +{ + $context = AgentContext::fromArray($this->context); + $imagePath = $context->getMeta('image_path'); + + $response = Atlas::agent($this->agentKey) + ->withContext($context) + ->withMedia(Image::fromPath($imagePath)) + ->chat($this->input); +} +``` + ## API Reference ```php diff --git a/src/Agents/Support/AgentContext.php b/src/Agents/Support/AgentContext.php index 69382c8..ac125ac 100644 --- a/src/Agents/Support/AgentContext.php +++ b/src/Agents/Support/AgentContext.php @@ -325,4 +325,51 @@ public function clearMetadata(): self $this->mcpTools, ); } + + /** + * Serialize context for queue transport. + * + * Note: Runtime-only properties (prismMedia, prismMessages, mcpTools) are + * not serialized as they contain Prism objects that cannot be serialized. + * These must be re-attached after deserialization if needed. + * + * @return array + */ + public function toArray(): array + { + return [ + 'messages' => $this->messages, + 'variables' => $this->variables, + 'metadata' => $this->metadata, + 'provider_override' => $this->providerOverride, + 'model_override' => $this->modelOverride, + 'prism_calls' => $this->prismCalls, + 'tools' => $this->tools, + ]; + } + + /** + * Restore context from serialized data. + * + * Note: Runtime-only properties (prismMedia, prismMessages, mcpTools) are + * set to empty arrays. Use withMedia() or other builder methods to re-attach + * these after deserialization if needed. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + messages: $data['messages'] ?? [], + variables: $data['variables'] ?? [], + metadata: $data['metadata'] ?? [], + providerOverride: $data['provider_override'] ?? null, + modelOverride: $data['model_override'] ?? null, + prismCalls: $data['prism_calls'] ?? [], + prismMedia: [], + prismMessages: [], + tools: $data['tools'] ?? [], + mcpTools: [], + ); + } } diff --git a/src/Agents/Support/PendingAgentRequest.php b/src/Agents/Support/PendingAgentRequest.php index f63b144..9aa4b69 100644 --- a/src/Agents/Support/PendingAgentRequest.php +++ b/src/Agents/Support/PendingAgentRequest.php @@ -240,6 +240,44 @@ public function mergeMcpTools(array $tools): static return $clone; } + /** + * Apply a deserialized AgentContext to this request. + * + * Useful for queue processing where context was serialized with toArray() + * and restored with fromArray(). Applies all serializable properties: + * messages, variables, metadata, provider/model overrides, prism calls, and tools. + * + * Runtime-only properties (prismMedia, prismMessages, mcpTools) are not applied + * as they cannot be serialized. Use withMedia() or withMcpTools() to re-attach + * these after applying the context. + * + * ```php + * $context = AgentContext::fromArray($serializedContext); + * $response = Atlas::agent('my-agent') + * ->withContext($context) + * ->withMedia($image) // Re-attach media if needed + * ->chat($input); + * ``` + */ + public function withContext(AgentContext $context): static + { + $clone = clone $this; + + // Apply serializable properties + $clone->messages = $context->messages; + $clone->prismMessages = $context->prismMessages; + $clone->variables = $context->variables; + $clone->metadata = $context->metadata; + $clone->providerOverride = $context->providerOverride; + $clone->modelOverride = $context->modelOverride; + $clone->prismCalls = $context->prismCalls; + $clone->tools = $context->tools; + $clone->mcpTools = $context->mcpTools; + $clone->prismMedia = $context->prismMedia; + + return $clone; + } + /** * Execute a blocking chat with the configured agent. * diff --git a/tests/Unit/Agents/AgentContextTest.php b/tests/Unit/Agents/AgentContextTest.php index bccd020..397b188 100644 --- a/tests/Unit/Agents/AgentContextTest.php +++ b/tests/Unit/Agents/AgentContextTest.php @@ -399,3 +399,149 @@ expect($newContext->providerOverride)->toBe('anthropic'); expect($newContext->modelOverride)->toBe('claude-3'); }); + +// === Serialization Tests === + +test('toArray serializes all serializable properties', function () { + $context = new AgentContext( + messages: [['role' => 'user', 'content' => 'Hello']], + variables: ['user_id' => 123], + metadata: ['task_id' => 'abc'], + providerOverride: 'anthropic', + modelOverride: 'claude-3-opus', + prismCalls: [['method' => 'withMaxSteps', 'args' => [10]]], + tools: ['App\\Tools\\MyTool'], + ); + + $array = $context->toArray(); + + expect($array)->toBe([ + 'messages' => [['role' => 'user', 'content' => 'Hello']], + 'variables' => ['user_id' => 123], + 'metadata' => ['task_id' => 'abc'], + 'provider_override' => 'anthropic', + 'model_override' => 'claude-3-opus', + 'prism_calls' => [['method' => 'withMaxSteps', 'args' => [10]]], + 'tools' => ['App\\Tools\\MyTool'], + ]); +}); + +test('fromArray restores context from array', function () { + $data = [ + 'messages' => [['role' => 'assistant', 'content' => 'Hi there']], + 'variables' => ['name' => 'John'], + 'metadata' => ['session' => 'xyz'], + 'provider_override' => 'openai', + 'model_override' => 'gpt-4o', + 'prism_calls' => [['method' => 'usingTemperature', 'args' => [0.7]]], + 'tools' => ['App\\Tools\\ToolA', 'App\\Tools\\ToolB'], + ]; + + $context = AgentContext::fromArray($data); + + expect($context->messages)->toBe([['role' => 'assistant', 'content' => 'Hi there']]); + expect($context->variables)->toBe(['name' => 'John']); + expect($context->metadata)->toBe(['session' => 'xyz']); + expect($context->providerOverride)->toBe('openai'); + expect($context->modelOverride)->toBe('gpt-4o'); + expect($context->prismCalls)->toBe([['method' => 'usingTemperature', 'args' => [0.7]]]); + expect($context->tools)->toBe(['App\\Tools\\ToolA', 'App\\Tools\\ToolB']); +}); + +test('toArray and fromArray round-trip preserves data', function () { + $original = new AgentContext( + messages: [ + ['role' => 'user', 'content' => 'First message'], + ['role' => 'assistant', 'content' => 'Response'], + ], + variables: ['company' => 'Acme', 'tier' => 'premium'], + metadata: ['request_id' => 'req-123', 'user_id' => 456], + providerOverride: 'anthropic', + modelOverride: 'claude-sonnet-4-20250514', + prismCalls: [ + ['method' => 'withMaxSteps', 'args' => [5]], + ['method' => 'usingTemperature', 'args' => [0.5]], + ], + tools: ['App\\Tools\\SearchTool', 'App\\Tools\\CalculateTool'], + ); + + $restored = AgentContext::fromArray($original->toArray()); + + expect($restored->messages)->toBe($original->messages); + expect($restored->variables)->toBe($original->variables); + expect($restored->metadata)->toBe($original->metadata); + expect($restored->providerOverride)->toBe($original->providerOverride); + expect($restored->modelOverride)->toBe($original->modelOverride); + expect($restored->prismCalls)->toBe($original->prismCalls); + expect($restored->tools)->toBe($original->tools); +}); + +test('fromArray handles missing keys with defaults', function () { + $context = AgentContext::fromArray([]); + + expect($context->messages)->toBe([]); + expect($context->variables)->toBe([]); + expect($context->metadata)->toBe([]); + expect($context->providerOverride)->toBeNull(); + expect($context->modelOverride)->toBeNull(); + expect($context->prismCalls)->toBe([]); + expect($context->tools)->toBe([]); +}); + +test('fromArray sets non-serializable properties to empty arrays', function () { + $data = [ + 'messages' => [['role' => 'user', 'content' => 'Test']], + 'variables' => ['key' => 'value'], + ]; + + $context = AgentContext::fromArray($data); + + expect($context->prismMedia)->toBe([]); + expect($context->prismMessages)->toBe([]); + expect($context->mcpTools)->toBe([]); +}); + +test('toArray excludes runtime-only properties', function () { + $mockImage = Mockery::mock(\Prism\Prism\ValueObjects\Media\Image::class); + $mockMessage = Mockery::mock(\Prism\Prism\ValueObjects\Messages\UserMessage::class); + $mockTool = Mockery::mock(\Prism\Prism\Tool::class); + + $context = new AgentContext( + messages: [['role' => 'user', 'content' => 'Hello']], + variables: ['var' => 'value'], + prismMedia: [$mockImage], + prismMessages: [$mockMessage], + mcpTools: [$mockTool], + ); + + $array = $context->toArray(); + + expect($array)->not->toHaveKey('prism_media'); + expect($array)->not->toHaveKey('prism_messages'); + expect($array)->not->toHaveKey('mcp_tools'); + expect(array_keys($array))->toBe([ + 'messages', + 'variables', + 'metadata', + 'provider_override', + 'model_override', + 'prism_calls', + 'tools', + ]); +}); + +test('fromArray partial data preserves specified values', function () { + $data = [ + 'messages' => [['role' => 'user', 'content' => 'Hello']], + 'provider_override' => 'anthropic', + // Other keys intentionally missing + ]; + + $context = AgentContext::fromArray($data); + + expect($context->messages)->toBe([['role' => 'user', 'content' => 'Hello']]); + expect($context->providerOverride)->toBe('anthropic'); + expect($context->variables)->toBe([]); + expect($context->metadata)->toBe([]); + expect($context->modelOverride)->toBeNull(); +}); From 93421805522706111f6217bc50278b906b0a0fca Mon Sep 17 00:00:00 2001 From: timothymarois Date: Tue, 27 Jan 2026 14:31:24 -0500 Subject: [PATCH 2/2] Add tests for withContext method in PendingAgentRequest Introduces comprehensive tests for the withContext method, verifying immutable application, property propagation, chaining with other methods, and queue serialization round-trip. These tests ensure that context properties such as messages, variables, metadata, provider/model overrides, tools, prism calls, prism messages, mcpTools, and prismMedia are correctly handled. --- tests/Unit/Agents/PendingAgentRequestTest.php | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/tests/Unit/Agents/PendingAgentRequestTest.php b/tests/Unit/Agents/PendingAgentRequestTest.php index 6f8eb28..3852ee1 100644 --- a/tests/Unit/Agents/PendingAgentRequestTest.php +++ b/tests/Unit/Agents/PendingAgentRequestTest.php @@ -1075,3 +1075,209 @@ function makeMockAgentResponse(string $text, $agent, string $input, AgentContext expect($capturedContext->variables)->toBe(['name' => 'John']); expect($capturedContext->metadata)->toBe(['id' => '123']); }); + +// === withContext Tests === + +test('withContext applies context immutably', function () { + $context = new AgentContext( + messages: [['role' => 'user', 'content' => 'Hello']], + variables: ['name' => 'John'], + ); + + $request2 = $this->request->withContext($context); + + expect($request2)->not->toBe($this->request); + expect($request2)->toBeInstanceOf(PendingAgentRequest::class); +}); + +test('withContext applies all serializable properties', function () { + $context = new AgentContext( + messages: [['role' => 'user', 'content' => 'Previous']], + variables: ['name' => 'John', 'role' => 'admin'], + metadata: ['request_id' => '123', 'user_id' => 456], + providerOverride: 'anthropic', + modelOverride: 'claude-3-opus', + prismCalls: [['method' => 'usingTemperature', 'args' => [0.7]]], + tools: ['App\\Tools\\MyTool'], + ); + + $capturedContext = null; + $this->executor->shouldReceive('execute') + ->once() + ->withArgs(function ($agent, $input, $ctx) use (&$capturedContext) { + $capturedContext = $ctx; + + return true; + }) + ->andReturnUsing(function ($agent, $input, $context) { + return makeMockAgentResponse('Response', $agent, $input, $context); + }); + + $this->request + ->withContext($context) + ->chat('New message'); + + expect($capturedContext->messages)->toBe([['role' => 'user', 'content' => 'Previous']]); + expect($capturedContext->variables)->toBe(['name' => 'John', 'role' => 'admin']); + expect($capturedContext->metadata)->toBe(['request_id' => '123', 'user_id' => 456]); + expect($capturedContext->providerOverride)->toBe('anthropic'); + expect($capturedContext->modelOverride)->toBe('claude-3-opus'); + expect($capturedContext->prismCalls)->toBe([['method' => 'usingTemperature', 'args' => [0.7]]]); + expect($capturedContext->tools)->toBe(['App\\Tools\\MyTool']); +}); + +test('withContext applies prismMessages from context', function () { + $prismMessages = [ + new \Prism\Prism\ValueObjects\Messages\UserMessage('Previous message'), + ]; + + $context = new AgentContext( + prismMessages: $prismMessages, + ); + + $capturedContext = null; + $this->executor->shouldReceive('execute') + ->once() + ->withArgs(function ($agent, $input, $ctx) use (&$capturedContext) { + $capturedContext = $ctx; + + return true; + }) + ->andReturnUsing(function ($agent, $input, $context) { + return makeMockAgentResponse('Response', $agent, $input, $context); + }); + + $this->request + ->withContext($context) + ->chat('New message'); + + expect($capturedContext->prismMessages)->toBe($prismMessages); +}); + +test('withContext applies mcpTools from context', function () { + $mockTool = Mockery::mock(\Prism\Prism\Tool::class); + + $context = new AgentContext( + mcpTools: [$mockTool], + ); + + $capturedContext = null; + $this->executor->shouldReceive('execute') + ->once() + ->withArgs(function ($agent, $input, $ctx) use (&$capturedContext) { + $capturedContext = $ctx; + + return true; + }) + ->andReturnUsing(function ($agent, $input, $context) { + return makeMockAgentResponse('Response', $agent, $input, $context); + }); + + $this->request + ->withContext($context) + ->chat('Hello'); + + expect($capturedContext->mcpTools)->toHaveCount(1); + expect($capturedContext->mcpTools[0])->toBe($mockTool); +}); + +test('withContext applies prismMedia from context', function () { + $image = Image::fromUrl('https://example.com/image.png'); + + $context = new AgentContext( + prismMedia: [$image], + ); + + $capturedContext = null; + $this->executor->shouldReceive('execute') + ->once() + ->withArgs(function ($agent, $input, $ctx) use (&$capturedContext) { + $capturedContext = $ctx; + + return true; + }) + ->andReturnUsing(function ($agent, $input, $context) { + return makeMockAgentResponse('Response', $agent, $input, $context); + }); + + $this->request + ->withContext($context) + ->chat('Describe this'); + + expect($capturedContext->prismMedia)->toHaveCount(1); + expect($capturedContext->prismMedia[0])->toBe($image); +}); + +test('withContext can be chained with additional methods', function () { + $context = new AgentContext( + variables: ['name' => 'John'], + metadata: ['request_id' => '123'], + ); + + $capturedContext = null; + $this->executor->shouldReceive('execute') + ->once() + ->withArgs(function ($agent, $input, $ctx) use (&$capturedContext) { + $capturedContext = $ctx; + + return true; + }) + ->andReturnUsing(function ($agent, $input, $context) { + return makeMockAgentResponse('Response', $agent, $input, $context); + }); + + $image = Image::fromUrl('https://example.com/new-image.png'); + + $this->request + ->withContext($context) + ->withMedia($image) + ->mergeVariables(['role' => 'admin']) + ->chat('Hello'); + + // Original context values preserved + expect($capturedContext->variables)->toBe(['name' => 'John', 'role' => 'admin']); + expect($capturedContext->metadata)->toBe(['request_id' => '123']); + // New media added after context + expect($capturedContext->prismMedia)->toHaveCount(1); +}); + +test('withContext works with fromArray for queue round-trip', function () { + // Simulate queue serialization round-trip + $originalContext = new AgentContext( + messages: [['role' => 'user', 'content' => 'Hello']], + variables: ['user_id' => 123], + metadata: ['task_id' => 'abc'], + providerOverride: 'anthropic', + modelOverride: 'claude-3-opus', + prismCalls: [['method' => 'withMaxSteps', 'args' => [5]]], + tools: ['App\\Tools\\SearchTool'], + ); + + // Serialize and deserialize (simulating queue transport) + $serialized = $originalContext->toArray(); + $restoredContext = AgentContext::fromArray($serialized); + + $capturedContext = null; + $this->executor->shouldReceive('execute') + ->once() + ->withArgs(function ($agent, $input, $ctx) use (&$capturedContext) { + $capturedContext = $ctx; + + return true; + }) + ->andReturnUsing(function ($agent, $input, $context) { + return makeMockAgentResponse('Response', $agent, $input, $context); + }); + + $this->request + ->withContext($restoredContext) + ->chat('Continue conversation'); + + expect($capturedContext->messages)->toBe([['role' => 'user', 'content' => 'Hello']]); + expect($capturedContext->variables)->toBe(['user_id' => 123]); + expect($capturedContext->metadata)->toBe(['task_id' => 'abc']); + expect($capturedContext->providerOverride)->toBe('anthropic'); + expect($capturedContext->modelOverride)->toBe('claude-3-opus'); + expect($capturedContext->prismCalls)->toBe([['method' => 'withMaxSteps', 'args' => [5]]]); + expect($capturedContext->tools)->toBe(['App\\Tools\\SearchTool']); +});