Files
kandev/docs/decisions/0005-agent-model-unification.md
T
2026-05-16 20:07:57 +01:00

19 KiB
Raw Blame History

0005: Agent model unification — enrich agent_profiles, drop office_agent_instances

Status: proposed Date: 2026-05-06 Area: backend, frontend

Context

Two separate concepts describe "an agent" in the codebase today:

  1. agent_profiles (singular table, defined at apps/backend/internal/agent/settings/store/sqlite.go:56). A profile is a configured variant of a CLI agent tool — it selects a model, a mode (e.g. ACP for Claude Code, plan for OpenCode), permission flags (auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough), and an MCP configuration (in the sibling agent_profile_mcp_configs table). It belongs to a CLI tool via agent_id (agents.id — the CLI registration table). Profiles are global today; the kanban settings UI manages them at /settings/agents/<agent>/profiles/<profile-id>. Workflow steps, workflow_step_participants, kanban tasks, and task_sessions all reference profiles via agent_profile_id.

  2. office_agent_instances (defined at apps/backend/internal/office/repository/sqlite/agents.go). Workspace-scoped. Carries the organisational and runtime identity of an agent: name (e.g. "Backend Engineer Alice"), role (ceo / worker / qa_lead), icon, status, pause_reason, skill_ids, custom_prompt, reports_to, budget, concurrency caps, failure tracking, etc. Each instance has an agent_profile_id foreign key pointing at concept (1).

The two-table model exists because office was added on top of an already-shipped kanban concept. It produces real friction:

  • Workflow_step_participants is keyed by agent_profile_id. For office reviewers we need the rich per-workspace identity (skills + prompt) but the participants table only references the profile tier. Two different reviewer instances sharing one CLI profile become indistinguishable in the workflow store. This is "Blocker 1" from the task-model-unification work.
  • Office tasks reference instances via tasks.assignee_agent_instance_id while workflow steps reference profiles. Every launch path threads both ids and resolves the union.
  • Kanban can't use office-created agents. A CEO with skills + a custom prompt is invisible to kanban workflow editors today, even though the underlying CLI configuration is exactly the same shape as any kanban profile.

The fix is to merge them: enrich agent_profiles with the office-only columns, migrate every office_agent_instance into a profile, and rename agent_instance_id → agent_profile_id everywhere in code and schema. After the merge, agent_profiles is the single source of truth for "an agent" — kanban-shallow agents (no skills, no role) and office-rich agents (with skills + role + workspace scoping) live as rows in the same table.

Decision

One agent_profiles table that supports two richness levels:

  • Shallow profile (kanban-style): just the existing columns. No workspace scope, no skills, no role. The kanban settings UI at /settings/agents/<agent>/profiles/<profile-id> continues to manage these as today.
  • Rich profile (office-style): all of the above PLUS workspace scope, role, skills, custom_prompt, status, budget, concurrency caps, failure tracking, etc. The office agents page surfaces both the shallow fields (so the user can pick / change the underlying CLI client, model, mode, MCP config) AND the office-only enrichment fields.

office_agent_instances is dropped. Every agent_instance_id in schema and code becomes agent_profile_id referencing the merged table. Skill deployment moves into the runtime's launch-prep so every launch (kanban or office) treats skill_ids + custom_prompt uniformly — agents with empty values get a no-op deploy.

Final schema

agent_profiles after this ADR:

