From 5c3c24fee2a2fcabf8dbe905534e869528c6519b Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 19:07:42 +0200 Subject: [PATCH 1/4] feat(agent): add agent user management, allow @mentions to agents, responses in 1-1 rooms, reactions, agency confirmations Signed-off-by: Julien Veyssier --- appinfo/info.xml | 2 +- appinfo/routes.php | 5 + lib/AppInfo/Application.php | 7 + lib/Controller/AgentsApiController.php | 189 +++ lib/Db/Agent.php | 58 + lib/Db/AgentMapper.php | 104 ++ lib/Db/ChattyLLM/Session.php | 62 +- lib/Db/ChattyLLM/SessionMapper.php | 56 + lib/Listener/ChattyLLMTaskListener.php | 28 +- lib/Listener/Talk/AgentMentionListener.php | 46 + lib/Listener/Talk/AgentReactionListener.php | 68 + .../Version040000Date20261023083738.php | 77 ++ .../Version040000Date20261023120000.php | 51 + lib/Service/AgentTalkService.php | 659 ++++++++++ lib/Service/AgentUserException.php | 13 + lib/Service/AgentUserService.php | 53 + lib/Service/AgentsService.php | 141 +++ lib/Service/ChatService.php | 21 +- lib/Settings/Admin.php | 22 + openapi.json | 1093 ++++++++++++++++- src/components/AdminSettings.vue | 24 + src/components/TalkAgentUsers.vue | 343 ++++++ .../Listener/ChattyLLMTaskListenerTest.php | 2 + tests/unit/Service/AgentUserServiceTest.php | 58 + tests/unit/Service/AgentsServiceTest.php | 127 ++ 25 files changed, 3296 insertions(+), 13 deletions(-) create mode 100644 lib/Controller/AgentsApiController.php create mode 100644 lib/Db/Agent.php create mode 100644 lib/Db/AgentMapper.php create mode 100644 lib/Listener/Talk/AgentMentionListener.php create mode 100644 lib/Listener/Talk/AgentReactionListener.php create mode 100644 lib/Migration/Version040000Date20261023083738.php create mode 100644 lib/Migration/Version040000Date20261023120000.php create mode 100644 lib/Service/AgentTalkService.php create mode 100644 lib/Service/AgentUserException.php create mode 100644 lib/Service/AgentUserService.php create mode 100644 lib/Service/AgentsService.php create mode 100644 src/components/TalkAgentUsers.vue create mode 100644 tests/unit/Service/AgentUserServiceTest.php create mode 100644 tests/unit/Service/AgentsServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 186b0288c..e87e2fb3d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -62,7 +62,7 @@ Known providers: More details on how to set this up in the [admin docs](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html) ]]> - 4.0.0 + 4.1.0 agpl Julien Veyssier Assistant diff --git a/appinfo/routes.php b/appinfo/routes.php index 3dc4df1d0..1b3b8e654 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -42,6 +42,11 @@ ['name' => 'agentSkillsApi#storeSkill', 'url' => '/api/{apiVersion}/skills', 'verb' => 'POST', 'requirements' => $requirements], ['name' => 'agentSkillsApi#loadSkill', 'url' => '/api/{apiVersion}/skills/{skillName}', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'agentsApi#listAgents', 'url' => '/api/{apiVersion}/agents', 'verb' => 'GET', 'requirements' => $requirements], + ['name' => 'agentsApi#createAgent', 'url' => '/api/{apiVersion}/agents', 'verb' => 'POST', 'requirements' => $requirements], + ['name' => 'agentsApi#updateAgent', 'url' => '/api/{apiVersion}/agents/{id}', 'verb' => 'PUT', 'requirements' => $requirements], + ['name' => 'agentsApi#deleteAgent', 'url' => '/api/{apiVersion}/agents/{id}', 'verb' => 'DELETE', 'requirements' => $requirements], + ['name' => 'chattyLLM#newSession', 'url' => '/chat/sessions', 'verb' => 'POST', 'postfix' => 'restful'], ['name' => 'chattyLLM#updateChatSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'PUT', 'postfix' => 'restful'], ['name' => 'chattyLLM#deleteSession', 'url' => '/chat/sessions/{sessionId}', 'verb' => 'DELETE', 'postfix' => 'restful'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 228b846b8..32a9ed9f4 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -18,6 +18,8 @@ use OCA\Assistant\Listener\NewFileMenuTaskFailedListener; use OCA\Assistant\Listener\NewFileMenuTaskSuccessfulListener; use OCA\Assistant\Listener\SpeechToText\SpeechToTextReferenceListener; +use OCA\Assistant\Listener\Talk\AgentMentionListener; +use OCA\Assistant\Listener\Talk\AgentReactionListener; use OCA\Assistant\Listener\TaskFailedListener; use OCA\Assistant\Listener\TaskOutputFileReferenceListener; use OCA\Assistant\Listener\TaskSuccessfulListener; @@ -108,6 +110,11 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); + // the Talk event classes are not necessarily autoloadable when this app + // file is registered, the listeners themselves guard for Talk being absent + $context->registerEventListener(\OCA\Talk\Events\ChatMessageSentEvent::class, AgentMentionListener::class); + $context->registerEventListener(\OCA\Talk\Events\ReactionAddedEvent::class, AgentReactionListener::class); + if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) { $context->registerTaskProcessingProvider(AudioToAudioChatProvider::class); } diff --git a/lib/Controller/AgentsApiController.php b/lib/Controller/AgentsApiController.php new file mode 100644 index 000000000..3176e44c7 --- /dev/null +++ b/lib/Controller/AgentsApiController.php @@ -0,0 +1,189 @@ + + */ + private function serializeAgents(): array { + $agents = $this->agentsService->getAllAgents(); + $result = []; + foreach ($agents as $agent) { + $result[] = $this->serializeAgent($agent); + } + return $result; + } + + private function serializeAgent(Agent $agent): array { + $user = $this->userManager->get($agent->getUserId()); + return [ + 'id' => (int)$agent->getId(), + 'user_id' => $agent->getUserId(), + 'display_name' => $user?->getDisplayName() ?: $agent->getUserId(), + 'personality' => $agent->getPersonality(), + 'scope' => $agent->getScope(), + ]; + } + + private function checkAdmin(): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + return $this->groupManager->isInGroup($user->getUID(), 'admin'); + } + + /** + * List all agents (admin only) + * + * @return DataResponse}, array{}>|DataResponse + * + * 200: Agents returned + * 403: User is not an admin + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function listAgents(): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + return new DataResponse(['agents' => $this->serializeAgents()]); + } + + /** + * Create an agent (admin only) + * + * A dedicated Nextcloud user is created in the default database backend if it does not exist yet. + * + * @param string $name The name of the agent + * @param string $userId The user id of the agent's dedicated Nextcloud user + * @param string $personality The agent's system prompt + * @param string $scope Optional scope for later use + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 201: Agent created + * 400: Invalid data + * 403: User is not an admin + * 500: Agent creation failed + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function createAgent(string $name = '', string $userId = '', string $personality = '', string $scope = ''): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + $name = trim($name); + $userId = trim($userId); + if ($name === '' || $userId === '') { + return new DataResponse(['error' => $this->l10n->t('Name and user id are required')], Http::STATUS_BAD_REQUEST); + } + if (strlen($personality) > 64_000) { + return new DataResponse(['error' => $this->l10n->t('The agent personality is too long')], Http::STATUS_BAD_REQUEST); + } + try { + $agent = $this->agentsService->createAgent($name, $userId, $personality, $scope); + return new DataResponse(['agent' => $this->serializeAgent($agent)], Http::STATUS_CREATED); + } catch (AgentUserException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\Exception|Throwable $e) { + return new DataResponse(['error' => $this->l10n->t('Failed to create the agent')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update an agent (admin only) + * + * @param int $id The agent id + * @param string|null $personality The new personality (system prompt) + * @param string|null $scope The new scope + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 200: Agent updated + * 400: Invalid data + * 403: User is not an admin + * 404: Agent not found + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function updateAgent(int $id, ?string $personality = null, ?string $scope = null): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + $agent = $this->agentsService->getAgentById($id); + if ($agent === null) { + return new DataResponse(['error' => $this->l10n->t('Agent not found')], Http::STATUS_NOT_FOUND); + } + try { + $agent = $this->agentsService->updateAgent($id, $personality, $scope); + return new DataResponse(['agent' => $this->serializeAgent($agent)]); + } catch (AgentUserException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\Exception|Throwable $e) { + return new DataResponse(['error' => $this->l10n->t('Failed to update the agent')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete an agent (admin only) + * + * @param int $id The agent id + * @param int $deleteUser Whether the related Nextcloud user should be deleted too + * @return DataResponse|DataResponse|DataResponse|DataResponse + * + * 200: Agent deleted + * 403: User is not an admin + * 404: Agent not found + * 500: Agent deletion failed + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] + public function deleteAgent(int $id, int $deleteUser = 0): DataResponse { + if (!$this->checkAdmin()) { + return new DataResponse(['error' => $this->l10n->t('Only administrators can manage agents')], Http::STATUS_FORBIDDEN); + } + $agent = $this->agentsService->getAgentById($id); + if ($agent === null) { + return new DataResponse(['error' => $this->l10n->t('Agent not found')], Http::STATUS_NOT_FOUND); + } + try { + $this->agentsService->deleteAgent($id, $deleteUser === 1); + return new DataResponse(['action' => 'deleted']); + } catch (\Exception|Throwable $e) { + return new DataResponse(['error' => $this->l10n->t('Failed to delete the agent')], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/lib/Db/Agent.php b/lib/Db/Agent.php new file mode 100644 index 000000000..d8fd83e57 --- /dev/null +++ b/lib/Db/Agent.php @@ -0,0 +1,58 @@ +addType('userId', Types::STRING); + $this->addType('personality', Types::TEXT); + $this->addType('scope', Types::TEXT); + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return [ + 'id' => $this->getId(), + 'user_id' => $this->getUserId(), + 'personality' => $this->getPersonality(), + 'scope' => $this->getScope(), + ]; + } +} diff --git a/lib/Db/AgentMapper.php b/lib/Db/AgentMapper.php new file mode 100644 index 000000000..4fd8acc18 --- /dev/null +++ b/lib/Db/AgentMapper.php @@ -0,0 +1,104 @@ + + */ +class AgentMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'assistant_agents', Agent::class); + } + + /** + * @param int $id + * @return Agent + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getById(int $id): Agent { + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($id, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + /** + * @return list + * @throws Exception + */ + public function getAllAgents(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->orderBy('id', 'ASC'); + return $this->findEntities($qb); + } + + /** + * @param string $userId + * @return Agent + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getByUserId(string $userId): Agent { + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))); + return $this->findEntity($qb); + } + + /** + * Whether a list of user ids contains registered agents, keyed by user id + * + * @param list $userIds + * @return array + * @throws Exception + */ + public function getByUserIds(array $userIds): array { + if ($userIds === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select(Agent::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->in('user_id', $qb->createParameter('userIds'))) + ->setParameter('userIds', $userIds, IQueryBuilder::PARAM_STR_ARRAY); + $result = $qb->executeQuery(); + $agents = []; + while (($row = $result->fetchAssociative()) !== false) { + $agent = $this->mapRowToEntity($row); + $agents[$agent->getUserId()] = $agent; + } + $result->closeCursor(); + return $agents; + } + + /** + * @param Agent $agent + * @return Agent + * @throws Exception + */ + public function deleteAgent(Agent $agent): Agent { + $this->delete($agent); + return $agent; + } +} diff --git a/lib/Db/ChattyLLM/Session.php b/lib/Db/ChattyLLM/Session.php index 1541aaac3..5e50fa6e5 100644 --- a/lib/Db/ChattyLLM/Session.php +++ b/lib/Db/ChattyLLM/Session.php @@ -27,6 +27,16 @@ * @method \void setAgencyPendingActions(?string $agencyPendingActions) * @method \void setAssignmentId(?int $id) * @method \int|null getAssignmentId() + * @method \string|null getAgentUserId() + * @method \void setAgentUserId(?string $agentUserId) + * @method \string|null getRoomToken() + * @method \void setRoomToken(?string $roomToken) + * @method \int|null getThreadId() + * @method \void setThreadId(?int $threadId) + * @method \string|null getAgentSeenRoomToken() + * @method \void setAgentSeenRoomToken(?string $agentSeenRoomToken) + * @method \int|null getAgentSeenCommentId() + * @method \void setAgentSeenCommentId(?int $agentSeenCommentId) */ class Session extends Entity implements \JsonSerializable { /** @var string */ @@ -63,6 +73,34 @@ class Session extends Entity implements \JsonSerializable { */ protected $assignmentId; + /** + * When the session is used by an agent through Talk: the agent's NC user id + * @var ?string + */ + protected $agentUserId; + + /** + * When the session is used by an agent through Talk: the token of the 1-1 Talk room + * @var ?string + */ + protected $roomToken; + + /** + * When the session is used by an agent through Talk: the id of the Talk thread for this session + * @var ?int + */ + protected $threadId; + + /** + * The Talk room + comment of the last πŸ‘€ reaction of the agent on a human message, + * replaced by βœ… or πŸ”΄ once the requested message generation completed/failed + * @var ?string + */ + protected $agentSeenRoomToken; + + /** @var ?int */ + protected $agentSeenCommentId; + public static $columns = [ 'id', 'user_id', @@ -73,7 +111,12 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'is_summary_up_to_date', 'is_remembered', - 'assignment_id' + 'assignment_id', + 'agent_user_id', + 'room_token', + 'thread_id', + 'agent_seen_room_token', + 'agent_seen_comment_id', ]; public static $fields = [ 'id', @@ -85,7 +128,12 @@ class Session extends Entity implements \JsonSerializable { 'summary', 'isSummaryUpToDate', 'isRemembered', - 'assignmentId' + 'assignmentId', + 'agentUserId', + 'roomToken', + 'threadId', + 'agentSeenRoomToken', + 'agentSeenCommentId', ]; public function __construct() { @@ -98,6 +146,11 @@ public function __construct() { $this->addType('isSummaryUpToDate', Types::SMALLINT); $this->addType('isRemembered', Types::SMALLINT); $this->addType('assignmentId', Types::BIGINT); + $this->addType('agentUserId', Types::STRING); + $this->addType('roomToken', Types::STRING); + $this->addType('threadId', Types::BIGINT); + $this->addType('agentSeenRoomToken', Types::STRING); + $this->addType('agentSeenCommentId', Types::BIGINT); } #[\ReturnTypeWillChange] @@ -113,6 +166,11 @@ public function jsonSerialize() { 'is_summary_up_to_date' => $this->getIsSummaryUpToDate(), 'is_remembered' => $this->getIsRemembered(), 'assignment_id' => $this->getAssignmentId(), + 'agent_user_id' => $this->getAgentUserId(), + 'room_token' => $this->getRoomToken(), + 'thread_id' => $this->getThreadId(), + 'agent_seen_room_token' => $this->getAgentSeenRoomToken(), + 'agent_seen_comment_id' => $this->getAgentSeenCommentId(), ]; } diff --git a/lib/Db/ChattyLLM/SessionMapper.php b/lib/Db/ChattyLLM/SessionMapper.php index 5c089c9bd..436cea20e 100644 --- a/lib/Db/ChattyLLM/SessionMapper.php +++ b/lib/Db/ChattyLLM/SessionMapper.php @@ -82,6 +82,62 @@ public function getUserSessionForAssignment(string $userId, int $assignmentId): return $this->findEntity($qb); } + /** + * @param string $userId + * @param string $agentUserId + * @param string $roomToken + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getAgentSession(string $userId, string $agentUserId, string $roomToken): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('agent_user_id', $qb->createPositionalParameter($agentUserId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('room_token', $qb->createPositionalParameter($roomToken, IQueryBuilder::PARAM_STR))) + ->orderBy('timestamp', 'DESC') + ->setMaxResults(1); + return $this->findEntity($qb); + } + + /** + * @param string $userId + * @param int $threadId The Talk thread id (same as the id of the thread starter Talk comment) + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getAgentSessionByThread(string $userId, int $threadId): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('thread_id', $qb->createPositionalParameter($threadId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNotNull('agent_user_id')) + ->orderBy('timestamp', 'DESC') + ->setMaxResults(1); + return $this->findEntity($qb); + } + + /** + * @param int $sessionId + * @return Session + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function getSessionById(int $sessionId): Session { + $qb = $this->db->getQueryBuilder(); + $qb->select(Session::$columns) + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createPositionalParameter($sessionId, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + /** * @return \Generator * @throws \OCP\DB\Exception diff --git a/lib/Listener/ChattyLLMTaskListener.php b/lib/Listener/ChattyLLMTaskListener.php index c1dcc0580..969592313 100644 --- a/lib/Listener/ChattyLLMTaskListener.php +++ b/lib/Listener/ChattyLLMTaskListener.php @@ -13,6 +13,7 @@ use OCA\Assistant\Db\ChattyLLM\Message; use OCA\Assistant\Db\ChattyLLM\MessageMapper; use OCA\Assistant\Db\ChattyLLM\SessionMapper; +use OCA\Assistant\Service\AgentTalkService; use OCA\Assistant\Service\NotificationService; use OCA\Assistant\Service\TaskProcessingService; use OCP\AppFramework\Db\DoesNotExistException; @@ -32,6 +33,7 @@ public function __construct( private MessageMapper $messageMapper, private SessionMapper $sessionMapper, private TaskProcessingService $taskProcessingService, + private AgentTalkService $agentTalkService, private LoggerInterface $logger, private NotificationService $notificationService, ) { @@ -43,13 +45,27 @@ public function handle(Event $event): void { $customId = $task->getCustomId(); if (preg_match('/^chatty-llm:(\d+)/', $customId, $matches)) { $sessionId = (int)$matches[1]; - $session = $this->sessionMapper->getUserSession($task->getUserId(), $sessionId); + try { + $session = $this->sessionMapper->getUserSession($task->getUserId(), $sessionId); + } catch (\OCP\AppFramework\Db\DoesNotExistException) { + return; + } catch (\OCP\AppFramework\Db\MultipleObjectsReturnedException|\OCP\DB\Exception $e) { + $this->logger->error('Could not load the session of a failed chat task', ['exception' => $e]); + return; + } $assignmentId = $session->getAssignmentId(); if ($assignmentId !== null) { $this->notificationService->sendAssignmentNotification( $task->getUserId(), $task, $session ); } + // in the agent-as-a-user feature, the πŸ‘€ reaction of the last message + // becomes πŸ”΄ when the generation failed + try { + $this->agentTalkService->markGenerationFailed($task->getUserId(), $sessionId); + } catch (\Throwable $e) { + $this->logger->error('Failed to mark the agent message as failed in Talk', ['exception' => $e]); + } } return; } @@ -180,6 +196,16 @@ public function handle(Event $event): void { } $this->sessionMapper->update($session); + + // in the agent-as-a-user feature, the generated message is posted + // in the 1-1 Talk conversation with the human user + if ($session->getAgentUserId() !== null) { + $this->agentTalkService->postAgentTalkReply( + $session, + $message->getContent(), + $taskOutput['actions'] ?? null, + ); + } } } diff --git a/lib/Listener/Talk/AgentMentionListener.php b/lib/Listener/Talk/AgentMentionListener.php new file mode 100644 index 000000000..ff38fca0f --- /dev/null +++ b/lib/Listener/Talk/AgentMentionListener.php @@ -0,0 +1,46 @@ + + * + * Handles messages sent by users in Talk conversations with agents: + * the agent answer is generated in a classic Assistant chat session + * and posts back in the 1-1 Talk conversation with the human user. + */ +class AgentMentionListener implements IEventListener { + + public function __construct( + private AgentTalkService $agentTalkService, + private LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + */ + public function handle(Event $event): void { + if (!class_exists('OCA\Talk\Events\ChatMessageSentEvent') + || !$event instanceof \OCA\Talk\Events\ChatMessageSentEvent) { + return; + } + try { + $this->agentTalkService->handleIncomingTalkMessage($event->getRoom(), $event->getComment()); + } catch (\Throwable $e) { + $this->logger->error('Failed to react to a Talk chat message for an agent', ['exception' => $e]); + } + } +} diff --git a/lib/Listener/Talk/AgentReactionListener.php b/lib/Listener/Talk/AgentReactionListener.php new file mode 100644 index 000000000..36c432a95 --- /dev/null +++ b/lib/Listener/Talk/AgentReactionListener.php @@ -0,0 +1,68 @@ + + * + * Handles the reactions of human users on confirmation-related agent Talk messages: + * they are sent back to the agent context as approve/reject signals. + */ +class AgentReactionListener implements IEventListener { + + public function __construct( + private AgentTalkService $agentTalkService, + private \OCA\Assistant\Service\AgentsService $agentsService, + private LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + */ + public function handle(Event $event): void { + if (!class_exists('OCA\Talk\Events\ReactionAddedEvent') + || !$event instanceof \OCA\Talk\Events\ReactionAddedEvent) { + return; + } + try { + $reaction = $event->getReaction(); + if ($event->getActorType() !== 'users') { + return; + } + // ignore reactions added by agent users themselves + if ($this->agentsService->getAgentByUserId($event->getActorId()) !== null) { + return; + } + $comment = $event->getMessage(); + $referenceId = $comment->getReferenceId(); + if (!preg_match('/^' . AgentTalkService::CONFIRMATION_REFERENCE_PREFIX . '(\d+)(:[0-9]+)?$/', $referenceId ?? '', $matches)) { + return; + } + if (!in_array($reaction, [AgentTalkService::REACTION_APPROVE, AgentTalkService::REACTION_REJECT], true)) { + return; + } + $this->agentTalkService->handleConfirmationReaction( + $event->getActorId(), + (int)$matches[1], + (int)$comment->getId(), + $reaction, + $event->getRoom(), + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to react to a Talk reaction for an agent', ['exception' => $e]); + } + } +} diff --git a/lib/Migration/Version040000Date20261023083738.php b/lib/Migration/Version040000Date20261023083738.php new file mode 100644 index 000000000..e32b098cf --- /dev/null +++ b/lib/Migration/Version040000Date20261023083738.php @@ -0,0 +1,77 @@ +hasTable('assistant_agents')) { + $table = $schema->createTable('assistant_agents'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('personality', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('scope', Types::TEXT, [ + 'notnull' => false, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id'], 'assistant_agents_user_id'); + $schemaChanged = true; + } + + if ($schema->hasTable('assistant_chat_sns')) { + $table = $schema->getTable('assistant_chat_sns'); + if (!$table->hasColumn('agent_user_id')) { + $table->addColumn('agent_user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('room_token', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('thread_id', Types::BIGINT, [ + 'notnull' => false, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addIndex(['user_id', 'agent_user_id', 'room_token'], 'assistant_chat_agent_idx'); + $schemaChanged = true; + } + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/Migration/Version040000Date20261023120000.php b/lib/Migration/Version040000Date20261023120000.php new file mode 100644 index 000000000..a9c8ea6a8 --- /dev/null +++ b/lib/Migration/Version040000Date20261023120000.php @@ -0,0 +1,51 @@ +hasTable('assistant_chat_sns')) { + $table = $schema->getTable('assistant_chat_sns'); + // the Talk room + message of the last πŸ‘€ reaction of the agent, + // so it can be replaced by the generated answer state (βœ…/πŸ”΄) + if (!$table->hasColumn('agent_seen_room_token')) { + $table->addColumn('agent_seen_room_token', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + $table->addColumn('agent_seen_comment_id', Types::BIGINT, [ + 'notnull' => false, + 'length' => 20, + 'unsigned' => true, + ]); + $schemaChanged = true; + } + } + + return $schemaChanged ? $schema : null; + } +} diff --git a/lib/Service/AgentTalkService.php b/lib/Service/AgentTalkService.php new file mode 100644 index 000000000..655e814a1 --- /dev/null +++ b/lib/Service/AgentTalkService.php @@ -0,0 +1,659 @@ +getActorType(); + $authorActorId = $comment->getActorId(); + if ($authorActorType !== 'users') { + return; + } + // Ignore messages sent by agent users to avoid loops + if ($this->agentsService->getAgentByUserId($authorActorId) !== null) { + return; + } + + $agent = $this->getAgentTarget($room, $comment, $authorActorId); + if ($agent === null) { + return; + } + + $humanUser = $this->userManager->get($authorActorId); + if ($humanUser === null) { + return; + } + + $this->processAgentInteraction( + $room, + $agent, + $humanUser, + $comment, + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to handle a Talk message for an agent', ['exception' => $e]); + } + } + + /** + * Find the agent user targeted by a Talk message: + * - in a 1-1 conversation: the other attendee, if it is a registered agent + * - in a group room: a mentioned registered agent that is a participant of the room. + * The agent must always answer in the private 1-1 conversation + * with the user who sent the message. + */ + private function getAgentTarget( + \OCA\Talk\Room $room, + IComment $comment, + string $authorActorId, + ): ?Agent { + if ($room->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) { + $partnerUserId = $this->getOneToOnePartnerUserId($room, $authorActorId); + if ($partnerUserId !== null) { + return $this->agentsService->getAgentByUserId($partnerUserId); + } + return null; + } + + // In a group room the mentioned agent must already be a participant of the room + /** @var \OCA\Talk\Manager $talkManager */ + $talkManager = Server::get(\OCA\Talk\Manager::class); + $mentions = $comment->getMentions(); + foreach ($mentions as $mention) { + if ($mention['type'] !== 'user') { + continue; + } + $mentionId = $mention['id']; + if ($this->agentsService->getAgentByUserId($mentionId) !== null + && $talkManager->isUserAttendeeInRoom($mentionId, $room->getToken())) { + return $this->agentsService->getAgentByUserId($mentionId); + } + } + return null; + } + + /** + * @return string|null The user id of the other actor in the room + */ + private function getOneToOnePartnerUserId(\OCA\Talk\Room $room, string $authorUserId): ?string { + try { + return $room->getSecondParticipant($authorUserId); + } catch (\InvalidArgumentException|JsonException $e) { + return null; + } + } + + /** + * @throws Exception + * @throws InternalException + * @throws UnauthorizedException + * @throws NotFoundException + * @throws BadRequestException + */ + private function processAgentInteraction( + \OCA\Talk\Room $room, + Agent $agent, + IUser $humanUser, + IComment $comment, + ): void { + $humanUserId = $humanUser->getUID(); + + // when the message is a reply inside the Talk thread of an agent session, + // the generation happens in that session and the answer goes back to the same thread + if ($room->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) { + $triggerThreadId = (int)($comment->getTopmostParentId() ?? 0); + if ($triggerThreadId !== 0) { + try { + $threadSession = $this->sessionMapper->getAgentSessionByThread($humanUserId, $triggerThreadId); + $this->storeAndSchedule($threadSession, $comment, $humanUserId, $room, $agent); + return; + } catch (DoesNotExistException) { + // the reply is not in the thread of an agent session: handle it below + } + } + } + + // the 1-1 room the agent answers in + $this->getOrCreateOneToOneRoom($humanUser, $agent->getUserId()); + + // the name of the context is the origin room of the mention/message, + // or the human display name when it was a direct 1-1 conversation + $contextRoomName = ($room->getType() === \OCA\Talk\Room::TYPE_ONE_TO_ONE) + ? $humanUser->getDisplayName() + : $room->getName(); + + // find or create the assistant chat session dedicated to this agent/origin-room context + $session = $this->getOrCreateAgentSession($humanUserId, $agent, $contextRoomName, $room->getToken()); + + $this->storeAndSchedule($session, $comment, $humanUserId, $room, $agent); + } + + /** + * @throws Exception + * @throws InternalException + * @throws UnauthorizedException + * @throws NotFoundException + * @throws BadRequestException + */ + private function storeAndSchedule(Session $session, IComment $comment, string $humanUserId, \OCA\Talk\Room $room, Agent $agent): void { + // store the human Talk message in the chat session + $creationTimestamp = $comment->getCreationDateTime()->getTimestamp(); + $talkContent = (string)$comment->getMessage(); + $this->chatService->createMessage( + $humanUserId, + (int)$session->getId(), + Message::ROLE_HUMAN, + $talkContent, + $creationTimestamp, + null, + ); + + // mark the message as seen before scheduling, so that the reaction + // also happens when the generation cannot be scheduled right away + // (the session answers one message at a time) + $this->addAgentReaction($room, $agent->getUserId(), (int)$comment->getId(), self::REACTION_READ); + // remember the seen message to later replace πŸ‘€ by βœ…/πŸ”΄ after the generation + $session->setAgentSeenRoomToken($room->getToken()); + $session->setAgentSeenCommentId((int)$comment->getId()); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to store the seen message of the agent session', ['exception' => $e]); + } + + // schedule generation of the agent answer + $this->chatService->scheduleMessageGeneration($humanUserId, (int)$session->getId()); + } + + /** + * The session is dedicated to one agent + human user + origin Talk room context: + * the Talk room in which the human mentioned the agent or wrote to him directly. + * All the agent answers are posted as one Talk thread of the 1-1 room + * between the human and the agent. + * + * @throws Exception + */ + private function getOrCreateAgentSession(string $humanUserId, Agent $agent, string $contextRoomName, string $originRoomToken): Session { + try { + return $this->sessionMapper->getAgentSession( + $humanUserId, $agent->getUserId(), $originRoomToken + ); + } catch (DoesNotExistException) { + // continue with session creation + } catch (Exception $e) { + throw new InternalException($e->getMessage(), previous: $e); + } + + $agentName = $this->getAgentDisplayName($agent); + $sessionTitle = $agentName . self::THREAD_TITLE_SEPARATOR . $contextRoomName; + + $session = $this->chatService->createChatSession( + $humanUserId, + timestamp: null, + title: $sessionTitle, + systemPrompt: $agent->getPersonality(), + ); + $session->setAgentUserId($agent->getUserId()); + $session->setRoomToken($originRoomToken); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + throw new InternalException('Failed to store the agent context on the chat session', previous: $e); + } + return $session; + } + + public function getAgentDisplayName(Agent $agent): string { + $userId = $agent->getUserId(); + $user = $this->userManager->get($userId); + $displayName = null; + if ($user !== null) { + $displayName = $user->getDisplayName(); + } + return $displayName ?: $userId; + } + + /** + * The 1-1 Talk room between a human user and an agent user + * + * @throws AgentUserException + */ + private function getOrCreateOneToOneRoom(IUser $humanUser, string $agentUserId): \OCA\Talk\Room { + /** @var \OCA\Talk\Manager $talkManager */ + $talkManager = Server::get(\OCA\Talk\Manager::class); + try { + return $talkManager->getOne2OneRoom($humanUser->getUID(), $agentUserId); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException) { + // fall through to the creation below + } + + $agentUser = $this->userManager->get($agentUserId); + if ($agentUser === null) { + throw new AgentUserException('The agent user ' . $agentUserId . ' does not exist'); + } + + /** @var \OCA\Talk\Service\RoomService $talkRoomService */ + $talkRoomService = Server::get(\OCA\Talk\Service\RoomService::class); + try { + return $talkRoomService->createOneToOneConversation($humanUser, $agentUser); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException|\InvalidArgumentException $e) { + throw new AgentUserException('Failed to create the 1-1 conversation with the agent user ' . $agentUserId, previous: $e); + } + } + + /** + * Called by ChattyLLMTaskListener instead of posting the agent answer + * in Talk: it will post the answer in the 1-1 Talk conversation thread. + * + * @param Session $session + * @param string $content The assistant message content + * @param string|null $pendingActions The pending sensitive actions (confirmation request) if any + * @return void + */ + public function postAgentTalkReply( + Session $session, + string $content, + ?string $pendingActions, + ): void { + if ($session->getAgentUserId() === null) { + return; + } + // only for agent sessions + if (!class_exists('OCA\Talk\Chat\ChatManager')) { + return; + } + $agentUser = $this->userManager->get($session->getAgentUserId()); + if ($agentUser === null) { + $this->logger->warning('The agent user was deleted while an agent answer was being handled'); + return; + } + $humanUser = $this->userManager->get($session->getUserId()); + if ($humanUser === null) { + $this->logger->warning('The session owner user was deleted while an agent answer was being handled'); + return; + } + try { + $room = $this->getOrCreateOneToOneRoom($humanUser, $agentUser->getUID()); + } catch (AgentUserException|\OCA\Talk\Exceptions\RoomNotFoundException $e) { + $this->logger->warning('Could not get the 1-1 Talk room to post an agent reply', [ + 'agentuserId' => $agentUser->getUID(), + 'humanUserId' => $humanUser->getUID(), + 'exception' => $e, + ]); + return; + } + + // messages sent outside a thread of a 1-1 conversation are answered + // with plain 1-1 messages; answers to a thread conversation go back in the thread + $threaded = true; + if ($session->getRoomToken() !== null) { + try { + $originRoom = $this->getRoomByToken($session->getRoomToken()); + $threaded = $originRoom->getType() !== \OCA\Talk\Room::TYPE_ONE_TO_ONE; + } catch (\OCA\Talk\Exceptions\RoomNotFoundException) { + // the origin room may have been deleted, still answer threaded + } + } + + $posted = $this->postAgentMessage( + $room, + $session, + $content, + referenceId: null, + threaded: $threaded, + ); + + $confirmPosted = false; + if ($pendingActions !== null && trim($pendingActions) !== '') { + $confirmPosted = $this->postAgentMessage( + $room, + $session, + $pendingActions, + referenceId: self::CONFIRMATION_REFERENCE_PREFIX . $session->getId() . ':' . time(), + addApproveRejectReactions: true, + threaded: $threaded, + ); + } + + // an agency confirmation turn has no reply content, only the confirmation message + $this->markGenerationCompleted($session, $posted || $confirmPosted); + } + + /** + * Called by a listener when a human user reacts in Talk to a confirmation message + * (sensitive actions of the agency feature). + * + * The agent removes its own πŸ‘/πŸ‘Ž (only the user's reaction remains), + * marks the message with πŸ‘€ as seen, and does the same as when confirming + * or rejecting from the assistant UI. + * + * @param string $humanUserId + * @param int $sessionId + * @param int $messageId The id of the Talk message that was reacted to + * @param string $reaction + * @return void + */ + public function handleConfirmationReaction(string $humanUserId, int $sessionId, int $messageId, string $reaction, \OCA\Talk\Room $room): void { + if (!in_array($reaction, [self::REACTION_APPROVE, self::REACTION_REJECT], true)) { + return; + } + try { + $session = $this->sessionMapper->getUserSession($humanUserId, $sessionId); + } catch (DoesNotExistException|Exception $e) { + $this->logger->warning('Could not find the session matching the Talk reaction', [ + 'humanUserId' => $humanUserId, + 'sessionId' => $sessionId, + ]); + return; + } + if ($session->getAgentUserId() === null) { + return; + } + + // remove the agent's own thumbs so only the one of the user remains, + // then mark the confirmation as being processed + $this->deleteAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_APPROVE); + $this->deleteAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_REJECT); + $this->addAgentReaction($room, $session->getAgentUserId(), $messageId, self::REACTION_READ); + // remember the seen message to later replace πŸ‘€ by βœ…/πŸ”΄ after the confirmation run + $session->setAgentSeenRoomToken($room->getToken()); + $session->setAgentSeenCommentId($messageId); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to store the seen confirmation of the agent session', ['exception' => $e]); + } + try { + $this->chatService->scheduleMessageGeneration( + $humanUserId, + $sessionId, + $reaction === self::REACTION_APPROVE ? 1 : 0, + ); + } catch (InternalException|NotFoundException|BadRequestException|UnauthorizedException $e) { + $this->logger->error('Failed to schedule the confirmation follow-up after a Talk reaction', ['exception' => $e]); + } + } + + private function postAgentMessage( + \OCA\Talk\Room $room, + Session $session, + string $content, + ?string $referenceId = null, + bool $addApproveRejectReactions = false, + bool $threaded = true, + ): bool { + if (trim($content) === '') { + return false; + } + $agentUser = $this->userManager->get($session->getAgentUserId()); + if ($agentUser === null) { + return false; + } + /** @var \OCA\Talk\Chat\ChatManager $chatManager */ + $chatManager = Server::get(\OCA\Talk\Chat\ChatManager::class); + + $threadId = \OCA\Talk\Model\Thread::THREAD_NONE; + $threadTitle = ''; + if ($threaded) { + if ($session->getThreadId() !== null) { + // answer in the existing thread of the session + $threadId = $session->getThreadId(); + } else { + // create the thread of the "real chat session" + $threadId = \OCA\Talk\Model\Thread::THREAD_CREATE; + $threadTitle = $session->getTitle() ?? $this->getAgentDisplayName($this->getAgentByUserIdOrFail($session)); + } + } + + try { + $comment = $chatManager->sendMessage( + $room, + null, + 'users', + $session->getAgentUserId(), + $content, + new DateTime(), + replyTo: null, + referenceId: $referenceId ?? '', + threadId: $threadId, + threadTitle: $threadTitle, + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to post an agent message in Talk', ['exception' => $e]); + return false; + } + + if ($threadId === \OCA\Talk\Model\Thread::THREAD_CREATE) { + // ChatManager only sets the thread metadata, the thread and the thread_created + // system message are created by the caller, like Talk's ChatController does + /** @var \OCA\Talk\Service\ThreadService $threadService */ + $threadService = Server::get(\OCA\Talk\Service\ThreadService::class); + try { + $thread = $threadService->createThread($room, (int)$comment->getId(), $threadTitle); + } catch (\Throwable $e) { + $this->logger->error('Failed to create the Talk thread of the agent session', ['exception' => $e]); + return false; + } + try { + $chatManager->addSystemMessage( + $room, + null, + 'users', + $session->getAgentUserId(), + json_encode([ + 'message' => 'thread_created', + 'parameters' => [ + 'thread' => (int)$comment->getId(), + 'title' => $thread->getName(), + ], + ]), + new DateTime(), + sendNotifications: false, + replyTo: $comment, + shouldSkipLastMessageUpdate: true, + ); + } catch (\Throwable $e) { + $this->logger->warning('Failed to post the thread_created system message', ['exception' => $e]); + } + } + + if ($threaded) { + // remember the thread, so the next messages are posted in it + $threadId = (int)($comment->getTopmostParentId() ?: $comment->getId()); + if ($session->getThreadId() !== $threadId) { + $session->setThreadId($threadId); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to store the Talk thread of the agent session', ['exception' => $e]); + } + } + } + + if ($addApproveRejectReactions) { + $this->addAgentReaction($room, $session->getAgentUserId(), (int)$comment->getId(), self::REACTION_APPROVE); + $this->addAgentReaction($room, $session->getAgentUserId(), (int)$comment->getId(), self::REACTION_REJECT); + } + return true; + } + + /** + * Replace the πŸ‘€ reaction of the agent on the last seen human message + * by the outcome emoji (βœ… answer generated, πŸ”΄ generation failed). + */ + private function updateSeenReaction(Session $session, string $reaction): void { + $seenRoomToken = $session->getAgentSeenRoomToken(); + $seenCommentId = $session->getAgentSeenCommentId(); + if ($seenRoomToken === null || $seenCommentId === null) { + return; + } + try { + $room = $this->getRoomByToken($seenRoomToken); + } catch (\OCA\Talk\Exceptions\RoomNotFoundException $e) { + $this->logger->warning('Cannot update the reaction, the Talk room of the seen message was not found', ['token' => $seenRoomToken]); + return; + } + $this->deleteAgentReaction($room, $session->getAgentUserId(), $seenCommentId, self::REACTION_READ); + $this->addAgentReaction($room, $session->getAgentUserId(), $seenCommentId, $reaction); + // forget the seen message + $session->setAgentSeenRoomToken(null); + $session->setAgentSeenCommentId(null); + try { + $this->sessionMapper->update($session); + } catch (Exception $e) { + $this->logger->error('Failed to forget the seen message of the agent session', ['exception' => $e]); + } + } + + /** + * Called by ChattyLLMTaskListener once the answer of an agent session was generated + * + * @param Session $session + * @param bool $postedInTalk Whether the answer was successfully posted in Talk + * @return void + */ + public function markGenerationCompleted(Session $session, bool $postedInTalk): void { + if ($session->getAgentUserId() === null) { + return; + } + if ($postedInTalk) { + $this->updateSeenReaction($session, self::REACTION_DONE); + } else { + $this->updateSeenReaction($session, self::REACTION_ERROR); + } + } + + /** + * Called by ChattyLLMTaskListener when the generation task of an agent session failed + * + * @param string $humanUserId + * @param int $sessionId + * @return void + */ + public function markGenerationFailed(string $humanUserId, int $sessionId): void { + try { + $session = $this->sessionMapper->getUserSession($humanUserId, $sessionId); + } catch (DoesNotExistException|Exception $e) { + $this->logger->warning('Cannot find the session of a failed agent message generation', ['sessionId' => $sessionId]); + return; + } + if ($session->getAgentUserId() === null) { + return; + } + $this->updateSeenReaction($session, self::REACTION_ERROR); + } + + private function deleteAgentReaction(\OCA\Talk\Room $room, string $agentUserId, int $messageId, string $reaction): void { + if (trim($reaction) === '' || $this->userManager->get($agentUserId) === null) { + return; + } + try { + /** @var \OCA\Talk\Chat\ReactionManager $reactionManager */ + $reactionManager = Server::get(\OCA\Talk\Chat\ReactionManager::class); + $reactionManager->deleteReactionMessage( + $room, + 'users', + $agentUserId, + $agentUserId, + $messageId, + $reaction, + ); + } catch (\Throwable $e) { + $this->logger->warning('Failed to remove an automatic reaction on an agent message in Talk', ['exception' => $e]); + } + } + + private function addAgentReaction(\OCA\Talk\Room $room, string $agentUserId, int $messageId, string $reaction): void { + $agentUser = $this->userManager->get($agentUserId); + if ($agentUser === null) { + return; + } + try { + /** @var \OCA\Talk\Chat\ReactionManager $reactionManager */ + $reactionManager = Server::get(\OCA\Talk\Chat\ReactionManager::class); + $reactionManager->addReactionMessage( + $room, + 'users', + $agentUserId, + $agentUser->getDisplayName() ?: $agentUserId, + $messageId, + $reaction, + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to add an automatic reaction on an agent message in Talk', ['exception' => $e]); + } + } + + private function getRoomByToken(string $token): \OCA\Talk\Room { + /** @var \OCA\Talk\Manager $talkManager */ + $talkManager = Server::get(\OCA\Talk\Manager::class); + return $talkManager->getRoomByToken($token); + } + + private function getAgentByUserIdOrFail(Session $session): Agent { + $agent = $this->agentsService->getAgentByUserId($session->getAgentUserId()); + if ($agent === null) { + throw new AgentUserException('Agent user ' . $session->getAgentUserId() . ' not found'); + } + return $agent; + } + +} diff --git a/lib/Service/AgentUserException.php b/lib/Service/AgentUserException.php new file mode 100644 index 000000000..d9b0d84c7 --- /dev/null +++ b/lib/Service/AgentUserException.php @@ -0,0 +1,13 @@ +userManager->userExists($userId)) { + throw new AgentUserException('The user id "' . $userId . '" already exists. Please choose another one.'); + } + + $password = bin2hex(random_bytes(self::PASSWORD_LENGTH)); + try { + $user = $this->userManager->createUser($userId, $password); + } catch (\Exception $e) { + throw new AgentUserException('Failed to create the agent user "' . $userId . '": ' . $e->getMessage(), previous: $e); + } + $user->setDisplayName($name); + return $user; + } +} diff --git a/lib/Service/AgentsService.php b/lib/Service/AgentsService.php new file mode 100644 index 000000000..438f24a33 --- /dev/null +++ b/lib/Service/AgentsService.php @@ -0,0 +1,141 @@ + + * @throws Exception + */ + public function getAllAgents(): array { + return $this->agentMapper->getAllAgents(); + } + + /** + * @param int $id + * @return Agent|null + */ + public function getAgentById(int $id): ?Agent { + try { + return $this->agentMapper->getById($id); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + return null; + } + } + + /** + * @param string $userId + * @return Agent|null + */ + public function getAgentByUserId(string $userId): ?Agent { + try { + return $this->agentMapper->getByUserId($userId); + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + return null; + } + } + + /** + * @param string|null $name + * @return array + * @throws Exception + */ + public function getAgentsByUserIds(array $userIds): array { + return $this->agentMapper->getByUserIds(array_values(array_filter($userIds))); + } + + /** + * @throws AgentUserException + * @throws Exception + */ + public function createAgent(string $name, string $userId, string $personality, string $scope): Agent { + $agent = new Agent(); + $agent->setUserId(trim(mb_strtolower($userId))); + $agent->setPersonality(trim($personality)); + $agent->setScope(trim($scope)); + try { + $this->agentUserService->createAgentUser($name, $userId); + } catch (AgentUserException $e) { + $this->logger->warning('Failed to provision the agent user', ['exception' => $e]); + throw $e; + } + return $this->agentMapper->insert($agent); + } + + /** + * @throws AgentUserException when the agent user does not exist anymore + * @throws Exception + */ + public function updateAgent(int $id, ?string $personality = null, ?string $scope = null): Agent { + try { + $agent = $this->agentMapper->getById($id); + } catch (DoesNotExistException $e) { + throw new AgentUserException('Agent not found'); + } + if ($personality !== null) { + if (strlen($personality) > self::MAX_PERSONALITY_LENGTH) { + throw new AgentUserException('The agent personality is too long'); + } + $agent->setPersonality(trim($personality)); + } + if ($scope !== null) { + if (strlen($scope) > self::MAX_SCOPE_LENGTH) { + throw new AgentUserException('The agent scope is too long'); + } + $agent->setScope(trim($scope)); + } + return $this->agentMapper->update($agent); + } + + /** + * @param int $id + * @param bool $deleteUser Whether the related NC user should be deleted too + * @return void + * @throws Exception + */ + public function deleteAgent(int $id, bool $deleteUser = false): void { + try { + $agent = $this->agentMapper->getById($id); + } catch (DoesNotExistException $e) { + return; + } + $this->agentMapper->deleteAgent($agent); + + if ($deleteUser) { + $user = $this->userManager->get($agent->getUserId()); + if ($user !== null) { + $user->delete(); + } + } + } +} diff --git a/lib/Service/ChatService.php b/lib/Service/ChatService.php index b2be960a3..fbc4e29da 100644 --- a/lib/Service/ChatService.php +++ b/lib/Service/ChatService.php @@ -49,7 +49,7 @@ public function __construct( * @throws InternalException * @throws UnauthorizedException */ - public function createChatSession(?string $userId, ?int $timestamp = null, ?string $title = null): Session { + public function createChatSession(?string $userId, ?int $timestamp = null, ?string $title = null, ?string $systemPrompt = null): Session { if ($userId === null) { throw new UnauthorizedException($this->l10n->t('Unauthorized')); } @@ -64,13 +64,18 @@ public function createChatSession(?string $userId, ?int $timestamp = null, ?stri $timestamp = intdiv($timestamp, 1000); } - $userInstructions = $this->appConfig->getValueString( - Application::APP_ID, - 'chat_user_instructions', - Application::CHAT_USER_INSTRUCTIONS, - lazy: true, - ) ?: Application::CHAT_USER_INSTRUCTIONS; - $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + $userInstructions = $systemPrompt; + if ($userInstructions === null) { + $userInstructions = $this->appConfig->getValueString( + Application::APP_ID, + 'chat_user_instructions', + Application::CHAT_USER_INSTRUCTIONS, + lazy: true, + ) ?: Application::CHAT_USER_INSTRUCTIONS; + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + } else { + $userInstructions = str_replace('{user}', $user->getDisplayName(), $userInstructions); + } $session = new Session(); $session->setUserId($userId); diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 016470539..6c21f3edd 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -9,15 +9,18 @@ use OCA\Assistant\AppInfo\Application; use OCA\Assistant\Service\AgentSkillsService; +use OCA\Assistant\Service\AgentsService; use OCA\Assistant\TaskProcessing\TextToStickerTaskType; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; +use OCP\DB\Exception; use OCP\IAppConfig; use OCP\Settings\ISettings; use OCP\TaskProcessing\IManager as ITaskProcessingManager; use OCP\TaskProcessing\TaskTypes\AudioToText; use OCP\TaskProcessing\TaskTypes\TextToImage; use OCP\TaskProcessing\TaskTypes\TextToText; +use Psr\Log\LoggerInterface; class Admin implements ISettings { @@ -26,6 +29,8 @@ public function __construct( private IInitialState $initialStateService, private ITaskProcessingManager $taskProcessingManager, private AgentSkillsService $agentSkillsService, + private AgentsService $agentsService, + private LoggerInterface $logger, ) { } @@ -63,6 +68,22 @@ public function getForm(): TemplateResponse { $globalSkillsConfig = $this->agentSkillsService->getGlobalSkillsConfig(); + $agents = []; + try { + foreach ($this->agentsService->getAllAgents() as $agent) { + $user = $this->userManager->get($agent->getUserId()); + $agents[] = [ + 'id' => (int)$agent->getId(), + 'user_id' => $agent->getUserId(), + 'display_name' => $user?->getDisplayName() ?: $agent->getUserId(), + 'personality' => $agent->getPersonality(), + 'scope' => $agent->getScope(), + ]; + } + } catch (Exception $e) { + $this->logger->warning('Could not load the agents', ['exception' => $e]); + } + $adminConfig = [ 'task_processing_available' => $taskProcessingAvailable, 'assistant_enabled' => $assistantEnabled, @@ -81,6 +102,7 @@ public function getForm(): TemplateResponse { 'context_agent_available' => $contextAgentAvailable, 'global_skills_admin_uid' => $globalSkillsConfig['admin_uid'], 'global_skills_path' => $globalSkillsConfig['path'], + 'agents' => $agents, ]; $this->initialStateService->provideInitialState('admin-config', $adminConfig); diff --git a/openapi.json b/openapi.json index 1ed2389b1..45dd10eb2 100644 --- a/openapi.json +++ b/openapi.json @@ -3692,6 +3692,1092 @@ } } }, + "/ocs/v2.php/apps/assistant/api/{apiVersion}/agents": { + "get": { + "operationId": "agents_api-list-agents", + "summary": "List all agents (admin only)", + "description": "This endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Agents returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "agents" + ], + "properties": { + "agents": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "user_id", + "display_name", + "personality", + "scope" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "personality": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "agents_api-create-agent", + "summary": "Create an agent (admin only)", + "description": "A dedicated Nextcloud user is created in the default database backend if it does not exist yet.\nThis endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "default": "", + "description": "The name of the agent" + }, + "userId": { + "type": "string", + "default": "", + "description": "The user id of the agent's dedicated Nextcloud user" + }, + "personality": { + "type": "string", + "default": "", + "description": "The agent's system prompt" + }, + "scope": { + "type": "string", + "default": "", + "description": "Optional scope for later use" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Agent created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "agent" + ], + "properties": { + "agent": { + "type": "object", + "required": [ + "id", + "user_id", + "display_name", + "personality", + "scope" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "personality": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid data", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "500": { + "description": "Agent creation failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/assistant/api/{apiVersion}/agents/{id}": { + "put": { + "operationId": "agents_api-update-agent", + "summary": "Update an agent (admin only)", + "description": "This endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "personality": { + "type": "string", + "nullable": true, + "default": null, + "description": "The new personality (system prompt)" + }, + "scope": { + "type": "string", + "nullable": true, + "default": null, + "description": "The new scope" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "id", + "in": "path", + "description": "The agent id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Agent updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "agent" + ], + "properties": { + "agent": { + "type": "object", + "required": [ + "id", + "user_id", + "display_name", + "personality", + "scope" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "user_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "personality": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid data", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "agents_api-delete-agent", + "summary": "Delete an agent (admin only)", + "description": "This endpoint requires admin access", + "tags": [ + "agents" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "id", + "in": "path", + "description": "The agent id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "deleteUser", + "in": "query", + "description": "Whether the related Nextcloud user should be deleted too", + "schema": { + "type": "integer", + "format": "int64", + "default": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Agent deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "User is not an admin", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "404": { + "description": "Agent not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Agent deletion failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/assistant/chat/sessions": { "post": { "operationId": "chattyllm-new-session-restful", @@ -7583,5 +8669,10 @@ } } }, - "tags": [] + "tags": [ + { + "name": "agents_api", + "description": "CRUD access to the agents of the \"agent as a user\" feature (admin only)" + } + ] } diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 6606deb3c..c2b99107d 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -231,6 +231,15 @@ {{ t('assistant', 'No global skills folder configured') }} +
+

+ {{ t('assistant', 'Talk agents') }} +

+ +
@@ -258,6 +267,8 @@ import { generateUrl } from '@nextcloud/router' import { delay } from '../utils.js' +import TalkAgentUsers from './TalkAgentUsers.vue' + export default { name: 'AdminSettings', @@ -273,6 +284,7 @@ export default { NcFormBox, NcFormBoxSwitch, OpenInNewIcon, + TalkAgentUsers, }, data() { @@ -288,6 +300,18 @@ export default { }, methods: { + onAgentCreated(agent) { + this.state.agents.push(agent) + }, + onAgentUpdated(agent) { + const index = this.state.agents.findIndex((a) => a.id === agent.id) + if (index !== -1) { + this.state.agents.splice(index, 1, agent) + } + }, + onAgentDeleted(agent) { + this.state.agents = this.state.agents.filter((a) => a.id !== agent.id) + }, isUnsignedIntStr(value) { return /^\d+$/.test(value) }, diff --git a/src/components/TalkAgentUsers.vue b/src/components/TalkAgentUsers.vue new file mode 100644 index 000000000..92e8ed15d --- /dev/null +++ b/src/components/TalkAgentUsers.vue @@ -0,0 +1,343 @@ + + + + + + diff --git a/tests/unit/Listener/ChattyLLMTaskListenerTest.php b/tests/unit/Listener/ChattyLLMTaskListenerTest.php index 845fb6141..a578b3b9b 100644 --- a/tests/unit/Listener/ChattyLLMTaskListenerTest.php +++ b/tests/unit/Listener/ChattyLLMTaskListenerTest.php @@ -14,6 +14,7 @@ use OCA\Assistant\Db\ChattyLLM\Session; use OCA\Assistant\Db\ChattyLLM\SessionMapper; use OCA\Assistant\Listener\ChattyLLMTaskListener; +use OCA\Assistant\Service\AgentTalkService; use OCA\Assistant\Service\NotificationService; use OCA\Assistant\Service\TaskProcessingService; use OCP\AppFramework\Db\DoesNotExistException; @@ -51,6 +52,7 @@ protected function setUp(): void { $this->messageMapper, $this->sessionMapper, $this->createMock(TaskProcessingService::class), + $this->createMock(AgentTalkService::class), $this->createMock(LoggerInterface::class), $this->createMock(NotificationService::class), ); diff --git a/tests/unit/Service/AgentUserServiceTest.php b/tests/unit/Service/AgentUserServiceTest.php new file mode 100644 index 000000000..b2c94cf4b --- /dev/null +++ b/tests/unit/Service/AgentUserServiceTest.php @@ -0,0 +1,58 @@ +userManager = $this->createMock(IUserManager::class); + $this->service = new AgentUserService($this->userManager); + } + + public function testCreatesUserWithRandomPasswordAndDisplayeName(): void { + $this->userManager->method('userExists')->willReturn(false); + $this->userManager->expects($this->once()) + ->method('createUser') + ->with('my_agent', $this->callback(static function (string $password) { + return strlen($password) >= 32 && ctype_alnum($password); + })) + ->willReturnCallback(function (string $userId, string $password) { + $user = $this->createConfiguredMock(IUser::class, ['getUID' => $userId]); + return $user; + }); + + $user = $this->service->createAgentUser('My Agent', 'My Agent'); + $this->assertSame('my_agent', $user->getUID()); + } + + public function testRejectsInvalidUserIds(): void { + $this->userManager->expects($this->never())->method('createUser'); + $this->expectException(AgentUserException::class); + $this->service->createAgent('Agent', 'invalid uid!'); + } + + public function testRejectsExistingUserIds(): void { + $this->userManager->method('userExists')->willReturn(true); + $this->userManager->expects($this->never())->method('createUser'); + $this->expectException(AgentUserException::class); + $this->service->createAgent('Agent', 'admin'); + } +} diff --git a/tests/unit/Service/AgentsServiceTest.php b/tests/unit/Service/AgentsServiceTest.php new file mode 100644 index 000000000..1e42b03dd --- /dev/null +++ b/tests/unit/Service/AgentsServiceTest.php @@ -0,0 +1,127 @@ +agentMapper = $this->createMock(AgentMapper::class); + $this->agentUserService = $this->createMock(AgentUserService::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new AgentsService( + $this->agentMapper, + $this->agentUserService, + $this->userManager, + $this->logger, + ); + } + + public function testCreateAgentProvisionsTheUserAndStoresTheAgent(): void { + $agentUser = $this->createMock(IUser::class); + $this->agentUserService->expects($this->once()) + ->method('createAgentUser') + ->with('Code Agent', 'code_agent'); + + $stored = null; + $this->agentMapper->method('insert') + ->willReturnCallback(static function ($agent) use (&$stored) { + $agent->setId(7); + $stored = $agent; + return $agent; + }); + + $agent = $this->service->createAgent('Code Agent', 'code_agent', 'Be nice.', 'tool:code'); + $this->assertSame('code_agent', $agent->getUserId()); + $this->assertSame('Be nice.', $agent->getPersonality()); + $this->assertSame('tool:code', $agent->getScope()); + $this->assertSame(7, $agent->getId()); + } + + public function testCreateAgentFailsIfUserProvisioningFails(): void { + $this->agentUserService->expects($this->once()) + ->method('createAgentUser') + ->willThrowException(new AgentUserException('taken')); + $this->agentMapper->expects($this->never())->method('insert'); + + $this->expectException(AgentUserException::class); + $this->service->createAgent('Code Agent', 'code_agent', 'Be nice.', ''); + } + + public function testDeleteAgentWithoutUserDeletion(): void { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('deleteAgent')->with($agent); + $this->userManager->expects($this->never())->method('get'); + + $this->service->deleteAgent(5, false); + } + + public function testDeleteAgentWithUserDeletion(): void { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('deleteAgent')->with($agent); + + $user = $this->createMock(IUser::class); + $user->expects($this->once())->method('delete'); + $this->userManager->expects($this->once())->method('get')->with('agent1')->willReturn($user); + + $this->service->deleteAgent(5, true); + } + + public function testDeleteAgentKeepsTheUserIfDeleteFails(): void { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('deleteAgent') + ->willThrowException(new \OCP\DB\Exception('db down')); + $this->userManager->expects($this->never())->method('get'); + + $this->expectException(\OCP\DB\Exception::class); + $this->service->deleteAgent(5, true); + } + + public function testUpdateAgent() { + $agent = $this->createAgentEntity(5, 'agent1'); + $this->agentMapper->method('getById')->willReturn($agent); + $this->agentMapper->expects($this->once())->method('update') + ->willReturnCallback(static function ($updated) { + return $updated; + }); + + $updated = $this->service->updateAgent(5, personality: 'New personality'); + $this->assertSame('New personality', $updated->getPersonality()); + } + + private function createAgentEntity(int $id, string $userId): \OCA\Assistant\Db\Agent { + $agent = new \OCA\Assistant\Db\Agent(); + $agent->setId($id); + $agent->setUserId($userId); + $agent->setPersonality('Be nice.'); + return $agent; + } +} From 6909ac081dca6154a075b26a3b95385ced1568ea Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 19:58:15 +0200 Subject: [PATCH 2/4] fix: psalm issue Signed-off-by: Julien Veyssier --- lib/AppInfo/Application.php | 2 ++ lib/Controller/AgentsApiController.php | 3 ++- lib/Listener/Talk/AgentMentionListener.php | 2 ++ lib/Listener/Talk/AgentReactionListener.php | 2 ++ lib/Service/AgentTalkService.php | 2 +- lib/Settings/Admin.php | 2 ++ psalm.xml | 2 ++ 7 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 32a9ed9f4..7f22bc153 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -112,7 +112,9 @@ public function register(IRegistrationContext $context): void { // the Talk event classes are not necessarily autoloadable when this app // file is registered, the listeners themselves guard for Talk being absent + /** @psalm-suppress UndefinedClass, InvalidArgument */ $context->registerEventListener(\OCA\Talk\Events\ChatMessageSentEvent::class, AgentMentionListener::class); + /** @psalm-suppress UndefinedClass, InvalidArgument */ $context->registerEventListener(\OCA\Talk\Events\ReactionAddedEvent::class, AgentReactionListener::class); if (class_exists('OCP\\TaskProcessing\\TaskTypes\\AudioToAudioChat')) { diff --git a/lib/Controller/AgentsApiController.php b/lib/Controller/AgentsApiController.php index 3176e44c7..27938a094 100644 --- a/lib/Controller/AgentsApiController.php +++ b/lib/Controller/AgentsApiController.php @@ -132,12 +132,13 @@ public function createAgent(string $name = '', string $userId = '', string $pers * @param int $id The agent id * @param string|null $personality The new personality (system prompt) * @param string|null $scope The new scope - * @return DataResponse|DataResponse|DataResponse|DataResponse + * @return DataResponse|DataResponse|DataResponse|DataResponse|DataResponse * * 200: Agent updated * 400: Invalid data * 403: User is not an admin * 404: Agent not found + * 500: Agent update failed */ #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['agents'])] public function updateAgent(int $id, ?string $personality = null, ?string $scope = null): DataResponse { diff --git a/lib/Listener/Talk/AgentMentionListener.php b/lib/Listener/Talk/AgentMentionListener.php index ff38fca0f..8e88114ab 100644 --- a/lib/Listener/Talk/AgentMentionListener.php +++ b/lib/Listener/Talk/AgentMentionListener.php @@ -20,6 +20,8 @@ * Handles messages sent by users in Talk conversations with agents: * the agent answer is generated in a classic Assistant chat session * and posts back in the 1-1 Talk conversation with the human user. + * + * @psalm-suppress InvalidTemplateParam, ImplementedParamTypeMismatch */ class AgentMentionListener implements IEventListener { diff --git a/lib/Listener/Talk/AgentReactionListener.php b/lib/Listener/Talk/AgentReactionListener.php index 36c432a95..2dd389523 100644 --- a/lib/Listener/Talk/AgentReactionListener.php +++ b/lib/Listener/Talk/AgentReactionListener.php @@ -19,6 +19,8 @@ * * Handles the reactions of human users on confirmation-related agent Talk messages: * they are sent back to the agent context as approve/reject signals. + * + * @psalm-suppress InvalidTemplateParam, ImplementedParamTypeMismatch */ class AgentReactionListener implements IEventListener { diff --git a/lib/Service/AgentTalkService.php b/lib/Service/AgentTalkService.php index 655e814a1..cf96cdc57 100644 --- a/lib/Service/AgentTalkService.php +++ b/lib/Service/AgentTalkService.php @@ -136,7 +136,7 @@ private function getAgentTarget( private function getOneToOnePartnerUserId(\OCA\Talk\Room $room, string $authorUserId): ?string { try { return $room->getSecondParticipant($authorUserId); - } catch (\InvalidArgumentException|JsonException $e) { + } catch (\InvalidArgumentException|\JsonException|\OCA\Talk\Exceptions\RoomNotFoundException $e) { return null; } } diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 6c21f3edd..88cfd5dbd 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -15,6 +15,7 @@ use OCP\AppFramework\Services\IInitialState; use OCP\DB\Exception; use OCP\IAppConfig; +use OCP\IUserManager; use OCP\Settings\ISettings; use OCP\TaskProcessing\IManager as ITaskProcessingManager; use OCP\TaskProcessing\TaskTypes\AudioToText; @@ -30,6 +31,7 @@ public function __construct( private ITaskProcessingManager $taskProcessingManager, private AgentSkillsService $agentSkillsService, private AgentsService $agentsService, + private IUserManager $userManager, private LoggerInterface $logger, ) { } diff --git a/psalm.xml b/psalm.xml index 05be2e863..aa36b04f5 100644 --- a/psalm.xml +++ b/psalm.xml @@ -38,6 +38,7 @@ + @@ -45,6 +46,7 @@ + From 6fa7efd42d45e7e35f8bc3c920726c0f594b009e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 20:00:31 +0200 Subject: [PATCH 3/4] fix: tests Signed-off-by: Julien Veyssier --- tests/unit/Service/AgentUserServiceTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/Service/AgentUserServiceTest.php b/tests/unit/Service/AgentUserServiceTest.php index b2c94cf4b..1aefaa743 100644 --- a/tests/unit/Service/AgentUserServiceTest.php +++ b/tests/unit/Service/AgentUserServiceTest.php @@ -27,7 +27,7 @@ protected function setUp(): void { $this->service = new AgentUserService($this->userManager); } - public function testCreatesUserWithRandomPasswordAndDisplayeName(): void { + public function testCreatesUserWithRandomPasswordAndDisplayName(): void { $this->userManager->method('userExists')->willReturn(false); $this->userManager->expects($this->once()) ->method('createUser') @@ -39,20 +39,20 @@ public function testCreatesUserWithRandomPasswordAndDisplayeName(): void { return $user; }); - $user = $this->service->createAgentUser('My Agent', 'My Agent'); + $user = $this->service->createAgentUser('My Agent', 'my_agent'); $this->assertSame('my_agent', $user->getUID()); } public function testRejectsInvalidUserIds(): void { $this->userManager->expects($this->never())->method('createUser'); $this->expectException(AgentUserException::class); - $this->service->createAgent('Agent', 'invalid uid!'); + $this->service->createAgentUser('Agent', 'invalid uid!'); } public function testRejectsExistingUserIds(): void { $this->userManager->method('userExists')->willReturn(true); $this->userManager->expects($this->never())->method('createUser'); $this->expectException(AgentUserException::class); - $this->service->createAgent('Agent', 'admin'); + $this->service->createAgentUser('Agent', 'admin'); } } From 786ddbd51018562f5d2b1044d137a60b796c778e Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Fri, 23 Oct 2026 20:01:57 +0200 Subject: [PATCH 4/4] fix: openapi Signed-off-by: Julien Veyssier --- openapi.json | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/openapi.json b/openapi.json index 45dd10eb2..fcdc63b95 100644 --- a/openapi.json +++ b/openapi.json @@ -4480,6 +4480,44 @@ } } }, + "500": { + "description": "Agent update failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, "401": { "description": "Current user is not logged in", "content": {