diff --git a/src/Controller/A2AController.php b/src/Controller/A2AController.php new file mode 100644 index 000000000..c0b979ab8 --- /dev/null +++ b/src/Controller/A2AController.php @@ -0,0 +1,91 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Controller; + +use Fusio\Impl\Service\A2A; +use Fusio\Impl\Service\System\FrameworkConfig; +use JsonException; +use PSX\Api\Attribute\Incoming; +use PSX\Api\Attribute\Outgoing; +use PSX\Api\Attribute\Path; +use PSX\Api\Attribute\Post; +use PSX\Framework\Controller\ControllerAbstract; +use PSX\Http\Exception as StatusCode; +use PSX\Http\FilterChainInterface; +use PSX\Http\RequestInterface; +use PSX\Http\ResponseInterface; +use PSX\Http\Stream\StringStream; +use PSX\Json\Parser; +use PSX\Json\Rpc\Context; +use PSX\Json\Rpc\Server; +use PSX\Schema\ContentType; + +/** + * A2AController + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +class A2AController extends ControllerAbstract +{ + public function __construct( + private readonly A2A $server, + private readonly FrameworkConfig $frameworkConfig, + ) { + } + + public function getPreFilter(): array + { + $filter = parent::getPreFilter(); + $filter[] = Filter\Tenant::class; + $filter[] = Filter\Firewall::class; + + return $filter; + } + + #[Post] + #[Path('/a2a/v1')] + #[Incoming(ContentType::JSON)] + #[Outgoing(200, ContentType::JSON)] + public function handle(RequestInterface $request, ResponseInterface $response, FilterChainInterface $filterChain): void + { + if (!$this->frameworkConfig->isA2AEnabled()) { + throw new StatusCode\ServiceUnavailableException('A2A service is not enabled'); + } + + $body = (string) $request->getBody(); + + try { + $data = Parser::decode($body); + } catch (JsonException) { + throw new StatusCode\BadRequestException('Provided an invalid request payload, must be an JSON object or array'); + } + + $return = new Server($this->server)->invoke($data, new Context()); + + $response->setStatus(200); + $response->setHeader('Content-Type', 'application/json'); + $response->setBody(new StringStream(Parser::encode($return))); + + $filterChain->handle($request, $response); + } +} diff --git a/src/Controller/JsonRPCController.php b/src/Controller/JsonRPCController.php index 4ababb62f..fa146e531 100644 --- a/src/Controller/JsonRPCController.php +++ b/src/Controller/JsonRPCController.php @@ -81,7 +81,7 @@ public function handle(RequestInterface $request, ResponseInterface $response, F throw new StatusCode\BadRequestException('Provided an invalid request payload, must be an JSON object or array'); } - $return = (new Server($this->server))->invoke($data, new Context()); + $return = new Server($this->server)->invoke($data, new Context()); $response->setStatus(200); $response->setHeader('Content-Type', 'application/json'); diff --git a/src/Controller/WellKnownController.php b/src/Controller/WellKnownController.php index 87638c561..c97012cfb 100644 --- a/src/Controller/WellKnownController.php +++ b/src/Controller/WellKnownController.php @@ -20,6 +20,7 @@ namespace Fusio\Impl\Controller; +use Fusio\Impl\Service\WellKnown\AgentCard; use Fusio\Impl\Service\WellKnown\APICatalog; use Fusio\Impl\Service\WellKnown\OAuthAuthorizationServer; use Fusio\Impl\Service\WellKnown\OAuthProtectedResource; @@ -42,6 +43,7 @@ class WellKnownController extends ControllerAbstract { public function __construct( + private readonly AgentCard $agentCard, private readonly APICatalog $apiCatalog, private readonly OAuthAuthorizationServer $oauthAuthorizationServer, private readonly OAuthProtectedResource $oauthProtectedResource, @@ -50,6 +52,13 @@ public function __construct( ) { } + #[Get] + #[Path('/.well-known/agent-card.json')] + public function getAgentCard(): mixed + { + return $this->agentCard->get(); + } + #[Get] #[Path('/.well-known/api-catalog')] public function getAPICatalog(): HttpResponse diff --git a/src/Messenger/InvokeAgent.php b/src/Messenger/InvokeAgent.php new file mode 100644 index 000000000..4c7135e23 --- /dev/null +++ b/src/Messenger/InvokeAgent.php @@ -0,0 +1,49 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Messenger; + +use Fusio\Model\Agent\Input; + +/** + * InvokeAgent + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +readonly class InvokeAgent +{ + public function __construct( + private int $agentId, + private Input $input, + ) { + } + + public function getAgentId(): int + { + return $this->agentId; + } + + public function getInput(): Input + { + return $this->input; + } +} diff --git a/src/MessengerHandler/InvokeAgentHandler.php b/src/MessengerHandler/InvokeAgentHandler.php new file mode 100644 index 000000000..efbef21b3 --- /dev/null +++ b/src/MessengerHandler/InvokeAgentHandler.php @@ -0,0 +1,56 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\MessengerHandler; + +use Fusio\Impl\Messenger\InvokeAgent; +use Fusio\Impl\Service\Agent\Sender; +use Fusio\Impl\Service\System\FrameworkConfig; +use Fusio\Impl\Table; +use Fusio\Model; +use PSX\Json\Rpc\Exception\InvalidRequestException; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +/** + * InvokeAgentHandler + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +#[AsMessageHandler] +readonly class InvokeAgentHandler +{ + public function __construct(private Sender $sender, private Table\Agent $agentTable, private FrameworkConfig $frameworkConfig) + { + } + + public function __invoke(InvokeAgent $agent): void + { + $row = $this->agentTable->findOneByTenantAndId($this->frameworkConfig->getTenantId(), null, $agent->getAgentId()); + if (!$row instanceof Table\Generated\AgentRow) { + throw new InvalidRequestException('Provided an invalid agent id'); + } + + $output = $this->sender->send($row->getId(), $agent->getInput(), $context); + + // @TODO handle output + } +} diff --git a/src/Migrations/Version20230508210151.php b/src/Migrations/Version20230508210151.php index 908b984f0..d1bb2782d 100644 --- a/src/Migrations/Version20230508210151.php +++ b/src/Migrations/Version20230508210151.php @@ -88,6 +88,22 @@ public function up(Schema $schema) : void $agentMessageTable->addIndex(['agent_id', 'user_id', 'child']); } + if (!$schema->hasTable('fusio_agent_task')) { + $agentTaskTable = $schema->createTable('fusio_agent_task'); + $agentTaskTable->addColumn('id', 'integer', ['autoincrement' => true]); + $agentTaskTable->addColumn('agent_id', 'integer'); + $agentTaskTable->addColumn('user_id', 'integer'); + $agentTaskTable->addColumn('context_id', 'string', ['length' => 64, 'notnull' => false]); + $agentTaskTable->addColumn('status', 'integer', ['default' => 1]); // 1: submitted, 2: working, 3: requires_action, 4: completed, 5: failed + $agentTaskTable->addColumn('input', 'text', ['notnull' => false]); + $agentTaskTable->addColumn('output', 'text', ['notnull' => false]); + $agentTaskTable->addColumn('pending_data', 'text', ['notnull' => false]); + $agentTaskTable->addColumn('update_date', 'datetime'); + $agentTaskTable->addColumn('insert_date', 'datetime'); + $agentTaskTable->setPrimaryKey(['id']); + $agentTaskTable->addIndex(['context_id']); + } + if (!$schema->hasTable('fusio_app')) { $appTable = $schema->createTable('fusio_app'); $appTable->addColumn('id', 'integer', ['autoincrement' => true]); @@ -547,7 +563,6 @@ public function up(Schema $schema) : void $testTable->addColumn('body', 'text', ['notnull' => false]); $testTable->setPrimaryKey(['id']); $testTable->addUniqueIndex(['operation_id']); - } if (!$schema->hasTable('fusio_token')) { @@ -703,6 +718,11 @@ public function up(Schema $schema) : void $agentMessageTable->addForeignKeyConstraint($schema->getTable('fusio_user'), ['user_id'], ['id'], [], 'agent_message_user_id'); } + if (isset($agentTaskTable)) { + $agentTaskTable->addForeignKeyConstraint($schema->getTable('fusio_agent'), ['agent_id'], ['id'], [], 'agent_task_agent_id'); + $agentTaskTable->addForeignKeyConstraint($schema->getTable('fusio_user'), ['user_id'], ['id'], [], 'agent_task_user_id'); + } + if (isset($appTable)) { $appTable->addForeignKeyConstraint($schema->getTable('fusio_user'), ['user_id'], ['id'], [], 'app_user_id'); } diff --git a/src/Migrations/Version20260507203029.php b/src/Migrations/Version20260507203029.php new file mode 100644 index 000000000..e8310e18e --- /dev/null +++ b/src/Migrations/Version20260507203029.php @@ -0,0 +1,50 @@ +hasTable('fusio_agent_task')) { + $agentTaskTable = $schema->createTable('fusio_agent_task'); + $agentTaskTable->addColumn('id', 'integer', ['autoincrement' => true]); + $agentTaskTable->addColumn('agent_id', 'integer'); + $agentTaskTable->addColumn('user_id', 'integer'); + $agentTaskTable->addColumn('context_id', 'string', ['length' => 64, 'notnull' => false]); + $agentTaskTable->addColumn('status', 'integer', ['default' => 1]); // 1: submitted, 2: working, 3: requires_action, 4: completed, 5: failed + $agentTaskTable->addColumn('input', 'text', ['notnull' => false]); + $agentTaskTable->addColumn('output', 'text', ['notnull' => false]); + $agentTaskTable->addColumn('pending_data', 'text', ['notnull' => false]); + $agentTaskTable->addColumn('update_date', 'datetime'); + $agentTaskTable->addColumn('insert_date', 'datetime'); + $agentTaskTable->setPrimaryKey(['id']); + $agentTaskTable->addIndex(['context_id']); + + $agentTaskTable->addForeignKeyConstraint($schema->getTable('fusio_agent'), ['agent_id'], ['id'], [], 'agent_task_agent_id'); + $agentTaskTable->addForeignKeyConstraint($schema->getTable('fusio_user'), ['user_id'], ['id'], [], 'agent_task_user_id'); + } + } + + public function down(Schema $schema): void + { + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Service/A2A.php b/src/Service/A2A.php new file mode 100644 index 000000000..841765d1e --- /dev/null +++ b/src/Service/A2A.php @@ -0,0 +1,75 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Service; + +use Fusio\Impl\Framework\Loader\ContextFactory; +use Fusio\Impl\Service\A2A\CancelTask; +use Fusio\Impl\Service\A2A\GetTask; +use Fusio\Impl\Service\A2A\ListTasks; +use Fusio\Impl\Service\A2A\SendMessage; +use Fusio\Model; +use PSX\Json\Rpc\Context as RpcContext; +use PSX\Json\Rpc\Exception\InvalidRequestException; +use PSX\Json\Rpc\Exception\MethodNotFoundException; +use PSX\Record\Record; +use stdClass; + +/** + * A2A + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +readonly class A2A +{ + public function __construct( + private SendMessage $sendMessage, + private GetTask $getTask, + private CancelTask $cancelTask, + private ListTasks $listTasks, + private ContextFactory $contextFactory, + ) { + } + + public function __invoke(string $method, array|stdClass|null $params, RpcContext $rpcContext): mixed + { + if (is_array($params)) { + throw new InvalidRequestException('Params as array (by-position) are not supported, please use params as object (by-name)'); + } + + if ($params instanceof stdClass) { + $arguments = Record::fromObject($params); + } else { + $arguments = new Record(); + } + + $context = $this->contextFactory->getActive(); + + return match ($method) { + 'SendMessage' => $this->sendMessage->invoke($arguments, $context), + 'GetTask' => $this->getTask->invoke($arguments, $context), + 'CancelTask' => $this->cancelTask->invoke($arguments, $context), + 'ListTasks' => $this->listTasks->invoke($arguments, $context), + default => throw new MethodNotFoundException('Method not found'), + }; + } +} diff --git a/src/Service/A2A/CancelTask.php b/src/Service/A2A/CancelTask.php new file mode 100644 index 000000000..abd4283e9 --- /dev/null +++ b/src/Service/A2A/CancelTask.php @@ -0,0 +1,45 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Service\A2A; + +use Fusio\Impl\Framework\Loader\Context; +use Fusio\Impl\Service\Agent\Sender; +use Fusio\Impl\Service\System\FrameworkConfig; +use Fusio\Impl\Table; +use Fusio\Model; +use PSX\Json\Rpc\Exception\InvalidRequestException; +use PSX\Record\RecordInterface; +use stdClass; + +/** + * CancelTask + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +readonly class CancelTask +{ + public function invoke(RecordInterface $arguments, Context $context): mixed + { + return null; + } +} diff --git a/src/Service/A2A/GetTask.php b/src/Service/A2A/GetTask.php new file mode 100644 index 000000000..1a0c2285e --- /dev/null +++ b/src/Service/A2A/GetTask.php @@ -0,0 +1,45 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Service\A2A; + +use Fusio\Impl\Framework\Loader\Context; +use Fusio\Impl\Service\Agent\Sender; +use Fusio\Impl\Service\System\FrameworkConfig; +use Fusio\Impl\Table; +use Fusio\Model; +use PSX\Json\Rpc\Exception\InvalidRequestException; +use PSX\Record\RecordInterface; +use stdClass; + +/** + * GetTask + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +readonly class GetTask +{ + public function invoke(RecordInterface $arguments, Context $context): mixed + { + return null; + } +} diff --git a/src/Service/A2A/ListTasks.php b/src/Service/A2A/ListTasks.php new file mode 100644 index 000000000..8134cf14e --- /dev/null +++ b/src/Service/A2A/ListTasks.php @@ -0,0 +1,45 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Service\A2A; + +use Fusio\Impl\Framework\Loader\Context; +use Fusio\Impl\Service\Agent\Sender; +use Fusio\Impl\Service\System\FrameworkConfig; +use Fusio\Impl\Table; +use Fusio\Model; +use PSX\Json\Rpc\Exception\InvalidRequestException; +use PSX\Record\RecordInterface; +use stdClass; + +/** + * ListTasks + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +readonly class ListTasks +{ + public function invoke(RecordInterface $arguments, Context $context): mixed + { + return null; + } +} diff --git a/src/Service/A2A/SendMessage.php b/src/Service/A2A/SendMessage.php new file mode 100644 index 000000000..2379a2bdc --- /dev/null +++ b/src/Service/A2A/SendMessage.php @@ -0,0 +1,161 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Service\A2A; + +use Fusio\Impl\Framework\Loader\Context; +use Fusio\Impl\Service\Agent\Sender; +use Fusio\Impl\Service\System\FrameworkConfig; +use Fusio\Impl\Table; +use Fusio\Model; +use PSX\Json\Rpc\Exception\InvalidRequestException; +use PSX\Record\RecordInterface; +use stdClass; + +/** + * SendMessage + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +readonly class SendMessage +{ + public function __construct( + private Sender $sender, + private Table\Agent $agentTable, + private FrameworkConfig $frameworkConfig, + + ) { + } + + public function invoke(RecordInterface $arguments, Context $context): mixed + { + $skillId = $arguments->get('skillId'); + if (empty($skillId)) { + throw new InvalidRequestException('Provided no skill id'); + } + + $agent = $this->agentTable->findOneByTenantAndName($this->frameworkConfig->getTenantId(), null, $skillId); + if (!$agent instanceof Table\Generated\AgentRow) { + throw new InvalidRequestException('Provided skill id does not exist'); + } + + $message = $arguments->get('message'); + if (!$message instanceof stdClass) { + throw new InvalidRequestException('Message must be an object'); + } + + $contextId = $arguments->get('contextId'); + if (!is_string($contextId)) { + $contextId = null; + } + + $parts = $message->parts ?? null; + if (!is_array($parts)) { + throw new InvalidRequestException('Provided no parts'); + } + + $input = new Model\Agent\Input(); + $input->setPreviousId($contextId); + $input->setItem($this->buildItem($parts)); + + $output = $this->sender->send($agent->getId(), $input, $context); + + return [ + 'role' => 'agent', + 'parts' => $this->buildParts($output), + 'contextId' => $output->getId(), + ]; + } + + /** + * @throws InvalidRequestException + */ + private function buildItem(array $parts): Model\Agent\Item + { + if (count($parts) === 1) { + $part = array_first($parts); + if (!$part instanceof stdClass) { + throw new InvalidRequestException('Provided an invalid part'); + } + + return $this->buildItemFromPart($part); + } elseif (count($parts) > 1) { + $items = []; + foreach ($parts as $part) { + if (!$part instanceof stdClass) { + throw new InvalidRequestException('Provided an invalid part'); + } + + $items[] = $this->buildItemFromPart($part); + } + + $choice = new Model\Agent\ItemChoice(); + $choice->setItems($items); + return $choice; + } else { + throw new InvalidRequestException('No parts provided'); + } + } + + /** + * @throws InvalidRequestException + */ + private function buildItemFromPart(stdClass $part): Model\Agent\Item + { + $kind = $part->kind ?? null; + if ($kind === 'text') { + $content = $part->text ?? null; + + $item = new Model\Agent\ItemText(); + $item->setContent($content); + return $item; + } elseif ($kind === 'data') { + $payload = $part->data ?? null; + + $item = new Model\Agent\ItemObject(); + $item->setPayload($payload); + return $item; + } else { + throw new InvalidRequestException('Provided an not supported kind'); + } + } + + private function buildParts(Model\Agent\Output $output): array + { + $parts = []; + $item = $output->getItem(); + + if ($item instanceof Model\Agent\ItemText) { + $parts[] = [ + 'kind' => 'text', + 'text' => $item->getContent(), + ]; + } elseif ($item instanceof Model\Agent\ItemObject) { + $parts[] = [ + 'kind' => 'data', + 'data' => $item->getPayload(), + ]; + } + + return $parts; + } +} diff --git a/src/Service/System/FrameworkConfig.php b/src/Service/System/FrameworkConfig.php index f3483fdd0..97d1bb4a9 100644 --- a/src/Service/System/FrameworkConfig.php +++ b/src/Service/System/FrameworkConfig.php @@ -126,6 +126,11 @@ public function isMarketplaceEnabled(): bool return $this->config->get('fusio_marketplace') !== false; } + public function isA2AEnabled(): bool + { + return $this->config->get('fusio_a2a') === true; + } + public function isMCPEnabled(): bool { return $this->config->get('fusio_mcp') === true; diff --git a/src/Service/WellKnown/AgentCard.php b/src/Service/WellKnown/AgentCard.php new file mode 100644 index 000000000..3c7dcf349 --- /dev/null +++ b/src/Service/WellKnown/AgentCard.php @@ -0,0 +1,105 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Service\WellKnown; + +use Fusio\Impl\Service; +use Fusio\Impl\Service\System\FrameworkConfig; +use Fusio\Impl\Table; +use PSX\Sql\Condition; +use PSX\Sql\OrderBy; + +/** + * AgentCard + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +readonly class AgentCard +{ + public function __construct( + private Service\Config $configService, + private Table\Agent $agentTable, + private Table\Scope $scopeTable, + private FrameworkConfig $frameworkConfig + ) { + } + + public function get(): array + { + $scopes = $this->getScopes(); + + return [ + 'name' => $this->configService->getValue('info_title') ?: 'Fusio', + 'description' => $this->configService->getValue('info_description') ?: null, + 'url' => $this->frameworkConfig->getDispatchUrl('a2a', 'v1'), + 'skills' => $this->getSkills(), + 'auth' => [ + 'type' => 'oauth2', + 'description' => 'Fusio OAuth2 Service used to authorize AI agents', + 'flows' => [ + 'authorizationCode' => [ + 'authorizationUrl' => $this->frameworkConfig->getDispatchUrl('authorization', 'authorize'), + 'tokenUrl' => $this->frameworkConfig->getDispatchUrl('authorization', 'token'), + 'scopes' => $scopes, + ], + 'clientCredentials' => [ + 'tokenUrl' => $this->frameworkConfig->getDispatchUrl('authorization', 'token'), + 'scopes' => $scopes, + ], + ], + ], + ]; + } + + private function getSkills(): array + { + $skills = []; + + $condition = Condition::withAnd(); + $condition->equals(Table\Generated\AgentTable::COLUMN_TENANT_ID, $this->frameworkConfig->getTenantId()); + + $result = $this->agentTable->findAll($condition); + foreach ($result as $agent) { + $skills[] = [ + 'id' => $agent->getName(), + 'name' => $agent->getName(), + 'description' => $agent->getDescription(), + ]; + } + + return $skills; + } + + private function getScopes(): array + { + $condition = Condition::withAnd(); + $condition->equals('category_id', 1); + $categories = $this->scopeTable->findAll($condition, 0, 1024, Table\Generated\ScopeColumn::NAME, OrderBy::ASC); + + $result = []; + foreach ($categories as $row) { + $result[] = $row->getName(); + } + + return $result; + } +} diff --git a/src/Table/Agent.php b/src/Table/Agent.php index e86701531..5dc05972a 100644 --- a/src/Table/Agent.php +++ b/src/Table/Agent.php @@ -57,11 +57,13 @@ public function findOneByIdentifier(?string $tenantId, int $categoryId, string $ } } - public function findOneByTenantAndId(?string $tenantId, int $categoryId, int $id): ?AgentRow + public function findOneByTenantAndId(?string $tenantId, ?int $categoryId, int $id): ?AgentRow { $condition = Condition::withAnd(); $condition->equals(self::COLUMN_TENANT_ID, $tenantId); - $condition->equals(self::COLUMN_CATEGORY_ID, $categoryId); + if ($categoryId !== null) { + $condition->equals(self::COLUMN_CATEGORY_ID, $categoryId); + } $condition->equals(self::COLUMN_ID, $id); return $this->findOneBy($condition); diff --git a/tests/Controller/WellKnown/GetAgentCardTest.php b/tests/Controller/WellKnown/GetAgentCardTest.php new file mode 100644 index 000000000..78068ddef --- /dev/null +++ b/tests/Controller/WellKnown/GetAgentCardTest.php @@ -0,0 +1,128 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Fusio\Impl\Tests\System\Api\WellKnown; + +use Fusio\Impl\Tests\DbTestCase; + +/** + * GetAgentCardTest + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://www.fusio-project.org + */ +class GetAgentCardTest extends DbTestCase +{ + public function testGet(): void + { + $response = $this->sendRequest('/.well-known/agent-card.json', 'GET', [ + 'User-Agent' => 'Fusio TestCase', + ]); + + $actual = (string) $response->getBody(); + $expect = <<assertEquals(200, $response->getStatusCode(), $actual); + $this->assertJsonStringEqualsJsonString($expect, $actual, $actual); + } + + public function testPost(): void + { + $response = $this->sendRequest('/.well-known/api-catalog', 'POST', [ + 'User-Agent' => 'Fusio TestCase', + ], json_encode([ + 'foo' => 'bar', + ])); + + $body = (string) $response->getBody(); + + $this->assertEquals(404, $response->getStatusCode(), $body); + } + + public function testPut(): void + { + $response = $this->sendRequest('/.well-known/api-catalog', 'PUT', [ + 'User-Agent' => 'Fusio TestCase', + ], json_encode([ + 'foo' => 'bar', + ])); + + $body = (string) $response->getBody(); + + $this->assertEquals(404, $response->getStatusCode(), $body); + } + + public function testDelete(): void + { + $response = $this->sendRequest('/.well-known/api-catalog', 'DELETE', [ + 'User-Agent' => 'Fusio TestCase', + ], json_encode([ + 'foo' => 'bar', + ])); + + $body = (string) $response->getBody(); + + $this->assertEquals(404, $response->getStatusCode(), $body); + } +}