Skip to content

Commit 6309a0b

Browse files
committed
[AI Bundle] Add chat console command for interactive AI agent conversations
1 parent de8db34 commit 6309a0b

File tree

6 files changed

+499
-1
lines changed

6 files changed

+499
-1
lines changed

src/ai-bundle/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"symfony/ai-platform": "@dev",
2020
"symfony/ai-store": "@dev",
2121
"symfony/config": "^6.4 || ^7.0",
22+
"symfony/console": "^6.4 || ^7.0",
2223
"symfony/dependency-injection": "^6.4 || ^7.0",
2324
"symfony/framework-bundle": "^6.4 || ^7.0",
2425
"symfony/string": "^6.4 || ^7.0"

src/ai-bundle/config/services.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\AI\Agent\Toolbox\ToolFactory\AbstractToolFactory;
2222
use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory;
2323
use Symfony\AI\Agent\Toolbox\ToolResultConverter;
24+
use Symfony\AI\AiBundle\Command\ChatCommand;
2425
use Symfony\AI\AiBundle\Profiler\DataCollector;
2526
use Symfony\AI\AiBundle\Profiler\TraceableToolbox;
2627
use Symfony\AI\AiBundle\Security\EventListener\IsGrantedToolAttributeListener;
@@ -137,5 +138,12 @@
137138
->set('ai.platform.token_usage_processor.gemini', GeminiTokenOutputProcessor::class)
138139
->set('ai.platform.token_usage_processor.openai', OpenAiTokenOutputProcessor::class)
139140
->set('ai.platform.token_usage_processor.vertexai', VertexAiTokenOutputProcessor::class)
141+
142+
// commands
143+
->set('ai.command.chat', ChatCommand::class)
144+
->args([
145+
tagged_locator('ai.agent', indexAttribute: 'name'),
146+
])
147+
->tag('console.command')
140148
;
141149
};

