diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 2b17ab93..593d68d6 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**207 classes** across **33 capabilities** · **21 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**208 classes** across **33 capabilities** · **21 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -296,6 +296,7 @@ ### Data layer (base) - **Tiger_Db_Migrator** `@api` — a tiny, dependency-free schema migration runner. · `library/Tiger/Db/Migrator.php` +- **Tiger_Model_Agent** `@api` — the agent registry table gateway (migration 0050, TIGER-151). · `library/Tiger/Model/Agent.php` - **Tiger_Model_AgentConversation** `@api` — a TigerAgent chat thread (see migration 0034). · `library/Tiger/Model/AgentConversation.php` - **Tiger_Model_AgentMessage** `@api` — one message in an agent conversation (see migration 0035). · `library/Tiger/Model/AgentMessage.php` - **Tiger_Model_AgentRun** `@api` — one turn's execution + control record (see migration 0036). · `library/Tiger/Model/AgentRun.php` @@ -318,7 +319,7 @@ ## Modules (`modules/*` — activatable features) - **Access** (`access`, plugin) · services: Org, User · `modules/access` -- **Agent** (`agent`, app) · services: Agent, Mcp, Settings, Skills · `modules/agent` +- **Agent** (`agent`, app) · services: Agent, Agents, Mcp, Settings, Skills · `modules/agent` - **Ally** (`ally`, plugin) · services: Scan · `modules/ally` - **Analytics** (`analytics`, app) · services: Analytics, Reports · `modules/analytics` - **Backup** (`backup`, app) · services: Backup · `modules/backup` diff --git a/library/Tiger/Agent.php b/library/Tiger/Agent.php index f92a2c78..7ff44e0d 100644 --- a/library/Tiger/Agent.php +++ b/library/Tiger/Agent.php @@ -44,13 +44,116 @@ class Tiger_Agent const MODES = ['ask' => 0, 'auto' => 1, 'yolo' => 2]; /** - * Whether the agent feature is switched on for this install. + * Per-request memo of the resolved default agent, keyed by org scope. Cleared implicitly by + * process end; a save in the settings service should call reset() so the next read is fresh. + * + * @var array + */ + private static $_defaultMemo = []; + + /** + * Identity of the Zend_Config instance the memo was built against. The config object is stable + * for the life of a request, so the memo holds across a request; when it is swapped (a settings + * save rebuilds it, or a test seeds a fresh one) the memo self-invalidates. @var int + */ + private static $_memoConfigId = -1; + + /** + * The DEFAULT agent for the current scope, as a normalized array. Reads through the registry + * (Tiger_Model_Agent): the current org's default, else the global default. While the table is + * empty — or before the DB is even booted — it falls back to the legacy `tiger.agent.*` config + * keys, so an install that has never opened the multi-agent UI behaves exactly as the old + * singleton did. The first save in the settings screen writes a real Default row. + * + * @return array{id:?string,name:string,provider:string,model:string,api_key_enc:string,enabled:bool} + */ + public static function default() + { + $cfgId = Zend_Registry::isRegistered('Zend_Config') + ? spl_object_id(Zend_Registry::get('Zend_Config')) + : 0; + if ($cfgId !== self::$_memoConfigId) { + self::$_defaultMemo = []; + self::$_memoConfigId = $cfgId; + } + + $org = self::currentOrg(); + if (array_key_exists($org, self::$_defaultMemo)) { + return self::$_defaultMemo[$org]; + } + + $agent = null; + try { + $row = (new Tiger_Model_Agent())->defaultForOrg($org); + if ($row) { + $agent = self::fromRow($row); + } + } catch (Throwable $e) { + // DB not booted, or the agent table doesn't exist yet — fall through to legacy config. + } + if ($agent === null) { + $agent = self::fromLegacyConfig(); + } + + return self::$_defaultMemo[$org] = $agent; + } + + /** + * One registered agent by id, scoped to the current org, as a normalized array — or null. + * TigerRoundtable uses this to seat a specific registered agent. + * + * @param string $agentId + * @return array{id:string,name:string,provider:string,model:string,api_key_enc:string,enabled:bool}|null + */ + public static function get($agentId) + { + try { + $row = (new Tiger_Model_Agent())->findForOrg(self::currentOrg(), $agentId); + return $row ? self::fromRow($row) : null; + } catch (Throwable $e) { + return null; + } + } + + /** + * Every registered agent in the current scope, default first — the roster the settings UI and + * TigerRoundtable draw from. Empty while the registry is unused (the legacy default is implicit, + * reachable via default(), and not listed as a row until it is saved). + * + * @return array + */ + public static function all() + { + try { + $out = []; + foreach ((new Tiger_Model_Agent())->allForOrg(self::currentOrg()) as $row) { + $out[] = self::fromRow($row); + } + return $out; + } catch (Throwable $e) { + return []; + } + } + + /** + * Forget the memoized default (call after a settings save so the next read reflects it). + * + * @return void + */ + public static function reset() + { + self::$_defaultMemo = []; + self::$_memoConfigId = -1; + } + + /** + * Whether the agent feature is switched on for this install — i.e. the default agent is enabled. * * @return bool */ public static function isEnabled() { - return self::config(self::CFG_ENABLED) === '1'; + return (bool) self::default()['enabled']; } /** @@ -92,7 +195,7 @@ public static function userCanChat() */ public static function provider() { - $p = (string) self::config(self::CFG_PROVIDER); + $p = (string) self::default()['provider']; return $p !== '' ? $p : 'anthropic'; } @@ -103,7 +206,7 @@ public static function provider() */ public static function model() { - $m = (string) self::config(self::CFG_MODEL); + $m = (string) self::default()['model']; if ($m !== '') { return $m; } @@ -117,7 +220,7 @@ public static function model() */ public static function apiKey() { - $blob = (string) self::config(self::CFG_KEY_ENC); + $blob = (string) self::default()['api_key_enc']; if ($blob === '') { return ''; } @@ -183,6 +286,53 @@ public static function clampMode($requested) // ----- internals --------------------------------------------------------- + /** + * Normalize a registry row into the shape default()/get()/all() return. + * + * @param Zend_Db_Table_Row_Abstract $row + * @return array{id:string,name:string,provider:string,model:string,api_key_enc:string,enabled:bool} + */ + protected static function fromRow($row) + { + return [ + 'id' => (string) $row->agent_id, + 'name' => (string) $row->name, + 'provider' => (string) $row->provider, + 'model' => (string) $row->model, + 'api_key_enc' => (string) $row->api_key_enc, + 'enabled' => (int) $row->enabled === 1, + ]; + } + + /** + * The default agent synthesized from the legacy singleton config keys — the exact behavior the + * facade had before the registry existed, so an empty table changes nothing. + * + * @return array{id:null,name:string,provider:string,model:string,api_key_enc:string,enabled:bool} + */ + protected static function fromLegacyConfig() + { + return [ + 'id' => null, + 'name' => 'Default', + 'provider' => (string) self::config(self::CFG_PROVIDER), + 'model' => (string) self::config(self::CFG_MODEL), + 'api_key_enc' => (string) self::config(self::CFG_KEY_ENC), + 'enabled' => self::config(self::CFG_ENABLED) === '1', + ]; + } + + /** + * The org scope the registry resolves against — the current tenant, or '' for platform/global. + * + * @return string + */ + protected static function currentOrg() + { + $org = Tiger_Model_Table::org(); + return $org === null ? '' : (string) $org; + } + /** * Read a value from the merged config cascade (Zend_Config in the registry). * diff --git a/library/Tiger/Model/Agent.php b/library/Tiger/Model/Agent.php new file mode 100644 index 00000000..0ac8c67d --- /dev/null +++ b/library/Tiger/Model/Agent.php @@ -0,0 +1,110 @@ +fetchRow( + $this->activeSelect() + ->where('org_id = ?', $orgId) + ->where('is_default = ?', 1) + ->order('created_at ASC') + ); + if ($row === null && $orgId !== '') { + $row = $this->fetchRow( + $this->activeSelect() + ->where("org_id = ''") + ->where('is_default = ?', 1) + ->order('created_at ASC') + ); + } + return $row; + } + + /** + * Every non-deleted agent in a scope, default first then newest. + * + * @param string $orgId + * @return Zend_Db_Table_Rowset_Abstract + */ + public function allForOrg($orgId) + { + return $this->fetchAll( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->order(['is_default DESC', 'created_at ASC']) + ); + } + + /** + * One agent by id within a scope (so an org can't address another org's agent), or null. + * + * @param string $orgId + * @param string $agentId + * @return Zend_Db_Table_Row_Abstract|null + */ + public function findForOrg($orgId, $agentId) + { + if ((string) $agentId === '') { return null; } + return $this->fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('agent_id = ?', (string) $agentId) + ); + } + + /** + * Make one agent the sole default in its scope, clearing the flag on every sibling. Runs in a + * transaction so a scope never has two defaults or, briefly, none. + * + * @param string $orgId + * @param string $agentId + * @return void + */ + public function setDefault($orgId, $agentId) + { + $db = $this->getAdapter(); + $db->beginTransaction(); + try { + $this->update(['is_default' => 0], [ + 'org_id = ?' => (string) $orgId, + 'agent_id <> ?' => (string) $agentId, + ]); + $this->update(['is_default' => 1], [ + 'org_id = ?' => (string) $orgId, + 'agent_id = ?' => (string) $agentId, + ]); + $db->commit(); + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } + } +} diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index ab50b3e2..5aad7c46 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.8.3'; + const VERSION = '1.9.0'; } diff --git a/migrations/0050_create_agent.php b/migrations/0050_create_agent.php new file mode 100644 index 00000000..821edd5a --- /dev/null +++ b/migrations/0050_create_agent.php @@ -0,0 +1,48 @@ + [ + "CREATE TABLE IF NOT EXISTS `agent` ( + `agent_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL DEFAULT '', + `name` VARCHAR(191) NOT NULL, + `persona` TEXT NULL, + `provider` VARCHAR(64) NOT NULL DEFAULT '', + `model` VARCHAR(191) NOT NULL DEFAULT '', + `api_key_enc` TEXT NULL, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + `is_default` TINYINT(1) NOT NULL DEFAULT 0, + `status` TINYINT(1) NOT NULL DEFAULT 1, + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NULL, + PRIMARY KEY (`agent_id`), + KEY `idx_agent_default` (`org_id`, `is_default`, `deleted`), + KEY `idx_agent_org` (`org_id`, `deleted`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + ], + 'down' => [ + "DROP TABLE IF EXISTS `agent`", + ], +]; diff --git a/modules/agent/configs/acl.ini b/modules/agent/configs/acl.ini index 161ad7a7..f80a1ab7 100644 --- a/modules/agent/configs/acl.ini +++ b/modules/agent/configs/acl.ini @@ -18,7 +18,8 @@ ; --- resources --- acl.resources.agent_admin_ctrl.resource = "Agent_AdminController" ; settings screen -acl.resources.agent_settings_svc.resource = "Agent_Service_Settings" ; save settings +acl.resources.agent_settings_svc.resource = "Agent_Service_Settings" ; save settings (mode ceiling + live models) +acl.resources.agent_agents_svc.resource = "Agent_Service_Agents" ; the agent registry CRUD acl.resources.agent_chat_svc.resource = "Agent_Service_Agent" ; the aside turn engine acl.resources.agent_forge.resource = "Tiger_Agent_Forge" ; the sharp write tiers acl.resources.agent_scout.resource = "Tiger_Agent_Scout" ; the read tiers (Scout) @@ -32,6 +33,10 @@ acl.rules.agent_settings_svc.role = "admin" acl.rules.agent_settings_svc.resource = "Agent_Service_Settings" acl.rules.agent_settings_svc.permission = "allow" +acl.rules.agent_agents_svc.role = "admin" +acl.rules.agent_agents_svc.resource = "Agent_Service_Agents" +acl.rules.agent_agents_svc.permission = "allow" + ; --- chat: content managers and up may open the aside. What they can actually DO through it ; is bounded per-call by each target service's own ACL (a manager reaches content services; ; an admin reaches the data surface) — so "content only for some" is automatic. --- diff --git a/modules/agent/controllers/AdminController.php b/modules/agent/controllers/AdminController.php index 9701ae4e..c8203fe9 100644 --- a/modules/agent/controllers/AdminController.php +++ b/modules/agent/controllers/AdminController.php @@ -16,22 +16,31 @@ public function init() parent::init(); } - /** Settings: provider, model, BYO key, and the on/off switch. */ + /** Settings: the agent registry (a card per agent) + the install-wide auto-mode ceiling. */ public function indexAction() { - $form = new Agent_Form_Settings(); - $form->populate([ - 'provider' => Tiger_Agent::provider(), - 'model' => Tiger_Agent::model(), - ]); + $form = new Agent_Form_Settings(); // carries the CSRF token the /api calls reuse $this->view->title = Zend_Registry::get('Zend_Translate')->translate('agent.settings.title') . ' — Tiger Admin'; $this->view->form = $form; - $this->view->enabled = Tiger_Agent::isEnabled(); - $this->view->connected = Tiger_Agent::isConnected(); + // The registry rows, or — while it is empty — one seed card prefilled from the legacy default, + // so the admin's first Save writes the real Default row (the singleton -> registry hand-off). + $agents = Tiger_Agent::all(); + if (!$agents) { + $agents = [[ + 'agent_id' => '', + 'name' => Zend_Registry::get('Zend_Translate')->translate('agent.agents.default_name'), + 'persona' => '', + 'provider' => Tiger_Agent::provider(), + 'model' => Tiger_Agent::model(), + 'enabled' => Tiger_Agent::isEnabled(), + 'is_default' => true, + 'connected' => Tiger_Agent::isConnected(), + ]]; + } + $this->view->agents = $agents; $this->view->providers = Tiger_Agent_Provider_Factory::options(); - $this->view->provider = Tiger_Agent::provider(); - $this->view->model = Tiger_Agent::model(); + $this->view->defaultProvider = Tiger_Agent::provider(); $this->view->modeMax = Tiger_Agent::modeMax(); $this->view->cryptoReady = Tiger_Crypto::isConfigured(); } diff --git a/modules/agent/forms/Agent.php b/modules/agent/forms/Agent.php new file mode 100644 index 00000000..a82884a3 --- /dev/null +++ b/modules/agent/forms/Agent.php @@ -0,0 +1,48 @@ + false, + 'filters' => ['StringTrim'], + ]], + ['text', 'name', [ + 'required' => true, + 'filters' => ['StringTrim'], + 'validators' => [['StringLength', false, [1, 191]]], + 'attribs' => ['class' => 'form-control'], + ]], + ['textarea', 'persona', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'attribs' => ['class' => 'form-control', 'rows' => 3], + ]], + ['text', 'provider', [ + 'required' => false, + 'filters' => ['StringTrim'], + ]], + ['text', 'model', [ + 'required' => false, + 'filters' => ['StringTrim'], + ]], + ['password', 'api_key', [ + 'required' => false, + 'filters' => ['StringTrim'], + 'attribs' => ['autocomplete' => 'off'], + ]], + ]; + } +} diff --git a/modules/agent/languages/de/agent.php b/modules/agent/languages/de/agent.php index 96aec8de..4509307f 100644 --- a/modules/agent/languages/de/agent.php +++ b/modules/agent/languages/de/agent.php @@ -30,6 +30,22 @@ 'agent.settings.how.body1' => 'Der Agent handelt als Sie — er kann nie mehr tun, als Ihre Rolle erlaubt. Lesevorgänge laufen von selbst; Änderungen werden zuerst zu Ihrer Genehmigung angezeigt.', 'agent.settings.how.body2' => 'Bringen Sie Ihr eigenes Konto mit: der Schlüssel, den Sie einfügen, gehört Ihnen, wird auf diesem Server verschlüsselt gespeichert und nie geteilt. Ihr KI-Anbieter rechnet direkt mit Ihnen ab.', + // Agent registry (multiple named agents) + 'agent.agents.add' => 'Agent hinzufügen', + 'agent.agents.name' => 'Name', + 'agent.agents.name.ph' => 'z. B. Support-Assistent', + 'agent.agents.default' => 'Standard', + 'agent.agents.enabled' => 'Aktiviert', + 'agent.agents.persona' => 'Persona', + 'agent.agents.persona.ph' => 'Optional – wie sich dieser Agent verhalten soll und was er weiß.', + 'agent.agents.delete' => 'Löschen', + 'agent.agents.empty' => 'Noch keine Agenten. Fügen Sie einen hinzu, um ein KI-Konto zu verbinden.', + 'agent.agents.default_name' => 'Standard', + 'agent.agents.saved' => 'Agent gespeichert.', + 'agent.agents.deleted' => 'Agent gelöscht.', + 'agent.agents.error.not_found' => 'Diesen Agenten gibt es nicht mehr.', + 'agent.agents.error.crypto' => 'Schlüssel kann nicht gespeichert werden – Verschlüsselung ist nicht konfiguriert (tiger.crypto.key).', + // Aside modes 'agent.mode.ask' => 'Fragen', 'agent.mode.auto' => 'Auto', @@ -115,6 +131,10 @@ 'agent.js.models_live' => 'Live aus Ihrem Konto.', 'agent.js.models_static' => 'Gängige Modelle — verbinden Sie einen Schlüssel für die Live-Liste.', 'agent.js.settings_saved' => 'Einstellungen gespeichert.', + 'agent.js.agent_saved' => 'Agent gespeichert.', + 'agent.js.agent_deleted' => 'Agent gelöscht.', + 'agent.js.confirm_delete' => 'Diesen Agenten löschen? Das kann nicht rückgängig gemacht werden.', + 'agent.js.name_required' => 'Geben Sie dem Agenten zuerst einen Namen.', 'agent.js.network_error' => 'Netzwerkfehler — bitte versuchen Sie es erneut.', 'agent.js.connection_saved' => 'Verbindung gespeichert.', 'agent.js.remove_connection_title' => 'Verbindung entfernen', diff --git a/modules/agent/languages/en/agent.php b/modules/agent/languages/en/agent.php index eb4b0331..9250e836 100644 --- a/modules/agent/languages/en/agent.php +++ b/modules/agent/languages/en/agent.php @@ -30,6 +30,22 @@ 'agent.settings.how.body1' => 'The agent acts as you — it can never do more than your role permits. Reads run on their own; changes are shown for your approval first.', 'agent.settings.how.body2' => 'Bring your own account: the key you paste is yours, stored encrypted on this server and never shared. Your AI provider bills you directly.', + // Agent registry (multiple named agents) + 'agent.agents.add' => 'Add Agent', + 'agent.agents.name' => 'Name', + 'agent.agents.name.ph' => 'e.g. Support Assistant', + 'agent.agents.default' => 'Default', + 'agent.agents.enabled' => 'Enabled', + 'agent.agents.persona' => 'Persona', + 'agent.agents.persona.ph' => 'Optional — how this agent should behave and what it knows.', + 'agent.agents.delete' => 'Delete', + 'agent.agents.empty' => 'No agents yet. Add one to connect an AI account.', + 'agent.agents.default_name' => 'Default', + 'agent.agents.saved' => 'Agent saved.', + 'agent.agents.deleted' => 'Agent deleted.', + 'agent.agents.error.not_found' => 'That agent no longer exists.', + 'agent.agents.error.crypto' => 'Can’t store the key — encryption isn’t configured (tiger.crypto.key).', + // Aside modes 'agent.mode.ask' => 'Ask', 'agent.mode.auto' => 'Auto', @@ -115,6 +131,10 @@ 'agent.js.models_live' => 'Live from your account.', 'agent.js.models_static' => 'Common models — connect a key for the live list.', 'agent.js.settings_saved' => 'Settings saved.', + 'agent.js.agent_saved' => 'Agent saved.', + 'agent.js.agent_deleted' => 'Agent deleted.', + 'agent.js.confirm_delete' => 'Delete this agent? This can’t be undone.', + 'agent.js.name_required' => 'Give the agent a name first.', 'agent.js.network_error' => 'Network error — please try again.', 'agent.js.connection_saved' => 'Connection saved.', 'agent.js.remove_connection_title' => 'Remove connection', diff --git a/modules/agent/languages/es/agent.php b/modules/agent/languages/es/agent.php index adb0a586..112f8a7e 100644 --- a/modules/agent/languages/es/agent.php +++ b/modules/agent/languages/es/agent.php @@ -30,6 +30,22 @@ 'agent.settings.how.body1' => 'El agente actúa como tú — nunca puede hacer más de lo que tu rol permite. Las lecturas se ejecutan solas; los cambios se muestran primero para tu aprobación.', 'agent.settings.how.body2' => 'Trae tu propia cuenta: la clave que pegas es tuya, se almacena cifrada en este servidor y nunca se comparte. Tu proveedor de IA te factura directamente.', + // Agent registry (multiple named agents) + 'agent.agents.add' => 'Añadir agente', + 'agent.agents.name' => 'Nombre', + 'agent.agents.name.ph' => 'p. ej. Asistente de soporte', + 'agent.agents.default' => 'Predeterminado', + 'agent.agents.enabled' => 'Activado', + 'agent.agents.persona' => 'Personalidad', + 'agent.agents.persona.ph' => 'Opcional: cómo debe comportarse este agente y qué sabe.', + 'agent.agents.delete' => 'Eliminar', + 'agent.agents.empty' => 'Aún no hay agentes. Añade uno para conectar una cuenta de IA.', + 'agent.agents.default_name' => 'Predeterminado', + 'agent.agents.saved' => 'Agente guardado.', + 'agent.agents.deleted' => 'Agente eliminado.', + 'agent.agents.error.not_found' => 'Ese agente ya no existe.', + 'agent.agents.error.crypto' => 'No se puede guardar la clave: el cifrado no está configurado (tiger.crypto.key).', + // Aside modes 'agent.mode.ask' => 'Preguntar', 'agent.mode.auto' => 'Auto', @@ -115,6 +131,10 @@ 'agent.js.models_live' => 'En vivo desde tu cuenta.', 'agent.js.models_static' => 'Modelos comunes — conecta una clave para la lista en vivo.', 'agent.js.settings_saved' => 'Configuración guardada.', + 'agent.js.agent_saved' => 'Agente guardado.', + 'agent.js.agent_deleted' => 'Agente eliminado.', + 'agent.js.confirm_delete' => '¿Eliminar este agente? No se puede deshacer.', + 'agent.js.name_required' => 'Primero ponle un nombre al agente.', 'agent.js.network_error' => 'Error de red — inténtalo de nuevo.', 'agent.js.connection_saved' => 'Conexión guardada.', 'agent.js.remove_connection_title' => 'Quitar conexión', diff --git a/modules/agent/languages/fr/agent.php b/modules/agent/languages/fr/agent.php index c3685d00..400f8fdc 100644 --- a/modules/agent/languages/fr/agent.php +++ b/modules/agent/languages/fr/agent.php @@ -30,6 +30,22 @@ 'agent.settings.how.body1' => 'L’agent agit en tant que vous — il ne peut jamais faire plus que ce que votre rôle permet. Les lectures s’exécutent seules ; les modifications sont d’abord présentées pour votre approbation.', 'agent.settings.how.body2' => 'Apportez votre propre compte : la clé que vous collez est la vôtre, stockée chiffrée sur ce serveur et jamais partagée. Votre fournisseur IA vous facture directement.', + // Agent registry (multiple named agents) + 'agent.agents.add' => 'Ajouter un agent', + 'agent.agents.name' => 'Nom', + 'agent.agents.name.ph' => 'ex. : Assistant support', + 'agent.agents.default' => 'Par défaut', + 'agent.agents.enabled' => 'Activé', + 'agent.agents.persona' => 'Persona', + 'agent.agents.persona.ph' => 'Facultatif — comment cet agent doit se comporter et ce qu’il sait.', + 'agent.agents.delete' => 'Supprimer', + 'agent.agents.empty' => 'Aucun agent pour l’instant. Ajoutez-en un pour connecter un compte IA.', + 'agent.agents.default_name' => 'Par défaut', + 'agent.agents.saved' => 'Agent enregistré.', + 'agent.agents.deleted' => 'Agent supprimé.', + 'agent.agents.error.not_found' => 'Cet agent n’existe plus.', + 'agent.agents.error.crypto' => 'Impossible d’enregistrer la clé — le chiffrement n’est pas configuré (tiger.crypto.key).', + // Aside modes 'agent.mode.ask' => 'Demander', 'agent.mode.auto' => 'Auto', @@ -115,6 +131,10 @@ 'agent.js.models_live' => 'En direct depuis votre compte.', 'agent.js.models_static' => 'Modèles courants — connectez une clé pour la liste en direct.', 'agent.js.settings_saved' => 'Paramètres enregistrés.', + 'agent.js.agent_saved' => 'Agent enregistré.', + 'agent.js.agent_deleted' => 'Agent supprimé.', + 'agent.js.confirm_delete' => 'Supprimer cet agent ? Cette action est irréversible.', + 'agent.js.name_required' => 'Donnez d’abord un nom à l’agent.', 'agent.js.network_error' => 'Erreur réseau — veuillez réessayer.', 'agent.js.connection_saved' => 'Connexion enregistrée.', 'agent.js.remove_connection_title' => 'Supprimer la connexion', diff --git a/modules/agent/languages/hi/agent.php b/modules/agent/languages/hi/agent.php index e27e5c7d..b1ee8fbb 100644 --- a/modules/agent/languages/hi/agent.php +++ b/modules/agent/languages/hi/agent.php @@ -30,6 +30,22 @@ 'agent.settings.how.body1' => 'एजेंट आपके रूप में काम करता है — यह कभी भी आपकी भूमिका की अनुमति से अधिक नहीं कर सकता। पढ़ने के काम अपने आप चलते हैं; बदलाव पहले आपकी मंज़ूरी के लिए दिखाए जाते हैं।', 'agent.settings.how.body2' => 'अपना खुद का अकाउंट लाएँ: आप जो कुंजी पेस्ट करते हैं वह आपकी है, इस सर्वर पर एन्क्रिप्टेड संग्रहित होती है और कभी साझा नहीं की जाती। आपका AI प्रदाता आपसे सीधे शुल्क लेता है।', + // Agent registry (multiple named agents) + 'agent.agents.add' => 'एजेंट जोड़ें', + 'agent.agents.name' => 'नाम', + 'agent.agents.name.ph' => 'जैसे, सहायता सहायक', + 'agent.agents.default' => 'डिफ़ॉल्ट', + 'agent.agents.enabled' => 'सक्षम', + 'agent.agents.persona' => 'व्यक्तित्व', + 'agent.agents.persona.ph' => 'वैकल्पिक — यह एजेंट कैसा व्यवहार करे और क्या जानता हो।', + 'agent.agents.delete' => 'हटाएँ', + 'agent.agents.empty' => 'अभी कोई एजेंट नहीं। AI खाता जोड़ने के लिए एक जोड़ें।', + 'agent.agents.default_name' => 'डिफ़ॉल्ट', + 'agent.agents.saved' => 'एजेंट सहेजा गया।', + 'agent.agents.deleted' => 'एजेंट हटाया गया।', + 'agent.agents.error.not_found' => 'वह एजेंट अब मौजूद नहीं है।', + 'agent.agents.error.crypto' => 'कुंजी सहेजी नहीं जा सकती — एन्क्रिप्शन कॉन्फ़िगर नहीं है (tiger.crypto.key)।', + // Aside modes 'agent.mode.ask' => 'पूछें', 'agent.mode.auto' => 'ऑटो', @@ -115,6 +131,10 @@ 'agent.js.models_live' => 'आपके अकाउंट से लाइव।', 'agent.js.models_static' => 'सामान्य मॉडल — लाइव सूची के लिए एक कुंजी कनेक्ट करें।', 'agent.js.settings_saved' => 'सेटिंग्स सहेजी गईं।', + 'agent.js.agent_saved' => 'एजेंट सहेजा गया।', + 'agent.js.agent_deleted' => 'एजेंट हटाया गया।', + 'agent.js.confirm_delete' => 'इस एजेंट को हटाएँ? इसे पूर्ववत नहीं किया जा सकता।', + 'agent.js.name_required' => 'पहले एजेंट को एक नाम दें।', 'agent.js.network_error' => 'नेटवर्क त्रुटि — कृपया फिर से प्रयास करें।', 'agent.js.connection_saved' => 'कनेक्शन सहेजा गया।', 'agent.js.remove_connection_title' => 'कनेक्शन हटाएँ', diff --git a/modules/agent/languages/pt/agent.php b/modules/agent/languages/pt/agent.php index ffe819ae..158564c4 100644 --- a/modules/agent/languages/pt/agent.php +++ b/modules/agent/languages/pt/agent.php @@ -30,6 +30,22 @@ 'agent.settings.how.body1' => 'O agente age como você — ele nunca pode fazer mais do que o seu papel permite. As leituras são executadas sozinhas; as alterações são exibidas primeiro para a sua aprovação.', 'agent.settings.how.body2' => 'Traga a sua própria conta: a chave que você cola é sua, armazenada criptografada neste servidor e nunca compartilhada. O seu provedor de IA cobra você diretamente.', + // Agent registry (multiple named agents) + 'agent.agents.add' => 'Adicionar agente', + 'agent.agents.name' => 'Nome', + 'agent.agents.name.ph' => 'ex.: Assistente de suporte', + 'agent.agents.default' => 'Padrão', + 'agent.agents.enabled' => 'Ativado', + 'agent.agents.persona' => 'Persona', + 'agent.agents.persona.ph' => 'Opcional — como este agente deve se comportar e o que sabe.', + 'agent.agents.delete' => 'Excluir', + 'agent.agents.empty' => 'Ainda não há agentes. Adicione um para conectar uma conta de IA.', + 'agent.agents.default_name' => 'Padrão', + 'agent.agents.saved' => 'Agente salvo.', + 'agent.agents.deleted' => 'Agente excluído.', + 'agent.agents.error.not_found' => 'Esse agente não existe mais.', + 'agent.agents.error.crypto' => 'Não é possível salvar a chave — a criptografia não está configurada (tiger.crypto.key).', + // Aside modes 'agent.mode.ask' => 'Perguntar', 'agent.mode.auto' => 'Auto', @@ -115,6 +131,10 @@ 'agent.js.models_live' => 'Ao vivo da sua conta.', 'agent.js.models_static' => 'Modelos comuns — conecte uma chave para a lista ao vivo.', 'agent.js.settings_saved' => 'Configurações salvas.', + 'agent.js.agent_saved' => 'Agente salvo.', + 'agent.js.agent_deleted' => 'Agente excluído.', + 'agent.js.confirm_delete' => 'Excluir este agente? Isso não pode ser desfeito.', + 'agent.js.name_required' => 'Primeiro dê um nome ao agente.', 'agent.js.network_error' => 'Erro de rede — tente novamente.', 'agent.js.connection_saved' => 'Conexão salva.', 'agent.js.remove_connection_title' => 'Remover conexão', diff --git a/modules/agent/languages/tlh/agent.php b/modules/agent/languages/tlh/agent.php index 25e449ca..dfd4b496 100644 --- a/modules/agent/languages/tlh/agent.php +++ b/modules/agent/languages/tlh/agent.php @@ -31,6 +31,22 @@ 'agent.settings.how.body1' => 'The agent acts as you — it can never do more than your role permits. Reads run on their own; changes are shown for your approval first.', 'agent.settings.how.body2' => 'Bring your own account: the key you paste is yours, stored encrypted on this server and never shared. Your AI provider bills you directly.', + // Agent registry (multiple named agents) + 'agent.agents.add' => 'Add Agent', + 'agent.agents.name' => 'Name', + 'agent.agents.name.ph' => 'e.g. Support Assistant', + 'agent.agents.default' => 'Default', + 'agent.agents.enabled' => 'Enabled', + 'agent.agents.persona' => 'Persona', + 'agent.agents.persona.ph' => 'Optional — how this agent should behave and what it knows.', + 'agent.agents.delete' => 'Delete', + 'agent.agents.empty' => 'No agents yet. Add one to connect an AI account.', + 'agent.agents.default_name' => 'Default', + 'agent.agents.saved' => 'Agent saved.', + 'agent.agents.deleted' => 'Agent deleted.', + 'agent.agents.error.not_found' => 'That agent no longer exists.', + 'agent.agents.error.crypto' => 'Can’t store the key — encryption isn’t configured (tiger.crypto.key).', + // Aside modes 'agent.mode.ask' => 'Ask', 'agent.mode.auto' => 'Auto', @@ -116,6 +132,10 @@ 'agent.js.models_live' => 'Live from your account.', 'agent.js.models_static' => 'Common models — connect a key for the live list.', 'agent.js.settings_saved' => 'Settings saved.', + 'agent.js.agent_saved' => 'Agent saved.', + 'agent.js.agent_deleted' => 'Agent deleted.', + 'agent.js.confirm_delete' => 'Delete this agent? This can’t be undone.', + 'agent.js.name_required' => 'Give the agent a name first.', 'agent.js.network_error' => 'Network error — please try again.', 'agent.js.connection_saved' => 'Connection saved.', 'agent.js.remove_connection_title' => 'Remove connection', diff --git a/modules/agent/services/Agents.php b/modules/agent/services/Agents.php new file mode 100644 index 00000000..674f90dc --- /dev/null +++ b/modules/agent/services/Agents.php @@ -0,0 +1,170 @@ +_org_id ?? ''); + } + + /** + * List the org's agents for the card view — default first. Never returns keys; each row carries + * a `connected` flag (a key is stored) instead. + * + * @param array $params + * @return void + */ + public function list(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $agents = []; + foreach ((new Tiger_Model_Agent())->allForOrg($this->_scope()) as $row) { + $agents[] = $this->_public($row); + } + $this->_success(['agents' => $agents, 'crypto' => Tiger_Crypto::isConfigured()], 'core.api.success'); + } + + /** + * Create or update one agent. `agent_id` blank = create. A blank `api_key` preserves the stored + * secret; a non-blank one is encrypted and replaces it. An org always keeps exactly one default: + * the first agent created becomes it automatically, and setting `is_default` moves it. + * + * @param array $params agent_id, name, persona, provider, model, api_key, enabled, is_default + * @return void + */ + public function save(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $form = new Agent_Form_Agent(); + if (!$form->isValid($params)) { $this->_formErrors($form); return; } + + $scope = $this->_scope(); + $model = new Tiger_Model_Agent(); + + $agentId = (string) $form->getValue('agent_id'); + $existing = $agentId !== '' ? $model->findForOrg($scope, $agentId) : null; + if ($agentId !== '' && $existing === null) { $this->_error('agent.agents.error.not_found'); return; } + + $provider = (string) $form->getValue('provider'); + if (!array_key_exists($provider, Tiger_Agent_Provider_Factory::options())) { $provider = 'anthropic'; } + + $name = (string) $form->getValue('name'); + $persona = (string) $form->getValue('persona'); + $mdl = (string) $form->getValue('model'); + $key = (string) $form->getValue('api_key'); + $enabled = !empty($params['enabled']) ? 1 : 0; + $wantDefault = !empty($params['is_default']); + + if ($key !== '' && !Tiger_Crypto::isConfigured()) { + $this->_error('agent.agents.error.crypto'); return; + } + + try { + $savedId = $this->_transaction(function () use ($model, $scope, $existing, $agentId, $name, $persona, $provider, $mdl, $key, $enabled, $wantDefault) { + // An org with no agent yet MUST end with a default, so the facade resolves a row. + $isFirst = $model->allForOrg($scope)->count() === 0; + $makeDefault = $wantDefault || $isFirst; + + $data = [ + 'name' => $name, + 'persona' => $persona !== '' ? $persona : null, + 'provider' => $provider, + 'model' => $mdl, + 'enabled' => $enabled, + ]; + if ($key !== '') { $data['api_key_enc'] = Tiger_Crypto::encrypt($key); } + + if ($existing !== null) { + $model->update($data, ['agent_id = ?' => $agentId, 'org_id = ?' => $scope]); + $id = $agentId; + } else { + $data['org_id'] = $scope; + $data['is_default'] = $makeDefault ? 1 : 0; + $id = $model->insert($data); + } + + if ($makeDefault) { $model->setDefault($scope, $id); } + return $id; + }); + + Tiger_Agent::reset(); + $saved = $model->findForOrg($scope, $savedId); + $this->_success(['agent' => $this->_public($saved)], 'agent.agents.saved'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * Soft-delete an agent. If the deleted one was the default and others remain, the oldest + * surviving agent is promoted so the org never loses its default. Deleting the last agent is + * allowed — the registry falls back to the legacy config until one is created again. + * + * @param array $params agent_id + * @return void + */ + public function delete(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $scope = $this->_scope(); + $model = new Tiger_Model_Agent(); + $agentId = (string) ($params['agent_id'] ?? ''); + $row = $agentId !== '' ? $model->findForOrg($scope, $agentId) : null; + if ($row === null) { $this->_error('agent.agents.error.not_found'); return; } + + $wasDefault = (int) $row->is_default === 1; + + try { + $this->_transaction(function () use ($model, $scope, $agentId, $wasDefault) { + $model->softDelete(['agent_id = ?' => $agentId, 'org_id = ?' => $scope]); + if ($wasDefault) { + $next = $model->allForOrg($scope)->current(); // oldest survivor (default-first, then created_at) + if ($next) { $model->setDefault($scope, $next->agent_id); } + } + }); + Tiger_Agent::reset(); + $this->_success(null, 'agent.agents.deleted'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + + /** + * The browser-safe view of an agent row: identity + config + a connected flag, never the key. + * + * @param Zend_Db_Table_Row_Abstract $row + * @return array + */ + private function _public($row): array + { + return [ + 'agent_id' => (string) $row->agent_id, + 'name' => (string) $row->name, + 'persona' => (string) ($row->persona ?? ''), + 'provider' => (string) $row->provider, + 'model' => (string) $row->model, + 'enabled' => (int) $row->enabled === 1, + 'is_default' => (int) $row->is_default === 1, + 'connected' => (string) ($row->api_key_enc ?? '') !== '', + ]; + } +} diff --git a/modules/agent/services/Settings.php b/modules/agent/services/Settings.php index b206dc02..e8275f8b 100644 --- a/modules/agent/services/Settings.php +++ b/modules/agent/services/Settings.php @@ -64,6 +64,33 @@ public function save(array $params): void } } + /** + * Save ONLY the install-wide auto-mode ceiling (governance) — the per-agent provider/model/key + * now live in the registry (Agent_Service_Agents), so this must not touch the legacy config keys + * that the registry falls back to while it is empty. + * + * @param array $params mode_max + * @return void + */ + public function mode(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + + $modeMax = (string) ($params['mode_max'] ?? ''); + if (!isset(Tiger_Agent::MODES[$modeMax])) { $modeMax = 'auto'; } + + try { + $this->_transaction(function () use ($modeMax) { + (new Tiger_Model_Config())->set( + Tiger_Model_Config::SCOPE_GLOBAL, '', Tiger_Agent::CFG_MODE_MAX, $modeMax + ); + }); + $this->_success(['mode_max' => $modeMax], 'agent.settings.saved'); + } catch (Throwable $e) { + $this->_error(APPLICATION_ENV !== 'production' ? $e->getMessage() : 'core.api.error.general'); + } + } + /** * List a provider's selectable models for the settings dropdown — LIVE from the provider when a * key is available (a just-typed `api_key`, else the stored one), else the curated static diff --git a/modules/agent/views/scripts/admin/_agent-card.phtml b/modules/agent/views/scripts/admin/_agent-card.phtml new file mode 100644 index 00000000..624ba0c9 --- /dev/null +++ b/modules/agent/views/scripts/admin/_agent-card.phtml @@ -0,0 +1,78 @@ +a (agent array), $this->providers (key => label). + */ +$t = static fn($k) => Zend_Registry::get('Zend_Translate')->translate($k); +$a = $this->a; +?> +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+
+ +
+ + +
+ >escape($t('agent.settings.connected')) ?> + >escape($t('agent.settings.disconnected')) ?> +
+
+ +
+ + +
+ +
+ + +
+
+
diff --git a/modules/agent/views/scripts/admin/index.phtml b/modules/agent/views/scripts/admin/index.phtml index 56561eaf..a24b8e15 100644 --- a/modules/agent/views/scripts/admin/index.phtml +++ b/modules/agent/views/scripts/admin/index.phtml @@ -2,172 +2,233 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. /** - * TigerAgent settings screen (ADMIN.md template): connect a BYO AI account + toggle the agent. - * The API key is write-only — never echoed back; a stored key shows a "connected" badge. + * TigerAgent settings screen — the agent REGISTRY (TIGER-151): a card per agent, each with its own + * persona, provider, model and BYO key, one marked default. Add Agent appends a blank card; each + * card saves and deletes on its own. The API key is write-only — never echoed back; a stored key + * shows a "connected" badge. The install-wide auto-mode ceiling sits in its own governance card. * * Views have no _t() helper in tiger-core — pull the registered translator off the registry. */ $t = static fn($k) => Zend_Registry::get('Zend_Translate')->translate($k); +$providers = $this->providers; ?>
-

