Agent Collaborators — implementation notes
Branch: session/feat-collaborators (worktree wt-feat-collaborators, based on origin/develop).
What shipped
Per-agent Collaborators: an allow-list of the owner's OTHER agents that a given
agent may spawn/delegate to as sub-agents, layered ON TOP of the existing sub-agent
delegation pipeline (judgment layer G9). Zero configuration keeps exactly the legacy
behaviour (delegation to self only); enabling collaborators makes named children
admissible through every delegation path (chat tool, workflow agent.delegate node,
any future caller), because enforcement lives in the single runner choke point.
Data model
- Entity
AgentCollaborator→ tableagent_collaborators(packages/agent/src/entities/agent-collaborator.entity.ts):iduuid pk,userId(owner of both ends),agentId(parent),collaboratorAgentId,enabledboolean default true,tenantId/organizationIdnullable Tier C denorm,createdAt/updatedAt. UNIQUE(agentId,collaboratorAgentId); reverse index oncollaboratorAgentId; index onuserId. - Registered in
entities/index.ts,database/_entities-inventory.ts,database/_entity-names.ts(drift specs stay green). - Migration
apps/api/src/migrations/1786800000000-CreateAgentCollaborators.ts— portable Table API, idempotent guard, FKs toagents.idCASCADE on BOTH ends (guarded onhasTable('agents')), spec atapps/api/src/migrations/__tests__/CreateAgentCollaborators.spec.ts.agentId != collaboratorAgentIdis a SERVICE guard (repository throws + controller 400), not a DB CHECK — no migration here uses TableCheck and CHECK quoting is not portable across the postgres/better-sqlite3 pair; the runner treats a self edge as allowed anyway, so a smuggled row would be inert. - Repository
AgentCollaboratorRepository(packages/agent/src/database/repositories/agent-collaborator.repository.ts):listForAgent,listEnabledForAgent,listAgentsAllowing,upsert(idempotent, self-edge throws),remove,deleteAllForAgent. Provided + exported by the agent-sideAgentsModule; re-exported from@ever-works/agent/databaseand@ever-works/agent/agents.
Contracts
packages/contracts/src/delegation/sub-agent-delegation.types.ts:
SubAgentDelegationRefusalCodegainscollaborator-not-allowed(append-only; the vitest pin was updated).- New pure helper
evaluateCollaboratorDelegation(parentAgentId, childAgentId, rules)(+SubAgentCollaboratorRule/SubAgentCollaboratorDecision): self ⇒ allowed; named child ⇒ requires anenabledrule; disabled vs missing get distinct messages. Unit-tested insrc/delegation/__tests__/sub-agent-delegation.spec.ts.
Enforcement (the choke point)
apps/api/src/agents/sub-agent-delegation.runner.ts
(SubAgentDelegationRunnerService.run): after the existing same-owner check, a
delegation whose childAgentId differs from the parent loads the parent's rules and
runs evaluateCollaboratorDelegation; a negative decision returns a typed REFUSAL
(refuseSubAgentDelegation(..., 'collaborator-not-allowed', ...)), not a failure.
AgentCollaboratorRepository is injected NON-optionally (a security gate must not
fail open; TasksModule already imports the agent-side AgentsModule that exports it).
Ownership is checked BEFORE the allow-list, so an enabled rule can never launder a
cross-owner child. Spec updated: sub-agent-delegation.runner.spec.ts (refusal on
no-rows / disabled row, pass-through on enabled row, self-delegation never consults
the list, cross-owner precedence).
New agent tool delegateToAgent
packages/agent/src/agents/agent-tool.service.ts:
- Gated on
permissions.canAssignTasks(same capability class as the task tools andrun_workflow_graph) + bothSubAgentDelegationServiceandAgentCollaboratorRepositorybeing bound (both appended LAST +@Optional(), so every positional spec constructor keeps compiling and legacy runtimes expose nothing). - Input
{ targetAgentId | targetAgentSlug, objective, context? }→ drives the SAMESubAgentDelegationService.delegatepath as the workflow node withchildAgentId=target,scope.allowedTools=['*'],limits.parentScope= the parent's own lazily-resolved tool set +networkAccess=canCallExternalTools, a bounded per-runsiblingCount(500-run evicting map, mirroringWorkflowNodeRunnerService), 5-minute duration budget, andparentRunIdfrom the run context (feeds the server-derived depth resolver). - Target resolution runs against SELF + ENABLED collaborators only (owner-scoped agent loads) — unknown/disabled/foreign targets error with the current enabled roster (name + slug) so the model can self-correct; nothing probes the agent table.
- Spec:
packages/agent/src/agents/__tests__/agent-tool-delegate.spec.ts(11 tests).
API
apps/api/src/agents/agent-collaborators.controller.ts (registered in the api-side
AgentsModule; its module pin spec got the matching controller stub):
GET /api/agents/:id/collaborators— every OTHER agent of the owner (cap 200) as{agentId,name,slug,title,status,avatarMode,avatarIcon,configured,enabled}.PUT /api/agents/:id/collaborators/:collaboratorAgentId— bodyUpdateAgentCollaboratorDto { enabled: boolean }(whitelisted), idempotent upsert. Self edge → 400. Both ends ownership-checked viaAgentsService.getOne(cross-user = 404, no existence leak). Throttled 30/min.DELETE /api/agents/:id/collaborators/:collaboratorAgentId— removes the rule,{removed: boolean}, idempotent.
Spec: agent-collaborators.controller.spec.ts (list shape/state merge, self-400,
foreign-404, delete idempotency, DTO ValidationPipe cases incl. forbidNonWhitelisted,
activity-trail rows incl. the unbound-service and throwing-logger paths).
tenantId/organizationId are written as NULL: they are the EW-651 Tier C denorm
columns, AgentDto does not expose the parent's scope ids, and no stamping
subscriber exists in this repo despite the comments in sibling entities. Every read
path is keyed on agentId, which the controller owner-checks first, so nothing
depends on them today.
Activity trail
Three additive ActivityActionType members (the column is a plain varchar — no
migration): agent_collaborator_enabled / _disabled / _removed. Emitted by
AgentCollaboratorsController.tryLog (best-effort, @Optional() ActivityLogService,
same posture as AgentsController), with details.resourceId = the PARENT agent and
details.collaboratorAgentId = the other end. A DELETE that removed nothing writes no
row. Added to AGENT_LIFECYCLE_EVENT_TYPES so GET /api/agents/:id/events returns
them, and to AgentActivityClient's presentation map so they render as labelled pills.
The literal-count pin in packages/agent/src/entities/__tests__/activity-log.types.spec.ts
went 123 → 126.
Web UI
- New tab Collaborators in
AgentDetailTabs(between Skills and Budgets), routeROUTES.DASHBOARD_AGENT_COLLABORATORS→/agents/[id]/collaborators. - Server page
apps/web/src/app/[locale]/(dashboard)/agents/[id]/collaborators/page.tsxcomposes agent (404 gate) + candidate list. apps/web/src/components/agents/AgentCollaboratorsClient.tsx— roster of the owner's other agents (avatar initials, name, slug, role line) each with aSwitch; optimistic toggle through server actions with rollback + toast; explanatory copy + legacy-default note. Rows that already have a rule also get a Clear rule button (DELETE) — disabling keeps the row, clearing returns the pair to UNCONFIGURED.- API wrappers
agentsAPI.listCollaborators/setCollaborator/removeCollaborator(lib/api/agents.ts), client-safeAgentCollaboratorCandidateinlib/api/agents.shared.ts, server actionslistAgentCollaboratorsAction/setAgentCollaboratorAction/removeAgentCollaboratorAction(app/actions/agents.ts). - i18n:
dashboard.agentsPage.tabs.collaborators+dashboard.agentsPage.collaborators.*added to ALL 21 locale files (English values copied verbatim per convention).
Test commands
cd packages/contracts && npx vitest run src/delegation/__tests__/sub-agent-delegation.spec.ts
cd packages/agent && npx jest --testPathPattern='agent-tool-delegate|agent-collaborator.repository|activity-log.types'
cd apps/api && npx jest --testPathPattern='sub-agent-delegation.runner|agent-collaborators.controller|agents.controller.runtime|agents.module|CreateAgentCollaborators'
Gotcha: apps/api resolves @ever-works/agent/* through the package's BUILT types, so
after touching activity-log.types.ts (or any exported agent type) run
npx turbo build --filter=@ever-works/agent before the api Jest run — otherwise the
new enum members surface as TS2339 inside ts-jest only.
Type-check/build: npx turbo type-check --filter=@ever-works/contracts --filter=@ever-works/agent --filter=ever-works-api --filter=ever-works-web
(shared packages must be built first: npx turbo build --filter=ever-works-api^...).
Divergences from the brief (code wins)
- The brief named the runner file
sub-agent-delegation.runner.service.ts; the real file issub-agent-delegation.runner.ts(class name matches the brief). - The brief asked for the tool descriptor to LIST enabled collaborators in its
description.
resolveAllowedToolsis synchronous by contract (no I/O at descriptor-build time), so the roster is surfaced at INVOKE time instead: any call with a missing/unknown/disabled target errors with the current enabled list (name + slug). Documented in the builder's doc comment. - The brief said "no collaborator rows = exactly today's behavior", describing today
as self-only. Strictly, today's runner admitted ANY same-owner
childAgentId; no production caller ever set one (the workflow node doesn't), so implementing the brief's self-only-when-unconfigured semantics changes no observable behaviour while making the allow-list opt-in rather than implicit. - The brief's "CHECK/service-guard" option for
agentId != collaboratorAgentIdwas resolved as service-guard only (portability; see Data model above). - No new DI tokens were bound, so no module-shape pin gained providers; the api-side
agents.module.spec.tsonly needed the new controller stubbed.
Verification (finishing session, 2026-08-15)
The two interrupted sessions' snapshot was re-verified from scratch, then completed with the activity trail, the repository spec, the Clear-rule affordance and the catalog doc. Results on this branch:
packages/contractsvitest delegation spec: 34 passed.packages/agentjestagent-tool|workflow-node.runner|sub-agent-delegation: 10 suites / 151 passed (includes the 11agent-tool-delegatetests and every pre-existing positional-constructor spec ofAgentToolService).packages/agentjestactivity-log.types|agent-collaborator.repository: 2 suites / 67 passed;--testPathPattern='activity': 6 suites / 109 passed.apps/apijestsub-agent-delegation|agent-collaborators|agents.module|CreateAgentCollaborators: 5 suites / 52 passed;agent-collaborators.controller|agents.controller.runtime|agents.module: 3 suites / 83 passed;--testPathPattern='activity': 8 suites / 118 passed.turbo type-checkgreen for@ever-works/contracts,@ever-works/agent,ever-works-api;apps/webtsc --noEmitgreen.turbo buildgreen for@ever-works/contracts,@ever-works/plugin,@ever-works/agent,ever-works-api,ever-works-web.- All 21 locale files parse and carry exactly the two new key blocks
(
tabs.collaborators+collaborators.*with 8 keys) — no duplicate-key landmine ("collaborators"appears exactly twice per file). - No pinned e2e validation/authz matrix mentions the new DTO or routes
(
UpdateAgentCollaboratorDtois a brand-new DTO; the e2e "collaborator" hits are the unrelated work-members feature).flow-agent-permissions-matrixdocuments the tool catalog in prose only and pins no tool-name list. - DI checked by hand (unit tests cannot catch a boot-time resolution failure):
the api-side
AgentsModuleimports the agent-sideAgentsModule, which provides AND exportsAgentCollaboratorRepository(+TypeOrmModule.forFeaturefor the entity), so both the new controller and — viaTasksModule's import of the same module — the runner's non-optional constructor arg resolve.
Branch base is 43de25a41; origin/develop has since moved on (8 commits). A
git diff origin/develop..HEAD therefore also shows develop-side additions
(e.g. assignedIdeaId on AgentRepository) as if they were deletions — diff
against the merge base instead.
Known follow-ups
- Surface the
collaborator-not-allowedrefusal in the Sessions/Task UI with a deep link to the Collaborators tab. - Playwright e2e for the tab (toggle → refusal path) — cheap once a seeded two-agent fixture exists.
deleteAllForAgentis available but hard agent deletes already cascade via FK; wire it into any soft-delete cleanup if agents ever stop being FK-cascaded.