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
1 change: 1 addition & 0 deletions src/ai-bundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"symfony/ai-platform": "@dev",
"symfony/ai-store": "@dev",
"symfony/config": "^6.4 || ^7.0",
"symfony/console": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/string": "^6.4 || ^7.0"
Expand Down
8 changes: 8 additions & 0 deletions src/ai-bundle/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Symfony\AI\Agent\Toolbox\ToolFactory\AbstractToolFactory;
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
use Symfony\AI\Agent\Toolbox\ToolResultConverter;
use Symfony\AI\AiBundle\Command\ChatCommand;
use Symfony\AI\AiBundle\Profiler\DataCollector;
use Symfony\AI\AiBundle\Profiler\TraceableToolbox;
use Symfony\AI\AiBundle\Security\EventListener\IsGrantedToolAttributeListener;
Expand Down Expand Up @@ -137,5 +138,12 @@
->set('ai.platform.token_usage_processor.gemini', GeminiTokenOutputProcessor::class)
->set('ai.platform.token_usage_processor.openai', OpenAiTokenOutputProcessor::class)
->set('ai.platform.token_usage_processor.vertexai', VertexAiTokenOutputProcessor::class)

// commands
->set('ai.command.chat', ChatCommand::class)
->args([
tagged_locator('ai.agent', indexAttribute: 'name'),
])
->tag('console.command')
;
};
2 changes: 1 addition & 1 deletion src/ai-bundle/src/AiBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ private function processAgentConfig(string $name, array $config, ContainerBuilde

// AGENT
$agentDefinition = (new Definition(Agent::class))
->addTag('ai.agent')
->addTag('ai.agent', ['name' => $name])
->setArgument(0, new Reference($config['platform']))
->setArgument(1, new Reference('ai.agent.'.$name.'.model'));

Expand Down
213 changes: 213 additions & 0 deletions src/ai-bundle/src/Command/ChatCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\AiBundle\Command;

use Symfony\AI\Agent\AgentInterface;
use Symfony\AI\AiBundle\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\AI\Platform\Result\TextResult;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ServiceLocator;

/**
* @author Oskar Stark <oskarstark@gmail.com>
*/
#[AsCommand(
name: 'ai:chat',
description: 'Chat with an agent',
)]
final class ChatCommand extends Command
{
private AgentInterface $agent;
private string $agentName;

/**
* @param ServiceLocator<AgentInterface> $agents
*/
public function __construct(
private readonly ServiceLocator $agents,
) {
parent::__construct();
}

public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('agent')) {
$suggestions->suggestValues($this->getAvailableAgentNames());
}
}

protected function configure(): void
{
$this
->addArgument('agent', InputArgument::OPTIONAL, 'The name of the agent to chat with')
Copy link
Member

Choose a reason for hiding this comment

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

The argument should be marked as required, as you use the interact method to fill it anyway. this provides better documentation (and standard error reporting)

->setHelp(
<<<'HELP'
The <info>%command.name%</info> command allows you to chat with different agents.

Usage:
<info>%command.full_name% [<agent_name>]</info>

Examples:
<info>%command.full_name% wikipedia</info>

If no agent is specified, you'll be prompted to select one interactively.

The chat session is interactive. Type your messages and press Enter to send.
Type 'exit' or 'quit' to end the conversation.
HELP
);
}

protected function interact(InputInterface $input, OutputInterface $output): void
{
// Skip interaction in non-interactive mode
if (!$input->isInteractive()) {
Copy link
Member

Choose a reason for hiding this comment

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

useless check. The parent class already skips calling the interact method entirely.

return;
}

$agentArg = $input->getArgument('agent');

// If agent is already provided and valid, nothing to do
if ($agentArg) {
return;
}

$availableAgents = $this->getAvailableAgentNames();

if (0 === \count($availableAgents)) {
throw new InvalidArgumentException('No agents are configured.');
}

$question = new ChoiceQuestion(
'Please select an agent to chat with:',
$availableAgents,
0
);
$question->setErrorMessage('Agent %s is invalid.');

/** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
$helper = $this->getHelper('question');
$selectedAgent = $helper->ask($input, $output, $question);

$input->setArgument('agent', $selectedAgent);
}

protected function initialize(InputInterface $input, OutputInterface $output): void
{
// Initialization will be done in execute() after interact() has run
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
// Initialize agent (moved from initialize() to execute() so it runs after interact())
$availableAgents = array_keys($this->agents->getProvidedServices());

if (0 === \count($availableAgents)) {
throw new InvalidArgumentException('No agents are configured.');
}

$agentArg = $input->getArgument('agent');
$this->agentName = \is_string($agentArg) ? $agentArg : '';
Copy link
Member

Choose a reason for hiding this comment

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

Why using instance properties (making the service stateful) when they are only used locally ? You should use local variables instead.


// In non-interactive mode, agent is required
if (!$this->agentName && !$input->isInteractive()) {
throw new InvalidArgumentException(\sprintf('Agent name is required. Available agents: "%s"', implode(', ', $availableAgents)));
}

// Validate that the agent exists if one was provided
if ($this->agentName && !$this->agents->has($this->agentName)) {
throw new InvalidArgumentException(\sprintf('Agent "%s" not found. Available agents: "%s"', $this->agentName, implode(', ', $availableAgents)));
}

// If we still don't have an agent name at this point, something went wrong
if (!$this->agentName) {
throw new InvalidArgumentException(\sprintf('Agent name is required. Available agents: "%s"', implode(', ', $availableAgents)));
}

$this->agent = $this->agents->get($this->agentName);

// Now start the chat
$io = new SymfonyStyle($input, $output);

$io->title(\sprintf('Chat with %s Agent', $this->agentName));
$io->info('Type your message and press Enter. Type "exit" or "quit" to end the conversation.');
$io->newLine();

$messages = new MessageBag();
$systemPromptDisplayed = false;

while (true) {
$userInput = $io->ask('You');

if (!\is_string($userInput) || '' === trim($userInput)) {
continue;
}

if (\in_array(strtolower($userInput), ['exit', 'quit'], true)) {
$io->success('Goodbye!');
break;
}

$messages->add(Message::ofUser($userInput));

try {
$result = $this->agent->call($messages);

// Display system prompt after first successful call
if (!$systemPromptDisplayed && null !== ($systemMessage = $messages->getSystemMessage())) {
$io->section('System Prompt');
$io->block($systemMessage->content, null, 'fg=gray', ' ', true);
$systemPromptDisplayed = true;
}

if ($result instanceof TextResult) {
$io->write('<fg=yellow>Assistant</>:');
$io->writeln('');
$io->writeln($result->getContent());
$io->newLine();

$messages->add(Message::ofAssistant($result->getContent()));
} else {
$io->error('Unexpected response type from agent');
}
} catch (\Exception $e) {
$io->error(\sprintf('Error: %s', $e->getMessage()));

if ($output->isVerbose()) {
$io->writeln('');
$io->writeln('<comment>Exception trace:</comment>');
$io->text($e->getTraceAsString());
}
}
}

return Command::SUCCESS;
}

/**
* @return string[]
*/
private function getAvailableAgentNames(): array
{
return array_keys($this->agents->getProvidedServices());
}
}
19 changes: 19 additions & 0 deletions src/ai-bundle/src/Exception/RuntimeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\AiBundle\Exception;

/**
* @author Oskar Stark <oskarstark@googlemail.com>
*/
class RuntimeException extends \RuntimeException implements ExceptionInterface
{
}
Loading