-

+

escape($t('agent.settings.title')) ?>

+

escape($t('agent.settings.subtitle')) ?>

-
-
+
cryptoReady): ?>
- + escape($t('agent.settings.crypto_missing')) ?>
-
- form->getElement('_csrf') ?> -
-
-
-
escape($t('agent.settings.connection')) ?>
-
- -
- enabled ? 'checked' : '' ?>> - -
- -
- - -
- -
- -
- - -
-
-
- -
- - form->getElement('api_key')->setAttrib('id', 'agent-api_key') ?> -
- connected): ?> - - - - -
-
- -
- - -
-
+
form->getElement('_csrf') ?>
+
+
+
+ agents as $a): ?> + partial('admin/_agent-card.phtml', [ + 'a' => $a, + 'providers' => $providers, + 't' => $t, + 'escape' => [$this, 'escape'], + ]) ?> + +
+ +
+ +
+
+
escape($t('agent.settings.mode_max')) ?>
+
+ +
+ +
+
escape($t('agent.settings.mode_max.help')) ?>
-
-
-
escape($t('agent.settings.how.title')) ?>
-
-

-

-
+
+
escape($t('agent.settings.how.title')) ?>
+
+

escape($t('agent.settings.how.body1')) ?>

+

escape($t('agent.settings.how.body2')) ?>