src/ai-bundle/src/AiBundle.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ private function processAgentConfig(string $name, array $config, ContainerBuilde
457457

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

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\AiBundle\Command;
13+
14+
use Symfony\AI\Agent\AgentInterface;
15+
use Symfony\AI\AiBundle\Exception\InvalidArgumentException;
16+
use Symfony\AI\Platform\Message\Message;
17+
use Symfony\AI\Platform\Message\MessageBag;
18+
use Symfony\AI\Platform\Result\TextResult;
19+
use Symfony\Component\Console\Attribute\AsCommand;
20+
use Symfony\Component\Console\Command\Command;
21+
use Symfony\Component\Console\Completion\CompletionInput;
22+
use Symfony\Component\Console\Completion\CompletionSuggestions;
23+
use Symfony\Component\Console\Input\InputArgument;
24+
use Symfony\Component\Console\Input\InputInterface;
25+
use Symfony\Component\Console\Output\OutputInterface;
26+
use Symfony\Component\Console\Question\ChoiceQuestion;
27+
use Symfony\Component\Console\Style\SymfonyStyle;
28+
use Symfony\Component\DependencyInjection\ServiceLocator;
29+
30+
/**
31+
* @author Oskar Stark <[email protected]>
32+
*/
33+
#[AsCommand(
34+
name: 'ai:chat',
35+
description: 'Chat with an agent',
36+
)]
37+
final class ChatCommand extends Command
38+
{
39+
private AgentInterface $agent;
40+
private string $agentName;
41+
42+
/**
43+
* @param ServiceLocator<AgentInterface> $agents
44+
*/
45+
public function __construct(
46+
private readonly ServiceLocator $agents,
47+
) {
48+
parent::__construct();
49+
}
50+
51+
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
52+
{
53+
if ($input->mustSuggestArgumentValuesFor('agent')) {
54+
$suggestions->suggestValues($this->getAvailableAgentNames());
55+
}
56+
}
57+
58+
protected function configure(): void
59+
{
60+
$this
61+
->addArgument('agent', InputArgument::OPTIONAL, 'The name of the agent to chat with')
62+
->setHelp(
63+
<<<'HELP'
64+
The <info>%command.name%</info> command allows you to chat with different agents.
65+
66+
Usage:
67+
<info>%command.full_name% [<agent_name>]</info>
68+
69+
Examples:
70+
<info>%command.full_name% wikipedia</info>
71+
72+
If no agent is specified, you'll be prompted to select one interactively.
73+
74+
The chat session is interactive. Type your messages and press Enter to send.
75+
Type 'exit' or 'quit' to end the conversation.
76+
HELP
77+
);
78+
}
79+
80+
protected function interact(InputInterface $input, OutputInterface $output): void
81+
{
82+
// Skip interaction in non-interactive mode
83+
if (!$input->isInteractive()) {
84+
return;
85+
}
86+
87+
$agentArg = $input->getArgument('agent');
88+
89+
// If agent is already provided and valid, nothing to do
90+
if ($agentArg) {
91+
return;
92+
}
93+
94+
$availableAgents = $this->getAvailableAgentNames();
95+
96+
if (0 === \count($availableAgents)) {
97+
throw new InvalidArgumentException('No agents are configured.');
98+
}
99+
100+
$question = new ChoiceQuestion(
101+
'Please select an agent to chat with:',
102+
$availableAgents,
103+
0
104+
);
105+
$question->setErrorMessage('Agent %s is invalid.');
106+
107+
/** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
108+
$helper = $this->getHelper('question');
109+
$selectedAgent = $helper->ask($input, $output, $question);
110+
111+
$input->setArgument('agent', $selectedAgent);
112+
}
113+
114+
protected function initialize(InputInterface $input, OutputInterface $output): void
115+
{
116+
// Initialization will be done in execute() after interact() has run
117+
}
118+
119+
protected function execute(InputInterface $input, OutputInterface $output): int
120+
{
121+
// Initialize agent (moved from initialize() to execute() so it runs after interact())
122+
$availableAgents = array_keys($this->agents->getProvidedServices());
123+
124+
if (0 === \count($availableAgents)) {
125+
throw new InvalidArgumentException('No agents are configured.');
126+
}
127+
128+
$agentArg = $input->getArgument('agent');
129+
$this->agentName = \is_string($agentArg) ? $agentArg : '';
130+
131+
// In non-interactive mode, agent is required
132+
if (!$this->agentName && !$input->isInteractive()) {
133+
throw new InvalidArgumentException(\sprintf('Agent name is required. Available agents: "%s"', implode(', ', $availableAgents)));
134+
}
135+
136+
// Validate that the agent exists if one was provided
137+
if ($this->agentName && !$this->agents->has($this->agentName)) {
138+
throw new InvalidArgumentException(\sprintf('Agent "%s" not found. Available agents: "%s"', $this->agentName, implode(', ', $availableAgents)));
139+
}
140+
141+
// If we still don't have an agent name at this point, something went wrong
142+
if (!$this->agentName) {
143+
throw new InvalidArgumentException(\sprintf('Agent name is required. Available agents: "%s"', implode(', ', $availableAgents)));
144+
}
145+
146+
$this->agent = $this->agents->get($this->agentName);
147+
148+
// Now start the chat
149+
$io = new SymfonyStyle($input, $output);
150+
151+
$io->title(\sprintf('Chat with %s Agent', $this->agentName));
152+
$io->info('Type your message and press Enter. Type "exit" or "quit" to end the conversation.');
153+
$io->newLine();
154+
155+
$messages = new MessageBag();
156+
$systemPromptDisplayed = false;
157+
158+
while (true) {
159+
$userInput = $io->ask('You');
160+
161+
if (!\is_string($userInput) || '' === trim($userInput)) {
162+
continue;
163+
}
164+
165+
if (\in_array(strtolower($userInput), ['exit', 'quit'], true)) {
166+
$io->success('Goodbye!');
167+
break;
168+
}
169+
170+
$messages->add(Message::ofUser($userInput));
171+
172+
try {
173+
$result = $this->agent->call($messages);
174+
175+
// Display system prompt after first successful call
176+
if (!$systemPromptDisplayed && null !== ($systemMessage = $messages->getSystemMessage())) {
177+
$io->section('System Prompt');
178+
$io->block($systemMessage->content, null, 'fg=gray', ' ', true);
179+
$systemPromptDisplayed = true;
180+
}
181+
182+
if ($result instanceof TextResult) {
183+
$io->write('<fg=yellow>Assistant</>:');
184+
$io->writeln('');
185+
$io->writeln($result->getContent());
186+
$io->newLine();
187+
188+
$messages->add(Message::ofAssistant($result->getContent()));
189+
} else {
190+
$io->error('Unexpected response type from agent');
191+
}
192+
} catch (\Exception $e) {
193+
$io->error(\sprintf('Error: %s', $e->getMessage()));
194+
195+
if ($output->isVerbose()) {
196+
$io->writeln('');
197+
$io->writeln('<comment>Exception trace:</comment>');
198+
$io->text($e->getTraceAsString());
199+
}
200+
}
201+
}
202+
203+
return Command::SUCCESS;
204+
}
205+
206+
/**
207+
* @return string[]
208+
*/
209+
private function getAvailableAgentNames(): array
210+
{
211+
return array_keys($this->agents->getProvidedServices());
212+
}
213+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\AiBundle\Exception;
13+
14+
/**
15+
* @author Oskar Stark <[email protected]>
16+
*/
17+
class RuntimeException extends \RuntimeException implements ExceptionInterface
18+
{
19+
}

0 commit comments

Comments
 (0)