CREATE TABLE agent_profiles (
    -- Existing columns (unchanged):
    id TEXT PRIMARY KEY,
    agent_id TEXT NOT NULL,                          -- FK → agents (CLI tool)
    name TEXT NOT NULL,
    agent_display_name TEXT NOT NULL,
    model TEXT NOT NULL DEFAULT '',
    mode TEXT DEFAULT NULL,
    migrated_from TEXT DEFAULT NULL,
    auto_approve INTEGER NOT NULL DEFAULT 0,
    dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0,
    allow_indexing INTEGER NOT NULL DEFAULT 1,
    cli_passthrough INTEGER NOT NULL DEFAULT 0,
    user_modified INTEGER NOT NULL DEFAULT 0,
    plan TEXT DEFAULT '',
    cli_flags TEXT DEFAULT NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,
    deleted_at TIMESTAMP,

    -- NEW (workspace scoping):
    workspace_id TEXT NOT NULL DEFAULT '',           -- '' = global / kanban-legacy; set = workspace-scoped

    -- NEW (organisational identity):
    role TEXT NOT NULL DEFAULT '',                   -- '' | 'ceo' | 'worker' | 'qa_lead' | ...
    icon TEXT NOT NULL DEFAULT '',
    reports_to TEXT NOT NULL DEFAULT '',             -- agent_profiles.id (org chart)

    -- NEW (skills + prompt enrichment):
    skill_ids TEXT NOT NULL DEFAULT '[]',            -- JSON array
    desired_skills TEXT NOT NULL DEFAULT '[]',
    custom_prompt TEXT NOT NULL DEFAULT '',

    -- NEW (runtime state):
    status TEXT NOT NULL DEFAULT 'idle'
        CHECK (status IN ('idle','working','paused','stopped','pending_approval')),
    pause_reason TEXT NOT NULL DEFAULT '',
    last_run_finished_at TIMESTAMP,

    -- NEW (concurrency / scheduling):
    max_concurrent_sessions INTEGER NOT NULL DEFAULT 1,
    cooldown_sec INTEGER NOT NULL DEFAULT 0,
    skip_idle_runs INTEGER NOT NULL DEFAULT 0,

    -- NEW (failure tracking):
    consecutive_failures INTEGER NOT NULL DEFAULT 0,
    failure_threshold INTEGER NOT NULL DEFAULT 3,

    -- NEW (executor / cost):
    executor_preference TEXT NOT NULL DEFAULT '',
    cheap_agent_profile_id TEXT NOT NULL DEFAULT '', -- self-ref for low-priority work
    budget_monthly_cents INTEGER NOT NULL DEFAULT 0,

    -- NEW (catch-all for office fields not explicitly listed):
    settings TEXT NOT NULL DEFAULT '{}',

    FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);

CREATE INDEX idx_agent_profiles_workspace ON agent_profiles(workspace_id);
CREATE INDEX idx_agent_profiles_role ON agent_profiles(workspace_id, role) WHERE role != '';
CREATE INDEX idx_agent_profiles_reports_to ON agent_profiles(reports_to) WHERE reports_to != '';

MCP config stays in the sibling agent_profile_mcp_configs table. That's already a 1:1 side table keyed by profile_id; it doesn't need to fold in. Both shallow and rich profiles use it the same way.

Tables that gain renamed columns

Every table that today references office's instance id by *_agent_instance_id (or its variants) is renamed to *_agent_profile_id, pointing at the merged agent_profiles table:

office_skills.created_by_agent_instance_id      → created_by_agent_profile_id
office_cost_events.agent_instance_id            → agent_profile_id
office_routines.assignee_agent_instance_id      → assignee_agent_profile_id
office_approvals.requested_by_agent_instance_id → requested_by_agent_profile_id
office_agent_memory.agent_instance_id           → agent_profile_id
office_channels.agent_instance_id               → agent_profile_id
office_agent_instructions.agent_instance_id     → agent_profile_id
office_inbox_dismissals.agent_instance_id       → agent_profile_id
office_workspace_settings.lead_agent_instance_id → lead_agent_profile_id
runs.agent_instance_id                          → agent_profile_id
tasks.assignee_agent_instance_id                → assignee_agent_profile_id
office_task_participants.agent_instance_id      → agent_profile_id

(After the merge, the office_task_participants table is itself migrated into workflow_step_participants — see "Implementation sequence" below.)

Identity preservation in the migration

For each office_agent_instances row, we INSERT a new agent_profiles row using the instance's id as the new profile's id. That way every existing FK that today points at the instance id (tasks, participants, decisions, runs, costs, etc.) continues to work unchanged after the rename — the column name flips but the value is preserved.

The instance's old agent_profile_id (which today points at a shallow profile that the instance was configured against) gets its columns copied into the new merged row (model, mode, auto_approve, mcp_config, etc.). The original shallow profile keeps existing as a distinct row — kanban tasks that referenced it continue to reference it. After the migration we'll have:

  • The original shallow row (kanban-flavour, workspace_id='').
  • A new rich row using the old instance's id (office-flavour, workspace_id set, with the CLI fields copied from the original shallow row).

Same logical kanban behaviour; rich identity now first-class.

Migration policy

