Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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`
Expand All @@ -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`
Expand Down
160 changes: 155 additions & 5 deletions library/Tiger/Agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,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<int,array{id:string,name:string,provider:string,model:string,api_key_enc:string,enabled:bool}>
*/
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'];
}

/**
Expand Down Expand Up @@ -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';
}

Expand All @@ -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;
}
Expand All @@ -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 '';
}
Expand Down Expand Up @@ -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).
*
Expand Down
110 changes: 110 additions & 0 deletions library/Tiger/Model/Agent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Model_Agent — the agent registry table gateway (migration 0050, TIGER-151).
*
* One row per registered agent, scoped by `org_id` ('' = the platform/global scope). Exactly one
* agent per scope carries `is_default = 1`; `Tiger_Agent::default()` resolves to it. Domain finders
* build on activeSelect() so soft-deleted agents stay hidden.
*
* The API key lives in `api_key_enc` (Tiger_Crypto ciphertext); this gateway never encrypts or
* decrypts — that is the service's job. save()/setDefault() keep the "one default per scope"
* invariant in a transaction.
*
* @api
* @since 1.9.0
*/
class Tiger_Model_Agent extends Tiger_Model_Table
{
protected $_name = 'agent';
protected $_primary = 'agent_id';

/**
* The default agent for a scope: the org's own default if it has one, else the global ('')
* default, else null. Falling back to the global scope means a tenant that never registered
* its own agent still resolves to the platform default (which is what the legacy singleton was).
*
* @param string $orgId the acting org, '' for the platform scope
* @return Zend_Db_Table_Row_Abstract|null
*/
public function defaultForOrg($orgId)
{
$orgId = (string) $orgId;
$row = $this->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;
}
}
}
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
48 changes: 48 additions & 0 deletions migrations/0050_create_agent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Migration 0050 — the agent registry (TIGER-151).
*
* TigerAgent was a singleton: one provider/model/key in the `tiger.agent.*` config tier. This
* table lets an org register MANY agents, each with its own persona, provider, model and BYO key,
* and marks one as the org's default. TigerRoundtable then seats REGISTERED agents rather than
* minting throwaway ones of its own.
*
* No data is copied here on purpose. `Tiger_Agent::default()` reads through to this table and,
* while it is empty, falls back to the legacy `tiger.agent.*` config keys — so an install that
* has never opened the new UI behaves EXACTLY as before, still one agent. The first save in the
* settings screen writes the real "Default" row and the fallback stops mattering. `mode_max`
* stays an install-wide governance setting (`tiger.agent.mode_max`), not a per-agent column.
*
* `org_id` scopes the registry per tenant ('' = the platform/global default). `is_default` marks
* the one agent the facade resolves to for a scope; the service keeps at most one default per org.
* The key is stored encrypted (Tiger_Crypto), never in plaintext, exactly as the config key was.
*/
return [
'up' => [
"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`",
],
];
Loading
Loading