- +
+ + + i18n([ - 'modelsLive' => 'agent.js.models_live', - 'modelsStatic' => 'agent.js.models_static', - 'networkError' => 'agent.js.network_error', + 'modelsLive' => 'agent.js.models_live', + 'modelsStatic' => 'agent.js.models_static', + 'networkError' => 'agent.js.network_error', 'settingsSaved' => 'agent.js.settings_saved', + 'agentSaved' => 'agent.js.agent_saved', + 'agentDeleted' => 'agent.js.agent_deleted', + 'confirmDelete' => 'agent.js.confirm_delete', + 'nameRequired' => 'agent.js.name_required', ]); ?> diff --git a/tests/Integration/Agent/AdminControllerTest.php b/tests/Integration/Agent/AdminControllerTest.php index d179d89e..8cc7d104 100644 --- a/tests/Integration/Agent/AdminControllerTest.php +++ b/tests/Integration/Agent/AdminControllerTest.php @@ -17,11 +17,11 @@ /** * Agent_AdminController — the TigerAgent settings screen shell (Wave 6). * - * A thin admin controller: it prefills the settings form from live config and hands the view the - * provider roster + the connected/enabled/crypto-ready flags; the actual save is the `/api` call to - * Agent_Service_Settings (covered by SettingsServiceTest). We dispatch `indexAction` with rendering - * off (ControllerTestCase) and assert the view vars the `.phtml` reads — the branch/assignment logic, - * not the markup. + * A thin admin controller: it hands the view the agent REGISTRY (a card per agent, or — while the + * registry is empty — one seed card prefilled from the legacy default) plus the provider roster and + * the mode ceiling; the actual CRUD is the `/api` call to Agent_Service_Agents (covered by + * AgentsServiceTest). We dispatch `indexAction` with rendering off (ControllerTestCase) and assert + * the view vars the `.phtml` reads — the branch/assignment logic, not the markup. */ #[CoversClass(Agent_AdminController::class)] final class AdminControllerTest extends ControllerTestCase @@ -54,7 +54,7 @@ protected function tearDown(): void } #[Test] - public function index_prefills_the_form_and_exposes_the_settings_view_model(): void + public function index_exposes_the_registry_view_model(): void { $this->loginAs('admin'); $this->dispatchAction(Agent_AdminController::class, 'index', [], 'GET'); @@ -62,23 +62,22 @@ public function index_prefills_the_form_and_exposes_the_settings_view_model(): v $view = $this->controller()->view; $this->assertStringContainsString('AI Agent', (string) $view->title, 'the title runs through the translator'); - $this->assertInstanceOf(\Agent_Form_Settings::class, $view->form, 'the settings form is handed to the view'); + $this->assertInstanceOf(\Agent_Form_Settings::class, $view->form, 'a form (for its CSRF token) is handed to the view'); // The provider roster the dropdown renders is the live factory options. $this->assertSame(Tiger_Agent_Provider_Factory::options(), $view->providers); $this->assertArrayHasKey('anthropic', (array) $view->providers); - // Prefill + capability flags the view branches on. - $this->assertSame('anthropic', $view->provider, 'defaults to anthropic with no stored provider'); - $this->assertNotSame('', (string) $view->model, 'a default model is offered'); - $this->assertIsBool($view->enabled); - $this->assertIsBool($view->connected); + // The agent cards + governance flags the view branches on. + $this->assertIsArray($view->agents); + $this->assertNotEmpty($view->agents, 'an empty registry still renders one seed card'); $this->assertIsBool($view->cryptoReady); $this->assertContains($view->modeMax, ['ask', 'auto', 'yolo']); + $this->assertNotSame('', (string) $view->defaultProvider); } #[Test] - public function index_reflects_stored_provider_and_model_config(): void + public function an_empty_registry_seeds_one_card_from_the_legacy_default(): void { Zend_Registry::set('Zend_Config', new Zend_Config([ 'tiger' => ['agent' => ['provider' => 'openai', 'model' => 'gpt-4o']], @@ -86,8 +85,10 @@ public function index_reflects_stored_provider_and_model_config(): void $this->loginAs('admin'); $this->dispatchAction(Agent_AdminController::class, 'index', [], 'GET'); - $view = $this->controller()->view; - $this->assertSame('openai', $view->provider, 'the stored provider is surfaced'); - $this->assertSame('gpt-4o', $view->model, 'the stored model is surfaced'); + $seed = $this->controller()->view->agents[0]; + $this->assertSame('', $seed['agent_id'], 'the seed card has no row id yet'); + $this->assertSame('openai', $seed['provider'], 'the seed reflects the legacy provider'); + $this->assertSame('gpt-4o', $seed['model'], 'the seed reflects the legacy model'); + $this->assertTrue($seed['is_default'], 'the seed is the default so the first save writes it'); } } diff --git a/tests/Integration/Agent/AgentRegistryTest.php b/tests/Integration/Agent/AgentRegistryTest.php new file mode 100644 index 00000000..04ad87cf --- /dev/null +++ b/tests/Integration/Agent/AgentRegistryTest.php @@ -0,0 +1,182 @@ + self::CRYPTO_KEY]; } + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => $tiger], true)); + Tiger_Agent::reset(); + } + + #[Test] + public function an_empty_registry_falls_back_to_the_legacy_singleton_config(): void + { + $this->seedConfig([ + 'enabled' => '1', + 'provider' => 'openai', + 'model' => 'gpt-4.1', + ]); + Tiger_Model_Table::setOrg(Tiger_Uuid::v7()); // an org that has registered nothing + + $this->assertTrue(Tiger_Agent::isEnabled(), 'enabled reads through to the legacy flag'); + $this->assertSame('openai', Tiger_Agent::provider()); + $this->assertSame('gpt-4.1', Tiger_Agent::model()); + $this->assertNull(Tiger_Agent::default()['id'], 'the synthesized default has no row id'); + $this->assertSame([], Tiger_Agent::all(), 'no rows means an empty roster'); + } + + #[Test] + public function a_disabled_legacy_flag_reads_as_disabled(): void + { + $this->seedConfig(['enabled' => '0', 'provider' => 'anthropic']); + Tiger_Model_Table::setOrg(Tiger_Uuid::v7()); + $this->assertFalse(Tiger_Agent::isEnabled()); + } + + #[Test] + public function a_default_row_overrides_the_legacy_config_for_its_org(): void + { + // Legacy config says openai; the org's Default row says gemini — the row wins. + $this->seedConfig(['enabled' => '1', 'provider' => 'openai', 'model' => 'gpt-4.1']); + $org = Tiger_Uuid::v7(); + Tiger_Model_Table::setOrg($org); + + $model = new Tiger_Model_Agent(); + $model->insert([ + 'org_id' => $org, + 'name' => 'House', + 'provider' => 'gemini', + 'model' => 'gemini-2.0-flash', + 'enabled' => 1, + 'is_default' => 1, + ]); + Tiger_Agent::reset(); + + $this->assertSame('gemini', Tiger_Agent::provider()); + $this->assertSame('gemini-2.0-flash', Tiger_Agent::model()); + $this->assertNotNull(Tiger_Agent::default()['id'], 'now resolving a real row'); + $this->assertCount(1, Tiger_Agent::all()); + } + + #[Test] + public function an_org_with_no_agent_resolves_to_the_global_default(): void + { + $this->seedConfig(['provider' => 'openai']); // legacy would say openai + $model = new Tiger_Model_Agent(); + // Global ('') default set by the platform. + $model->insert([ + 'org_id' => '', + 'name' => 'Platform', + 'provider' => 'anthropic', + 'model' => 'claude-opus', + 'enabled' => 1, + 'is_default' => 1, + ]); + + Tiger_Model_Table::setOrg(Tiger_Uuid::v7()); // a tenant with nothing of its own + Tiger_Agent::reset(); + + $this->assertSame('anthropic', Tiger_Agent::provider(), 'falls back to the global default row, not legacy config'); + } + + #[Test] + public function set_default_keeps_exactly_one_default_per_scope(): void + { + $org = Tiger_Uuid::v7(); + $model = new Tiger_Model_Agent(); + Tiger_Model_Table::setOrg($org); + + $a = $model->insert(['org_id' => $org, 'name' => 'A', 'provider' => 'openai', 'is_default' => 1]); + $b = $model->insert(['org_id' => $org, 'name' => 'B', 'provider' => 'gemini', 'is_default' => 0]); + + $model->setDefault($org, $b); + + $this->assertSame(0, (int) $model->findById($a)->is_default, 'the old default was cleared'); + $this->assertSame(1, (int) $model->findById($b)->is_default, 'the new one is the sole default'); + Tiger_Agent::reset(); + $this->assertSame('gemini', Tiger_Agent::provider()); + } + + #[Test] + public function get_and_all_are_scoped_to_the_current_org(): void + { + $mine = Tiger_Uuid::v7(); + $other = Tiger_Uuid::v7(); + $model = new Tiger_Model_Agent(); + + Tiger_Model_Table::setOrg($mine); + $myId = $model->insert(['org_id' => $mine, 'name' => 'Mine', 'provider' => 'openai', 'is_default' => 1]); + Tiger_Model_Table::setOrg($other); + $otherId = $model->insert(['org_id' => $other, 'name' => 'Other', 'provider' => 'anthropic', 'is_default' => 1]); + + Tiger_Model_Table::setOrg($mine); + Tiger_Agent::reset(); + + $this->assertNotNull(Tiger_Agent::get($myId), 'own agent is reachable'); + $this->assertNull(Tiger_Agent::get($otherId), 'another org\'s agent is NOT reachable'); + $names = array_column(Tiger_Agent::all(), 'name'); + $this->assertSame(['Mine'], $names, 'all() lists only the current org'); + } + + #[Test] + public function the_default_rows_encrypted_key_decrypts_through_the_facade(): void + { + $this->seedConfig([], true); // crypto configured, no legacy agent key + $org = Tiger_Uuid::v7(); + $model = new Tiger_Model_Agent(); + Tiger_Model_Table::setOrg($org); + + $model->insert([ + 'org_id' => $org, + 'name' => 'Keyed', + 'provider' => 'openai', + 'model' => 'gpt-4.1', + 'api_key_enc' => Tiger_Crypto::encrypt('sk-secret-123'), + 'enabled' => 1, + 'is_default' => 1, + ]); + Tiger_Agent::reset(); + + $this->assertSame('sk-secret-123', Tiger_Agent::apiKey()); + $this->assertTrue(Tiger_Agent::isConnected()); + } +} diff --git a/tests/Integration/Agent/AgentsServiceTest.php b/tests/Integration/Agent/AgentsServiceTest.php new file mode 100644 index 00000000..60f2a648 --- /dev/null +++ b/tests/Integration/Agent/AgentsServiceTest.php @@ -0,0 +1,137 @@ + ['crypto' => ['key' => self::CRYPTO_KEY]]], true)); + } + + private function call(string $action, array $params = []): object + { + return (new Agent_Service_Agents(['action' => $action] + $params))->getResponse(); + } + + #[Test] + public function crud_is_admin_only(): void + { + foreach (['guest', 'user', 'manager'] as $role) { + $this->loginAs($role); + foreach (['list', 'save', 'delete'] as $action) { + $res = $this->call($action, ['name' => 'X']); + $this->assertSame(0, (int) $res->result, "{$role} denied on {$action}"); + $this->assertStringContainsString('not_allowed', json_encode($res->messages)); + } + } + } + + #[Test] + public function first_save_creates_the_forced_default_and_never_returns_the_key(): void + { + $this->loginAs('admin'); + $res = $this->call('save', [ + 'name' => 'House', 'provider' => 'openai', 'model' => 'gpt-4.1', 'api_key' => 'sk-live', 'enabled' => '1', + // note: is_default NOT passed — the first agent must still become default + ]); + $this->assertSame(1, (int) $res->result, json_encode($res->messages)); + $agent = $res->data['agent']; + $this->assertNotSame('', $agent['agent_id']); + $this->assertTrue($agent['is_default'], 'the first agent is forced default'); + $this->assertTrue($agent['connected'], 'a stored key reads as connected'); + $this->assertArrayNotHasKey('api_key', $agent, 'the key never round-trips'); + $this->assertArrayNotHasKey('api_key_enc', $agent); + + // The facade now resolves the row (not legacy config). + Tiger_Agent::reset(); + $this->assertSame('openai', Tiger_Agent::provider()); + $this->assertSame('sk-live', Tiger_Agent::apiKey()); + } + + #[Test] + public function setting_default_moves_it_and_blank_key_preserves_the_secret(): void + { + $this->loginAs('admin'); + $a = $this->call('save', ['name' => 'A', 'provider' => 'openai', 'api_key' => 'sk-a'])->data['agent']; + $b = $this->call('save', ['name' => 'B', 'provider' => 'anthropic', 'api_key' => 'sk-b', 'is_default' => '1'])->data['agent']; + + $model = new Tiger_Model_Agent(); + $this->assertSame(0, (int) $model->findById($a['agent_id'])->is_default, 'A is no longer default'); + $this->assertSame(1, (int) $model->findById($b['agent_id'])->is_default, 'B is now the default'); + + // Re-save A with a BLANK key — the stored secret survives. + $this->call('save', ['agent_id' => $a['agent_id'], 'name' => 'A2', 'provider' => 'openai', 'api_key' => '']); + $this->assertSame('sk-a', Tiger_Crypto::decrypt($model->findById($a['agent_id'])->api_key_enc), 'blank key kept the secret'); + $this->assertSame('A2', $model->findById($a['agent_id'])->name, 'other fields still update'); + } + + #[Test] + public function deleting_the_default_promotes_a_survivor(): void + { + $this->loginAs('admin'); + $a = $this->call('save', ['name' => 'A', 'provider' => 'openai', 'api_key' => 'sk-a'])->data['agent']; // default + $b = $this->call('save', ['name' => 'B', 'provider' => 'anthropic', 'api_key' => 'sk-b'])->data['agent']; + + $res = $this->call('delete', ['agent_id' => $a['agent_id']]); + $this->assertSame(1, (int) $res->result, json_encode($res->messages)); + + $model = new Tiger_Model_Agent(); + $this->assertNull($model->findById($a['agent_id']), 'A is soft-deleted (hidden)'); + $this->assertSame(1, (int) $model->findById($b['agent_id'])->is_default, 'B was promoted to default'); + } + + #[Test] + public function deleting_the_last_agent_returns_to_the_legacy_fallback(): void + { + $this->loginAs('admin'); + // Legacy config present so the fallback is observable after the registry empties. + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => [ + 'crypto' => ['key' => self::CRYPTO_KEY], + 'agent' => ['enabled' => '1', 'provider' => 'anthropic', 'model' => 'claude-legacy'], + ]], true)); + + $a = $this->call('save', ['name' => 'Only', 'provider' => 'openai', 'model' => 'gpt-4.1'])->data['agent']; + $this->call('delete', ['agent_id' => $a['agent_id']]); + + $this->assertCount(0, $this->call('list')->data['agents'], 'registry is empty again'); + Tiger_Agent::reset(); + $this->assertSame('anthropic', Tiger_Agent::provider(), 'facade falls back to legacy config'); + $this->assertSame('claude-legacy', Tiger_Agent::model()); + } + + #[Test] + public function a_key_without_crypto_configured_is_refused(): void + { + $this->loginAs('admin'); + Zend_Registry::set('Zend_Config', new Zend_Config(['tiger' => []], true)); // no crypto key + $res = $this->call('save', ['name' => 'NoCrypto', 'provider' => 'openai', 'api_key' => 'sk-x']); + $this->assertSame(0, (int) $res->result); + $this->assertStringContainsString('crypto', json_encode($res->messages)); + } +} diff --git a/tests/Support/IntegrationTestCase.php b/tests/Support/IntegrationTestCase.php index aa4c760f..e96247bf 100644 --- a/tests/Support/IntegrationTestCase.php +++ b/tests/Support/IntegrationTestCase.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Tiger_Acl_Acl; +use Tiger_Agent; use Tiger_Db_Migrator; use Tiger_Model_Table; use Zend_Auth; @@ -53,6 +54,7 @@ protected function setUp(): void // Clean per-test static context on the base model, then isolate the test in a transaction. Tiger_Model_Table::setActor(null); Tiger_Model_Table::setOrg(''); + Tiger_Agent::reset(); // agent facade memoizes the default per request $this->db->beginTransaction(); }