Per the user's directive: office tables can be wiped without migrations (greenfield — the office feature is in development, not deployed). For non-office tables (tasks, task_sessions, runs, workflow_step_participants) we write proper migrations because they carry kanban data.

Concretely:

Table Migration policy
office_agent_instances wipe + repopulate from migration insert
office_skills, office_routines, office_approvals, office_agent_memory, office_channels, office_agent_instructions, office_inbox_dismissals, office_workspace_settings, office_cost_events, office_task_participants, office_task_approval_decisions recreate-table with renamed column; data preservation only when trivial — wiping is fine for office state
tasks proper recreate-table migration: copy assignee_agent_instance_id → assignee_agent_profile_id, drop the old column
task_sessions column rename if agent_instance_id exists (kanban data preserved via the rename)
runs column rename, kanban + office data preserved
workflow_step_participants already has task_id (wave 8 piece 1); no schema change

For SQLite, the recreate-table pattern handles column renames where ALTER TABLE RENAME COLUMN isn't available. Look at apps/backend/internal/task/repository/sqlite/base.go for the existing pattern.

UX

Kanban settings UI — unchanged. The page at /settings/agents/<agent>/profiles/<profile-id> continues to manage just the existing fields (model, mode, MCP config, permission flags, etc.). For shallow / kanban-style profiles, the office fields are not exposed — the form looks identical to today.

Office agents page — gains the full picture. The office page surfaces:

  • The existing CLI fields: which CLI client (claude / codex / opencode-acp / amp / etc.), model, mode, MCP config, permissions. Today these fields are configured in kanban settings; on the office page they become inline so the user doesn't have to context-switch.
  • The office enrichment fields: name, role, icon, reports_to, skill_ids, custom_prompt, max_concurrent_sessions, cooldown_sec, budget, etc.

A profile created in the office page is a rich profile (workspace_id set, office fields populated). A profile created in kanban settings is a shallow profile (workspace_id='', office fields empty). Either way, the same row in the same table.

The office agents page becomes the recommended place to create an agent that you intend to use for office work. Existing kanban profiles continue to work and can be enriched into office agents by setting their workspace + office fields.

Office onboarding — adds CLI configuration step. Today's onboarding creates a CEO agent with hardcoded defaults. After this ADR, onboarding asks the user to pick:

  • Which CLI client (claude / codex / opencode-acp / amp / ...).
  • Which model (claude-sonnet / opus / o3-mini / etc.).
  • Which mode (ACP / plan mode / etc.).
  • Optionally MCP servers + permission flags (or accept defaults).

These were previously handled by reading the user's existing kanban settings for an inferred default; making them explicit at onboarding time matches the "office can configure agents end-to-end" model.

Skill deployment in runtime

Today: office's launch path materialises skill_ids + custom_prompt into the worktree before the agent starts (symlink deploy + prompt file). After this ADR: that step lives in internal/agent/runtime/'s launch-prep — every launch reads the agent profile, looks at skill_ids and custom_prompt, and deploys. Empty values → no-op fast path. Kanban launches that don't use these fields are unaffected; kanban profiles that the user later enriches with skills get them on the next launch.

runtime.LaunchSpec.AgentProfileID already exists from Phase 1 of task-model-unification. The runtime resolves the agent record (now including office enrichment) from the profiles repo.

Code-level rename

Across backend (~593 lines, ~131 files) and frontend (~164 lines):

Old New
agent_instance_id (column / JSON / param) agent_profile_id
AgentInstanceID (Go field) AgentProfileID
agentInstanceId (TS field) agentProfileId
models.AgentInstance (Go struct) merge with models.AgentProfile (the existing struct gains office fields)
OfficeAgentInstance (TS type) merge with AgentProfile
office_agent_instances (table refs in code) agent_profiles

Most of this is mechanical rename + recompile. A subset of files have both AgentProfileID and AgentInstanceID as separate fields on the same struct (those collapse — needs hand-judgement per case to ensure no callsite depended on the two values being different).

Consequences

Positive

  • Blocker 1 dissolved. workflow_step_participants.agent_profile_id carries skills + prompt for office reviewers. Decision attribution is unambiguous because there's one identity per agent.
  • Office agents become available to kanban. A CEO or Backend Engineer profile created in the office page is selectable in any workflow editor. Kanban profiles are eligible as office reviewers.
  • One settings model. Skills + custom prompt + role are first-class fields on a profile. The runtime treats every launch the same way.
  • Smaller refactor than the workspace_agents alternative. ~600 callsites instead of ~2,762. Tractable in 1–2 focused waves.

Negative

  • agent_profiles becomes a fat table. ~25 columns, many nullable for shallow profiles. Mitigated by clear documentation of which fields are kanban-relevant vs office-relevant; the kanban UI doesn't expose office columns.
  • Workspace scoping coexistence. Some profiles are global (workspace_id=''), some are workspace-scoped. Every read that's scoped by workspace must filter by workspace_id IN ('', $ws) to see both. Acceptable cost; encoded in the repo helpers.
  • Office onboarding gains complexity. Users now have to make CLI choices upfront. Mitigated by sensible defaults (preselect the user's current kanban-default profile).

Risks

  • Field-name collisions on existing structs. OfficeAgentInstance has both ID and AgentProfileID today; merging makes them potentially the same value. Each callsite needs case-by-case review. Mitigated by mechanical sed for the 80% case + careful review of conflicts.
  • Frontend type churn. AgentProfile is referenced in many components; adding office fields means TypeScript types widen. Mitigated by making office fields optional on the type so existing consumers don't break.

Alternatives considered

Alternative A: Keep two tables, add a bridge

Reject. Preserves Blocker 1.

Alternative B: This proposal — enrich agent_profiles, drop office_agent_instances

Chosen. Smallest viable refactor that solves the impedance mismatch. Keeps the existing table and its FK relationships. Office becomes a richer view of the same row.

Alternative C: New workspace_agents table, drop both old tables, full rename

Originally proposed in the first draft of this ADR. Rejected as disproportionately large (~2,762 callsites for a name change). The user's directive: "i don't want to move away from agent profiles for now, its a much bigger change."

Implementation sequence

Three waves, each shippable on its own:

Wave A — Foundation (schema + migration only, no callsite changes)

  1. Add the new columns to agent_profiles (idempotent ALTER TABLE migrations).
  2. Update the AgentProfile Go model to carry the new fields.
  3. Update the repo to round-trip them.
  4. Migration: insert one agent_profiles row per office_agent_instances row, preserving the instance's id.
  5. Skill deployment moves into internal/agent/runtime/'s launch-prep (no-op if skill_ids empty).

After Wave A: agent_profiles is the canonical store, both shallow and rich rows live in it. office_agent_instances still exists but is no longer the source of truth — it becomes a redundant copy until Wave B.

Wave B — Callsite rename

  1. Rename agent_instance_id → agent_profile_id columns in all non-office tables (tasks, runs, task_sessions, workflow_step_participants, etc.) — proper migrations.
  2. Recreate office tables with renamed columns (no data preservation needed; greenfield).
  3. Rename AgentInstanceID → AgentProfileID Go fields, code references, frontend types, payloads.
  4. Drop office_agent_instances table.

After Wave B: office_agent_instances is gone; everything references agent_profile_id against the unified agent_profiles table.

Wave C — Dashboard reviewer/approver migration

  1. Migrate office_task_participants → workflow_step_participants (using the dual-scope task_id from wave 8 piece 1).
  2. Migrate office_task_approval_decisions → workflow_step_decisions.
  3. Drop the two legacy office tables.
  4. Drop tasks.assignee_agent_profile_id entirely. Per-task assignee resolves through the workflow step's primary_agent_profile_id (or workflow_step_participants for multi-agent stages). The per-task snapshot was redundant.
  5. Update office UI to surface the new agent fields (Office Agents page changes).
  6. Update office onboarding to ask for CLI client + model + mode.

After Wave C: the dashboard reviewer/approver flow uses the unified participants/decisions tables. The Phase 4 deferred items from ADR 0004 are resolved.

Out of scope

  • Cross-workspace agent sharing (workspace_id is the boundary).
  • Agent versioning beyond updated_at.
  • Per-tool MCP overrides at the office level (use the existing agent_profile_mcp_configs for now).
  • Renaming agent_profiles → agents or anything else. Keep the current name; it fits.

References

  • ADR 0004 — task-model-unification.
  • docs/specs/task-model-unification/plan.md Phase 4 deferred items.
  • Wave-9 / Wave-10 agent scope surveys (in PR commit messages).