--- id: IHUB-WP-0012 type: workplan title: "IHF Phase 11 — Advanced AI Federation" domain: inter_hub repo: inter-hub status: done owner: custodian topic_slug: inter_hub created: "2026-04-01" updated: "2026-04-01" state_hub_sync: done state_hub_workstream_id: "b6d8058b-b786-4cb8-81d4-19067b2684f8" --- # IHF Phase 11 — Advanced AI Federation ## Goal Move AI assistance from single-model, single-agent operation (Phase 5) to a federated, multi-model, multi-agent architecture. Phase 11 introduces agent specialisation, model routing, inter-agent delegation, collective proposals, and AI governance policies — while keeping humans firmly in control of final decisions. ## Background Phases 1–10 and IHUB-WP-0012 entry gates are satisfied: - Phase 5 `AgentProposal`, `AgentReviewRecord`, `ConfidenceAnnotation` operational ✓ - `HubCapabilityManifest` and type registries in place (governance policies attach per-hub) ✓ - `/api/v2/` surface live (agent registry will expose an API endpoint) ✓ - GAAF scorecard at 3.56 (Strong) ✓ - `llm-connect` available at `~/llm-connect` as a Python subprocess library ✓ Reference: `specs/InteractionHubFrameworkSpecification_v0.2.md` §Phase 11. ## llm-connect Integration Architecture **llm-connect** (`~/llm-connect`) is a Python library with pluggable LLM adapters for OpenRouter, Gemini, OpenAI, and Claude Code CLI. It is not a Haskell library; the integration is via a thin **Python subprocess bridge**. ### Bridge pattern ``` IHP controller action → AgentBridge.callLlmBridge (Haskell) → System.Process: python3 scripts/llm_bridge.py → llm_connect.create_adapter(provider, model, api_key, system_prompt) → adapter.execute_prompt(prompt, RunConfig(...)) → JSON stdout: {content, model, tokens_in, tokens_out, finish_reason} ← AgentResponse | AgentError ``` `scripts/llm_bridge.py` is the sole integration seam. Each `AgentRegistration` record stores the provider, model name, and system prompt that are passed to the bridge. The API key is resolved from environment variables at bridge call time (following llm-connect's key resolution order: env var → key file → error). ### Supported providers via llm-connect | Provider | llm-connect adapter | Notes | |---|---|---| | `openrouter` | `OpenRouterAdapter` | Multi-model routing; recommended for production | | `gemini` | `GeminiAdapter` | Free tier available; Google AI Studio key | | `openai` | `OpenAIAdapter` | GPT-4o and family | | `claude-code` | `ClaudeCodeAdapter` | Shells out to `claude --print` CLI | ### Known llm-connect limitations (feature requests raised in T10) | Gap | Impact on Phase 11 | Feature request | |---|---|---| | No HTTP serve mode | Process spawn overhead on every agent call | FR-1: `--serve` mode (local JSON-RPC HTTP server) | | No routing policy API | ModelRoutingPolicy implemented in Haskell only | FR-2: `RoutingPolicy` class for declarative provider/model selection | | Synchronous only | Collective proposals invoke agents sequentially | FR-3: `async_execute_prompt()` for concurrent execution | | No token budget ledger | Delegation budget is checked by Haskell, not enforced by bridge | FR-4: `BudgetTracker` that spans a delegation chain | These limitations do not block Phase 11 — they affect performance and architectural elegance. The feature requests are filed against llm-connect as a separate deliverable (T10). ## GAAF Architectural Constraints 1. `agent_registrations.trust_level` must carry a CHECK constraint (`advisory`, `elevated`, `autonomous`) — no bare TEXT discriminator. 2. `agent_delegations.status` must carry a CHECK constraint (`pending`, `completed`, `failed`, `cancelled`). 3. `collective_proposals.consensus_status` must carry a CHECK constraint (`pending`, `consensus`, `divergent`). 4. `ai_governance_policies.allowed_actions` is a JSONB array — validated at the controller layer (each element must be one of: `read`, `propose`, `delegate`, `auto_apply`). 5. Core tables remain frozen — `agent_proposals` gains `agent_registration_id` and token columns via ALTER TABLE, with a corresponding update to `/contracts/core/`. 6. Append-only invariant on `interaction_events` and `outcome_signals` is permanent and unaffected by this phase. ## Data Artifacts Introduced `AgentRegistration`, `ModelRoutingPolicy`, `AgentDelegation`, `CollectiveProposal`, `CollectiveProposalContribution`, `AiGovernancePolicy`, `AgentPerformanceRecord` ### Schema additions ```sql -- agent_registrations: named, versioned AI agents backed by llm-connect providers CREATE TABLE agent_registrations ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, hub_id UUID NOT NULL REFERENCES hubs(id), name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, description TEXT, provider TEXT NOT NULL, -- openrouter | gemini | openai | claude-code model_name TEXT NOT NULL, trust_level TEXT NOT NULL DEFAULT 'advisory', capabilities JSONB NOT NULL DEFAULT '[]', system_prompt TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, version INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, CHECK (trust_level IN ('advisory', 'elevated', 'autonomous')) ); -- model_routing_policies: task_type → agent selection rules per hub CREATE TABLE model_routing_policies ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, hub_id UUID NOT NULL REFERENCES hubs(id), task_type TEXT NOT NULL, agent_registration_id UUID NOT NULL REFERENCES agent_registrations(id), priority INTEGER NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, UNIQUE (hub_id, task_type, priority) ); -- agent_delegations: auditable inter-agent subtask delegation records CREATE TABLE agent_delegations ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, delegating_agent_id UUID NOT NULL REFERENCES agent_registrations(id), receiving_agent_id UUID NOT NULL REFERENCES agent_registrations(id), parent_proposal_id UUID REFERENCES agent_proposals(id), scope TEXT NOT NULL, token_budget INTEGER NOT NULL DEFAULT 1000, tokens_used INTEGER, status TEXT NOT NULL DEFAULT 'pending', result JSONB, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, completed_at TIMESTAMP WITH TIME ZONE, CHECK (status IN ('pending', 'completed', 'failed', 'cancelled')) ); -- collective_proposals: multi-agent proposals with attribution CREATE TABLE collective_proposals ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, title TEXT NOT NULL, summary TEXT, task_type TEXT NOT NULL, consensus_status TEXT NOT NULL DEFAULT 'pending', final_content JSONB, source_widget_id UUID REFERENCES widgets(id), source_candidate_id UUID REFERENCES requirement_candidates(id), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, CHECK (consensus_status IN ('pending', 'consensus', 'divergent')) ); -- collective_proposal_contributions: per-agent contribution records CREATE TABLE collective_proposal_contributions ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, collective_proposal_id UUID NOT NULL REFERENCES collective_proposals(id), agent_registration_id UUID NOT NULL REFERENCES agent_registrations(id), content JSONB NOT NULL, tokens_in INTEGER, tokens_out INTEGER, model_used TEXT, contributed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL ); -- ai_governance_policies: per-hub rules controlling agent scope CREATE TABLE ai_governance_policies ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, hub_id UUID NOT NULL REFERENCES hubs(id), agent_registration_id UUID NOT NULL REFERENCES agent_registrations(id), artifact_type TEXT NOT NULL, allowed_actions JSONB NOT NULL DEFAULT '["read"]', is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL ); -- agent_performance_records: periodic snapshots of per-agent metrics CREATE TABLE agent_performance_records ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, agent_registration_id UUID NOT NULL REFERENCES agent_registrations(id), hub_id UUID NOT NULL REFERENCES hubs(id), period_start TIMESTAMP WITH TIME ZONE NOT NULL, period_end TIMESTAMP WITH TIME ZONE NOT NULL, proposals_generated INTEGER NOT NULL DEFAULT 0, proposals_accepted INTEGER NOT NULL DEFAULT 0, proposals_rejected INTEGER NOT NULL DEFAULT 0, proposals_revised INTEGER NOT NULL DEFAULT 0, mean_confidence DOUBLE PRECISION, calibration_score DOUBLE PRECISION, computed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL ); -- Extend agent_proposals with agent_registration_id and token tracking ALTER TABLE agent_proposals ADD COLUMN agent_registration_id UUID REFERENCES agent_registrations(id), ADD COLUMN tokens_in INTEGER, ADD COLUMN tokens_out INTEGER; ``` --- ## Tasks ### T01 — Schema: all Phase 11 tables + migration ```task id: IHUB-WP-0012-T01 status: done priority: high state_hub_task_id: "d86574f0-f0a4-4217-b4de-d020442de7e4" ``` Add all Phase 11 tables to `Application/Schema.sql` and write migration `Application/Migration/-ihf-phase11-ai-federation.sql`. Includes the ALTER TABLE on `agent_proposals` to add `agent_registration_id`, `tokens_in`, `tokens_out`. Run `migrate` after writing. --- ### T02 — llm-connect bridge: scripts/llm_bridge.py + Application/Helper/AgentBridge.hs ```task id: IHUB-WP-0012-T02 status: done priority: high state_hub_task_id: "404d1a89-aae2-49ea-a565-90261001a633" ``` **`scripts/llm_bridge.py`** — thin wrapper around llm-connect: ```python #!/usr/bin/env python3 # Usage: echo '{"provider":"openrouter","model":"...","prompt":"..."}' | python3 scripts/llm_bridge.py import sys, json, os sys.path.insert(0, os.path.expanduser("~/llm-connect")) from llm_connect import create_adapter, RunConfig from llm_connect.exceptions import LLMError def main(): req = json.load(sys.stdin) try: adapter = create_adapter( provider=req.get("provider", "openrouter"), model=req.get("model"), api_key=req.get("api_key"), system_prompt=req.get("systemPrompt"), ) config = RunConfig( model_name=req.get("model", ""), temperature=req.get("temperature", 0.7), max_tokens=req.get("maxTokens", 2000), ) resp = adapter.execute_prompt(req["prompt"], config) print(json.dumps({ "content": resp.content, "model": resp.model, "tokensIn": resp.usage.get("prompt_tokens", 0), "tokensOut": resp.usage.get("completion_tokens", 0), "finishReason": resp.finish_reason, })) except LLMError as e: json.dump({"error": str(e), "errorType": type(e).__name__}, sys.stdout) sys.exit(1) if __name__ == "__main__": main() ``` **`Application/Helper/AgentBridge.hs`** — Haskell wrapper: ```haskell module Application.Helper.AgentBridge where import IHP.Prelude import Data.Aeson (object, (.=), encode, decode) import qualified Data.ByteString.Lazy as LBS import System.Process (readProcessWithExitCode) import System.Exit (ExitCode(..)) import Generated.Types data BridgeRequest = BridgeRequest { provider :: !Text , model :: !Text , systemPrompt :: !(Maybe Text) , prompt :: !Text , maxTokens :: !Int , temperature :: !Double } data BridgeResponse = BridgeResponse { content :: !Text , modelUsed :: !Text , tokensIn :: !Int , tokensOut :: !Int , finishReason :: !Text } deriving (Show) data BridgeError = BridgeError { errorMessage :: !Text , errorType :: !Text } deriving (Show) callBridge :: BridgeRequest -> IO (Either BridgeError BridgeResponse) callBridge req = do let input = cs . LBS.toStrict . encode $ object [ "provider" .= req.provider , "model" .= req.model , "systemPrompt" .= req.systemPrompt , "prompt" .= req.prompt , "maxTokens" .= req.maxTokens , "temperature" .= req.temperature ] (exitCode, stdout, stderr) <- readProcessWithExitCode "python3" ["scripts/llm_bridge.py"] input let outBytes = LBS.fromStrict (cs stdout) case exitCode of ExitSuccess -> case decode outBytes of Just v -> pure (Right v) Nothing -> pure (Left (BridgeError "Unparseable bridge output" "ParseError")) ExitFailure _ -> case decode outBytes of Just v -> pure (Left v) Nothing -> pure (Left (BridgeError (cs stderr) "BridgeError")) -- | Call bridge using an AgentRegistration record. callAgent :: AgentRegistration -> Text -> IO (Either BridgeError BridgeResponse) callAgent agent prompt = callBridge BridgeRequest { provider = agent.provider , model = agent.modelName , systemPrompt = agent.systemPrompt , prompt , maxTokens = 2000 , temperature = 0.7 } ``` --- ### T03 — Agent Registry: AgentRegistration CRUD + capability/trust UI ```task id: IHUB-WP-0012-T03 status: done priority: high state_hub_task_id: "3ae5b3b1-e644-444d-ae72-8eef07318c49" ``` **Controller:** `Web/Controller/AgentRegistrations.hs` Actions: - `AgentRegistrationsAction` — list all agents with hub name, trust level, provider, active/inactive - `ShowAgentRegistrationAction { agentRegistrationId }` — detail with routing policies, recent proposals, performance record - `NewAgentRegistrationAction` / `CreateAgentRegistrationAction` — form with hub selector, provider dropdown (openrouter / gemini / openai / claude-code), model name, trust level selector, capabilities multi-tag input, system prompt textarea - `EditAgentRegistrationAction` / `UpdateAgentRegistrationAction` - `DeactivateAgentAction { agentRegistrationId }` — set `is_active = False` Provider dropdown options map to the four llm-connect adapters. **Views:** `Web/View/AgentRegistrations/{Index,Show,New,Edit}.hs` Add nav link ("Agents") next to existing "Agent" proposals link. --- ### T04 — Model routing: ModelRoutingPolicy CRUD + Application/Helper/ModelRouter.hs ```task id: IHUB-WP-0012-T04 status: done priority: high state_hub_task_id: "20bcff74-1923-4c11-95ef-2b37a7a10dd8" ``` **Controller:** `Web/Controller/ModelRoutingPolicies.hs` Actions: - `ModelRoutingPoliciesAction` — list by hub, grouped by task_type - `NewModelRoutingPolicyAction` / `CreateModelRoutingPolicyAction` — hub, task_type selector (requirement_draft / triage / synthesis / policy_check / implementation), agent selector, priority - `DeleteModelRoutingPolicyAction { modelRoutingPolicyId }` **`Application/Helper/ModelRouter.hs`:** ```haskell module Application.Helper.ModelRouter where import IHP.Prelude import IHP.ControllerPrelude import Generated.Types -- | Resolve the AgentRegistration to use for a hub + task type. -- Selects the highest-priority active policy for the hub. -- Returns Nothing if no policy matches (caller should fall back or error). resolveAgent :: (?modelContext :: ModelContext) => Id Hub -> Text -> IO (Maybe AgentRegistration) resolveAgent hubId taskType = do mPolicy <- sqlQuery "SELECT mrp.agent_registration_id \ \ FROM model_routing_policies mrp \ \ WHERE mrp.hub_id = ? AND mrp.task_type = ? AND mrp.is_active = TRUE \ \ ORDER BY mrp.priority DESC LIMIT 1" (hubId, taskType) case mPolicy of [Only agentId] -> fetchOneOrNothing agentId _ -> pure Nothing ``` --- ### T05 — Agent invocation: route + invoke via bridge, replace Phase 5 hard-coded calls ```task id: IHUB-WP-0012-T05 status: done priority: high state_hub_task_id: "45fec21a-47d3-4381-99bc-88dcf0f117cb" ``` Update the four existing agent invocation points to use the routing + bridge: 1. `SummarizeClusterAction` in `Web/Controller/Widgets.hs` 2. `DraftRequirementAction` in `Web/Controller/Widgets.hs` 3. `ProposeImplementationAction` in `Web/Controller/DecisionRecords.hs` 4. `DetectDuplicatesAction` in `Web/Controller/RequirementCandidates.hs` **Pattern for each:** ```haskell mAgent <- resolveAgent hubId "requirement_draft" case mAgent of Nothing -> setErrorMessage "No routing policy for this task type" Just agent -> do -- Check AI governance policy before invocation allowed <- checkGovernancePolicy hubId agent.id "requirement_candidate" if not allowed then do newRecord @AgentProposal |> set #status "blocked_by_policy" |> set #agentRegistrationId (Just agent.id) |> createRecord setErrorMessage "Blocked by AI governance policy" else do result <- callAgent agent prompt case result of Left err -> setErrorMessage err.errorMessage Right resp -> do newRecord @AgentProposal |> set #content resp.content |> set #agentRegistrationId (Just agent.id) |> set #tokensIn (Just resp.tokensIn) |> set #tokensOut (Just resp.tokensOut) |> createRecord ``` `checkGovernancePolicy` defined in `Application/Helper/AgentBridge.hs`: queries `ai_governance_policies` for the hub + agent + artifact_type combination. --- ### T06 — Inter-agent delegation: AgentDelegation records + bounded sub-task invocation ```task id: IHUB-WP-0012-T06 status: done priority: medium state_hub_task_id: "9fe1c284-ecb7-4777-98f3-253224df704c" ``` **Controller:** `Web/Controller/AgentDelegations.hs` Actions: - `AgentDelegationsAction` — list delegations for a proposal - `ShowAgentDelegationAction { agentDelegationId }` — detail with result - `DelegateSubtaskAction { agentProposalId }` — form: pick receiving agent, scope description, token budget; on submit: create `AgentDelegation` record, call bridge with `maxTokens = delegation.tokenBudget`, store result + `tokensUsed`, set `status = completed | failed` **Delegation tree on proposal Show page:** recursive query over `parent_proposal_id` to render the full provenance chain. --- ### T07 — Collective proposals: multi-agent attribution + consensus detection ```task id: IHUB-WP-0012-T07 status: done priority: medium state_hub_task_id: "37782cec-2d42-44b8-8bda-2049a7bf4898" ``` **Controller:** `Web/Controller/CollectiveProposals.hs` Actions: - `CollectiveProposalsAction` — list by hub - `ShowCollectiveProposalAction { collectiveProposalId }` — side-by-side agent contributions + consensus/divergent status + human review buttons - `CreateCollectiveProposalAction` — invokes all `is_active` agents for the task_type in `model_routing_policies` priority order; creates a `CollectiveProposalContribution` per agent; then runs a synthesis step (invoke the highest-priority agent with all contributions as context) to produce `final_content` **Consensus detection:** ```haskell detectConsensus :: [CollectiveProposalContribution] -> Text detectConsensus contribs = -- Simple heuristic: if all contributions contain the same top-level -- recommendation key, mark as consensus; otherwise divergent if allSameRecommendation contribs then "consensus" else "divergent" ``` **Views:** `Web/View/CollectiveProposals/{Index,Show}.hs` --- ### T08 — AI governance policies: AiGovernancePolicy CRUD + enforcement at invocation ```task id: IHUB-WP-0012-T08 status: done priority: medium state_hub_task_id: "50d05787-9629-4f94-ac55-50a6e417f730" ``` **Controller:** `Web/Controller/AiGovernancePolicies.hs` Actions: - `AiGovernancePoliciesAction` — list by hub - `NewAiGovernancePolicyAction` / `CreateAiGovernancePolicyAction` — form: hub, agent, artifact_type, allowed_actions checkboxes (read / propose / delegate / auto_apply) - `ToggleAiGovernancePolicyAction { aiGovernancePolicyId }` — flip `is_active` **`checkGovernancePolicy` in `Application/Helper/AgentBridge.hs`:** ```haskell checkGovernancePolicy :: (?modelContext :: ModelContext) => Id Hub -> Id AgentRegistration -> Text -> IO Bool checkGovernancePolicy hubId agentId artifactType = do mPolicy <- query @AiGovernancePolicy |> filterWhere (#hubId, hubId) |> filterWhere (#agentRegistrationId, agentId) |> filterWhere (#artifactType, artifactType) |> filterWhere (#isActive, True) |> fetchOneOrNothing case mPolicy of Nothing -> pure True -- no policy = permit by default Just p -> pure ("propose" `elem` jsonArrayTexts p.allowedActions) ``` **Governance compliance panel** added to existing `GovernanceDashboardAction`: count of blocked invocations in the last 7 days per hub. --- ### T09 — Agent performance dashboard ```task id: IHUB-WP-0012-T09 status: done priority: medium state_hub_task_id: "6ef63612-f913-4a13-b683-f44929cb1b2d" ``` **Action added to `AgentRegistrationsController`:** `ComputeAgentPerformanceAction { agentRegistrationId }` — computes a 30-day snapshot and writes `AgentPerformanceRecord`: ```sql SELECT COUNT(*) FILTER (WHERE ap.status = 'accepted') AS accepted, COUNT(*) FILTER (WHERE ap.status = 'rejected') AS rejected, COUNT(*) FILTER (WHERE ap.status NOT IN ('accepted','rejected')) AS other, AVG(ca.score) AS mean_confidence FROM agent_proposals ap LEFT JOIN confidence_annotations ca ON ca.proposal_id = ap.id WHERE ap.agent_registration_id = ? AND ap.created_at >= NOW() - INTERVAL '30 days' ``` Calibration score = Pearson correlation between `ca.score` and the binary accept/reject outcome. **View:** `Web/View/AgentRegistrations/Performance.hs` — panel per agent with progress bars for acceptance/rejection ratio, mean confidence gauge, calibration indicator. --- ### T10 — llm-connect feature requests + scorecard + CLAUDE.md ```task id: IHUB-WP-0012-T10 status: done priority: medium state_hub_task_id: "41a64c6c-0ce8-4c63-9175-6b8d28143f81" ``` **File feature requests against `~/llm-connect`** (as GitHub issues or local `FEATURE_REQUESTS.md` in that repo): | # | Title | Why needed | Proposed API | |---|---|---|---| | FR-1 | HTTP/JSON-RPC serve mode | Avoid process spawn per call in production | `python -m llm_connect.server --port 9999`; IHP calls `POST localhost:9999/execute` | | FR-2 | RoutingPolicy class | Declarative provider/model selection beyond `model_name` in RunConfig | `RoutingPolicy(rules=[{task_type: "triage", prefer: [{provider: "openrouter", model: "claude-haiku-4-5"}], max_cost_per_1k: 0.5}])` | | FR-3 | `async_execute_prompt()` | Collective proposals need parallel agent invocation | Standard asyncio coroutine interface matching `execute_prompt` signature | | FR-4 | `BudgetTracker` for delegation chains | Token budget enforcement across nested delegations | `BudgetTracker(total=4000)` passed through `RunConfig`; raises `LLMBudgetExceededError` | **ARCHITECTURE-LAYERS.md scorecard update:** - Functional: 3.4 → 3.6 (multi-agent federation formalises AI collaboration) - Extensions: 3.8 → 3.9 (agent registry exposes an API surface) - Target overall: ≥3.7 **CLAUDE.md:** move IHUB-WP-0012 to completed; set active → IHUB-WP-0013 (Phase 12 — Platform Memory and Continuous Learning). **Commit all changes** and mark workplan `status: done`.