Skip to content

(feat) Add tools, memory, structured output, and multi-agent handoff support - #46

Merged
rrr63 merged 3 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x
Sep 25, 2026
Merged

rrr63 merged 3 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x

Conversation

@techmahedy

Copy link
Copy Markdown
Member

Summary

This PR extracts the duplicated platform-building/message-hydration/execute/stream logic out of the five provider classes (Claude, Gemini, OpenAI, OpenRouter, SelfHost) into a new shared AbstractAgent base class, and uses that consolidation to wire in Symfony AI's Agent pipeline — unlocking tool calling, persistent memory, structured output, and multi-agent handoff, none of which the old per-provider implementation could support

Changes

  1. New AbstractAgent base class (src/AgentFactory/Agent/AbstractAgent.php): each provider now only implements buildPlatform(); everything else (message hydration, execute(), stream(), toAgent()) is shared. All five providers (Claude, Gemini, OpenAI, OpenRouter, SelfHost) were refactored to extend it
  2. Tool calling: Agent::withTools() registers #[AsTool]-annotated objects the model can call; wired through Symfony\AI\Agent\Toolbox.
  3. Memory: Agent::withMemory() / Agent::remember() inject persistent context into the system prompt via Symfony\AI\Agent\Memory\MemoryProviderInterface. Adds Doppar\AI\Memory\StoreMemoryProvider, backed by the existing StoreInterface, so remembered facts persist across requests.
  4. Structured output: Agent::asStructured() constrains and deserializes the model's response into a given DTO class/instance.
  5. Multi-agent handoff: new Doppar\AI\MultiAgent class — a fluent wrapper around Symfony AI's MultiAgent/Handoff that routes a prompt to one of several named, specialized agents based on keyword conditions, with a required fallback agent.
  6. Agent::toAgent() / named(): exposes the underlying Symfony\AI\Agent\AgentInterface and lets an agent be named, both needed for MultiAgent routing.
  7. execute()/stream() now route through the Symfony AI processor pipeline only when tools or memory are actually configured (usesAgentPipeline()), so the plain-text/plain-platform path is unchanged when those features aren't used.
  8. composer.json: added symfony/event-dispatcher (^7.3|^8.0), required for the PlatformSubscriber/structured-output event wiring now passed into each provider's platform.

@techmahedy
techmahedy requested a review from rrr63 September 23, 2026 09:29
@techmahedy techmahedy added the feat New feature label Sep 23, 2026
Comment on lines +55 to +103

/**
* Register tool objects (classes carrying #[AsTool] on themselves) the
* model may call during execute()/stream(). Each
*
* @param array<int, object> $tools
* @return static
*/
public function withTools(array $tools): static;

/**
* Register memory providers whose content is injected into the system
* prompt before every call, giving the model persistent/contextual
* information beyond the current message history.
*
* @param iterable<MemoryProviderInterface> $memoryProviders
* @return static
*/
public function withMemory(iterable $memoryProviders): static;

/**
* Request the model's response be constrained to, and deserialized into, the given shape
*
* @param string|object|null $responseFormat
* @return static
*/
public function asStructured(string|object|null $responseFormat): static;

/**
* The agent's name, used to identify it in MultiAgent handoff routing
*
* @return string
*/
public function getName(): string;

/**
* Set the agent's name (see getName()).
*
* @param string $name
* @return static
*/
public function named(string $name): static;

/**
* Expose the underlying Symfony AI agent
*
* @return SymfonyAgentInterface
*/
public function toAgent(): SymfonyAgentInterface;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could break some custom agents created by users. We should write the breaking change in the docs (or just tell them to add the missing functions)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

Comment on lines +196 to +222
public function toAgent(): SymfonyAgentInterface
{
if (null !== $this->agent) {
return $this->agent;
}

$inputProcessors = [];
$outputProcessors = [];

if ([] !== $this->memoryProviders) {
$inputProcessors[] = new MemoryInputProcessor($this->memoryProviders);
}

if ([] !== $this->tools) {
$toolProcessor = new AgentProcessor(new Toolbox($this->tools));
$inputProcessors[] = $toolProcessor;
$outputProcessors[] = $toolProcessor;
}

return $this->agent = new SymfonyAgent(
$this->getPlatform(),
$this->model,
$inputProcessors,
$outputProcessors,
$this->getName(),
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toAgent() does not preserve the format set by asStructured(). Please fix this or add a test covering asStructured()->toAgent()->call()

they should behave the same :

$agent->asStructured(MyDto::class)->execute([]);
$agent->asStructured(MyDto::class)->toAgent()->call($messages);

execute() preserves MyDto::class, while toAgent()->call() ignores it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

Comment on lines +185 to +191
public function named(string $name): static
{
$this->name = $name;
$this->agent = null;

return $this;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add this to match with MultiAgent.php and to be sure name is not empty

if ('' === $name) {
     throw new RuntimeException('$name must not be empty.');
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

@techmahedy
techmahedy requested a review from rrr63 September 25, 2026 13:02
@techmahedy

Copy link
Copy Markdown
Member Author

@rrr63 I think all are ok now

@rrr63 rrr63 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all seems ok to me, thank you @techmahedy

@techmahedy

Copy link
Copy Markdown
Member Author

all seems ok to me, thank you @techmahedy

Yes, locally tested manually, all seems ok

@rrr63
rrr63 merged commit c875a34 into doppar:4.x Sep 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants