From ae74f545cc056c5871c1499380f4737821305dec Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 16 Sep 2026 10:41:34 -0400 Subject: [PATCH 1/3] feat(agent): agent registry table + read-through facade (TIGER-151, step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TigerAgent was a singleton — one provider/model/key in the tiger.agent.* config tier. This adds the persistence + facade foundation for many agents per org so TigerRoundtable can seat REGISTERED agents rather than minting its own. - migration 0050: `agent` table (org-scoped, is_default marks the resolved one, api_key_enc stored encrypted). No data is copied — see below. - Tiger_Model_Agent: defaultForOrg (org default, then global), allForOrg, findForOrg, setDefault (one default per scope, transactional). - Tiger_Agent: default()/get()/all()/reset(); provider()/model()/apiKey()/ isEnabled() now read through the DEFAULT agent, memoized per request. Back-compat is exact: an EMPTY registry (or a DB not yet booted) falls back to the legacy tiger.agent.* config keys, so an install that never opens the new UI behaves as the old singleton did. The first save in the settings screen (step 2) writes the real Default row. mode_max stays an install-wide governance setting. Bumps core 1.8.3 -> 1.9.0 (new feature). Full suite green (2371 tests). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- library/Tiger/Agent.php | 160 ++++++++++++++- library/Tiger/Model/Agent.php | 110 +++++++++++ library/Tiger/Version.php | 2 +- migrations/0050_create_agent.php | 48 +++++ tests/Integration/Agent/AgentRegistryTest.php | 182 ++++++++++++++++++ tests/Support/IntegrationTestCase.php | 2 + 6 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 library/Tiger/Model/Agent.php create mode 100644 migrations/0050_create_agent.php create mode 100644 tests/Integration/Agent/AgentRegistryTest.php 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/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/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(); } From 10564cbecf8aea1b24f389753e99bc226131104f Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 16 Sep 2026 10:42:49 -0400 Subject: [PATCH 2/3] docs(agent): regenerate CAPABILITIES.md for Tiger_Model_Agent Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- CAPABILITIES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 2b17ab93..576bc6bc 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` From 41a191cabeb9cfbaf7db17a0bedfc4aa49bf366c Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Wed, 16 Sep 2026 11:03:58 -0400 Subject: [PATCH 3/3] feat(agent): multi-agent registry CRUD + card UI (TIGER-151, step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings screen becomes a registry: a card per agent (name, persona, provider, model, BYO key), Add Agent to append one, per-card Save/Delete, and one default radio across the cards. While the registry is empty the screen shows one seed card prefilled from the legacy default, so the admin's first Save writes the real Default row (the singleton -> registry hand-off from step 1). - Agent_Service_Agents: list/save/delete. Write-only key (encrypted, never returned; blank field keeps the stored secret); one-default-per-org invariant (first create forced default; setting one moves it; deleting the default promotes a survivor; deleting the last returns to the legacy fallback). Admin+. - Agent_Form_Agent (name required; provider validated in the service). - Agent_Service_Settings::mode() — saves ONLY the install-wide auto-mode ceiling, so it no longer clobbers the legacy config keys the registry falls back to. - AdminController + admin/index.phtml + _agent-card.phtml partial (Add Agent, per-card model live-list, connected badge, delete-with-confirm). - ACL: Agent_Service_Agents granted admin+. Strings in all 7 locales. Tests: AgentsServiceTest (6) + updated AdminControllerTest for the registry view model; view smoke-rendered. Full suite green (2377 tests). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01L8p9pLJ3DFstG3xZuh2QgZ --- CAPABILITIES.md | 2 +- modules/agent/configs/acl.ini | 7 +- modules/agent/controllers/AdminController.php | 29 +- modules/agent/forms/Agent.php | 48 +++ modules/agent/languages/de/agent.php | 20 ++ modules/agent/languages/en/agent.php | 20 ++ modules/agent/languages/es/agent.php | 20 ++ modules/agent/languages/fr/agent.php | 20 ++ modules/agent/languages/hi/agent.php | 20 ++ modules/agent/languages/pt/agent.php | 20 ++ modules/agent/languages/tlh/agent.php | 20 ++ modules/agent/services/Agents.php | 170 +++++++++ modules/agent/services/Settings.php | 27 ++ .../views/scripts/admin/_agent-card.phtml | 78 +++++ modules/agent/views/scripts/admin/index.phtml | 323 +++++++++++------- .../Integration/Agent/AdminControllerTest.php | 33 +- tests/Integration/Agent/AgentsServiceTest.php | 137 ++++++++ 17 files changed, 835 insertions(+), 159 deletions(-) create mode 100644 modules/agent/forms/Agent.php create mode 100644 modules/agent/services/Agents.php create mode 100644 modules/agent/views/scripts/admin/_agent-card.phtml create mode 100644 tests/Integration/Agent/AgentsServiceTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 576bc6bc..593d68d6 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -319,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/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/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)); + } +}