Skip to main content

Agent/Task Injection Tokens

The Agents/Skills/Tasks feature set (PR #1017) introduces a family of Nest injection tokens that platform operators can bind to custom adapters. Each token follows the same posture: the agent package defines a small contract interface; downstream services consume the contract via @Optional() @Inject(TOKEN); the API-side module (apps/api/src/agents/agents.module.ts or apps/api/src/tasks/tasks.module.ts) binds the token to a thin adapter over the real platform service.

This page is the single reference for what each token does, what's bound by default, and the binding pattern.

Why injection tokens

Three concerns drove this layout:

  1. Circular-dep avoidance. Many of these adapters bridge the agent package's agents/ module with services in tasks-domain/ or facades/. Importing the concrete services directly would create a graph cycle. Tokens break the cycle: the agent module only knows the contract; the platform module owns the binding.
  2. Operator opt-in for risky tools. commitToRepo / openPullRequest are deliberately unbound by default — operators bind them only when the git provider configuration is stable. Leaving the token unbound keeps the model from seeing tools that would fail mysteriously at invoke time.
  3. Best-effort side effects. Post-processor failures (a flaky chat-back insert, a transition gate rejection) should not unwind the upstream LLM work. The @Optional() injection + try/catch wrapper at the consumer site enforces this contract.

Inventory

TokenContractConsumerDefault binding
AGENT_TASK_EXECUTE_DISPATCHERAgentTaskExecuteDispatcherTaskTransitionServiceagentTaskExecuteTriggerAdapter (Trigger.dev)
AGENT_CHAT_REPLY_DISPATCHERAgentChatReplyDispatcherTaskChatService.postagentChatReplyTriggerAdapter (Trigger.dev)
AGENT_RUN_CHAT_BACK_POSTERAgentRunChatBackPosterAgentRunService.finalizeTaskChatService.post(authorType='agent')
AGENT_RUN_TASK_FINISHERAgentRunTaskFinisherAgentRunService.finalizeTasksService.transition
AGENT_GIT_FACADEAgentGitFacadeAgentToolServiceUNBOUND in v1
AGENT_PLUGIN_TOOLS_FACADEAgentPluginToolsFacadeAgentToolServiceSearchFacadeService + ScreenshotFacadeService + ContentExtractorFacadeService

All token strings are exported from @ever-works/agent/agents and @ever-works/agent/tasks-domain respectively. Contract interfaces ship alongside.

Token-by-token reference

AGENT_TASK_EXECUTE_DISPATCHER

Contract

interface AgentTaskExecuteDispatcher {
enqueue(payload: { agentId: string; userId: string; taskId: string; dedupKey: string }): Promise<{ runId: string }>;
}

Consumer: TaskTransitionService.transition(), on every → in_progress transition, for each Agent assignee on the Task.

Default binding: agentTaskExecuteTriggerAdapter from packages/tasks/src/dispatchers/agent-task-dispatchers.ts — forwards to a Trigger.dev agent-task-execute job (maxDuration=3600). dedupKey = '${taskId}:${agentId}:${recurrenceOccurredCount + 1}'.

When to override: testing (synchronous in-process dispatcher for e2e specs); replacing Trigger.dev with another job runner.

AGENT_CHAT_REPLY_DISPATCHER

Contract

interface AgentChatReplyDispatcher {
enqueue(payload: {
agentId: string;
userId: string;
taskId: string;
triggeringMessageId: string;
dedupKey: string;
}): Promise<{ runId: string }>;
}

Consumer: TaskChatService.post(), for every resolved @agent mention.

Default binding: agentChatReplyTriggerAdapter — Trigger.dev agent-chat-reply job (maxDuration=300). T6 chat-dedup posture: an in-flight run for the same (taskId, agentId) is re-used rather than spawned.

When to override: same as above — testing and alternate job runners.

AGENT_RUN_CHAT_BACK_POSTER

Contract

interface AgentRunChatBackPoster {
postReply(input: { userId: string; taskId: string; agentId: string; body: string }): Promise<{ messageId: string }>;
}

Consumer: AgentRunService.finalize(context, outcome) — called for chat kind outcomes when outcome.replyBody is set.

Default binding (in apps/api/src/agents/agents.module.ts):

{
provide: AGENT_RUN_CHAT_BACK_POSTER,
inject: [TaskChatService],
useFactory: (chat: TaskChatService): AgentRunChatBackPoster => ({
async postReply({ userId, taskId, agentId, body }) {
const row = await chat.post(userId, {
taskId,
authorType: 'agent',
authorId: agentId,
body,
});
return { messageId: row.id };
},
}),
}

The wrapped TaskChatService.post() still runs the mention parser

  • secret-scan + size cap, so an agent-authored reply gets the same treatment as a user-authored one.

When to override: routing chat-back posts through an alternate chat surface (Slack, email digest, etc.); short-circuiting the post to a moderation queue.

AGENT_RUN_TASK_FINISHER

Contract

interface AgentRunTaskFinisher {
finishTask(input: {
userId: string;
taskId: string;
to: 'done' | 'in_review' | 'blocked' | 'cancelled';
force?: boolean;
}): Promise<{ status: string }>;
}

Consumer: AgentRunService.finalize() — called for task kind outcomes when outcome.taskFinishStatus is set.

Default binding: useFactory(TasksService) forwarding to tasks.transition(userId, taskId, to, { force }). The transition runs through TaskTransitionService's state-machine, so blocker/approver gates still apply (force=true overrides approver only, not blocker — security spec §6).

When to override: routing the status flip through an approval-required workflow; auditing every agent-driven transition in a separate event stream.

AGENT_GIT_FACADE

Contract

interface AgentGitFacade {
commitToRepo(input: {
userId: string;
agentId: string;
workId: string;
message: string;
files?: { path: string; body: string }[];
branch?: string;
}): Promise<{ sha: string | null; branch: string; filesChanged: number }>;

openPullRequest(input: {
userId: string;
agentId: string;
workId: string;
title: string;
body: string;
head: string;
base?: string;
draft?: boolean;
}): Promise<{ number: number; url: string; state: 'open' | 'closed' | 'merged' | 'draft' }>;
}

Consumer: AgentToolService.resolveAllowedTools() — when bound, the commitToRepo and openPullRequest tool descriptors appear in the Agent's per-run tool list (gated additionally on the matching permissions.canCommitToRepo / canOpenPullRequests flags).

Default binding (FU-13 follow-up — post-PR-1019):

{
provide: AGENT_GIT_FACADE,
inject: [GitFacadeService, AgentRepository],
useFactory: (git, agents): AgentGitFacade => ({
async commitToRepo({ userId, agentId, workId, message, files, branch }) {
const agent = await agents.findById(agentId);
const dir = await git.getRepoDir('work', workId, { userId, workId });
// … writeFile per `files[]`, then commit + push …
const committerName = agent.committerName ?? agent.name;
const committerEmail = agent.committerEmail ?? `${agent.slug}@agents.ever.works`;
const sha = await git.commit('github', dir, message, {
name: committerName,
email: committerEmail,
});
await git.push({ dir }, { providerId: 'github', userId, workId });
return { sha, branch: branch ?? 'main', filesChanged: files?.length ?? 0 };
},
async openPullRequest({}) {},
}),
}

Committer identity falls back to (Agent.committerName ?? Agent.name) and (Agent.committerEmail ?? <slug>@agents.ever.works) when the operator hasn't set either column. The synthesized email domain is a deliberate non-deliverable placeholder until the Email Providers surface ships — at that point operators can assign a real inbox to each Agent.

Auth is delegated to GitFacadeService.resolvePluginAndToken, which already handles the User's stored OAuth token + plugin-integration account chain.

When to override: bind once the operator's git provider setup is stable. Suggested adapter shape:

{
provide: AGENT_GIT_FACADE,
inject: [GitFacadeService, WorkRepository],
useFactory: (git: GitFacadeService, works: WorkRepository): AgentGitFacade => ({
async commitToRepo({ userId, agentId, workId, message, files, branch }) {
const work = await works.findByIdAndUser(workId, userId);
if (!work) throw new Error('Work not reachable.');
// Resolve provider + repo dir + auth from work settings,
// stage `files`, then forward into git.commit() / .push().
// See packages/agent/src/facades/git.facade.ts for the
// underlying methods.
//
// ... operator-specific resolution logic ...
return { sha, branch: resolvedBranch, filesChanged };
},
async openPullRequest({ ... }) { ... },
}),
}

AGENT_PLUGIN_TOOLS_FACADE

Contract

interface AgentPluginToolsFacade {
searchWeb(input: AgentSearchWebInput): Promise<AgentSearchWebResult>;
screenshot(input: AgentScreenshotInput): Promise<AgentScreenshotResult>;
extractContent(input: AgentExtractContentInput): Promise<AgentExtractContentResult>;
}

(See packages/agent/src/agents/agent-plugin-tools-facade.ts for the full payload shapes — each carries userId / agentId / workId? / taskId? for Phase 15.6 attribution.)

Consumer: AgentToolService — when bound, the searchWeb / screenshot / extractContent tool descriptors appear in the Agent's tool list (gated on permissions.canCallExternalTools AND token presence).

Default binding: useFactory(SearchFacadeService, ScreenshotFacadeService, ContentExtractorFacadeService). Each forwarded call threads agentId + taskId onto FacadeOptions so the resulting PluginUsageEvent rows carry full attribution (per-Agent + per-Task spend rollups work without additional plumbing).

extractContent clamps maxChars to a 50 KB default / 200 KB hard cap so a long-page extract can't bloat the model context window.

When to override: stubbing out external network calls in test environments; routing through a moderation/cache proxy.

Binding posture

All bindings live in the api-side module (apps/api/src/{agents,tasks}/...module.ts), not the agent-side. The agent-side module (packages/agent/src/agents/agents.module.ts) declares no defaults for these tokens — that's the entire point of the indirection. This means:

  • Unit tests for agent services can pass plain jest mocks for the token slot (or omit it entirely; @Optional() honors the absence).
  • The platform module is the single source of truth for what "production wired" means.
  • Operators can swap any token without touching the agent package.

@Global() is REQUIRED on api-side modules that bind these tokens

The api-side TasksModule and AgentsModule (the ones that bind the 6 tokens via useFactory) are both declared @Global(). This is load-bearing — do not remove the decorator.

The reason: the consumers (TaskTransitionService, TaskChatService, AgentRunService, AgentToolService) live in the imported agent-package modules (TasksDomainModule, agent-side AgentsModule). NestJS's module isolation rule is providers from a parent module are NOT visible to services in an imported child module's DI scope. Without @Global() the @Optional() @Inject(TOKEN) calls silently resolve to undefined in production — every unit test still passes (each binds tokens locally in its TestingModule), but the entire Phase-15 dispatch + post-processor + plugin-tools surfaces no-op at runtime with no error message.

When you write your own override binding, put it in a module that already imports (or IS) the global api-side module — OR mark your custom module @Global() so the token reaches consumers regardless of import direction.

Test posture

Each consumer ships with unit tests that verify the consumer's behavior with the token unbound (descriptors absent / side effects skipped) and with the token bound to a jest mock (happy invoke + payload shape). See:

  • packages/agent/src/agents/__tests__/agent-run-finalize.spec.ts — chat-back poster + task finisher
  • packages/agent/src/agents/__tests__/agent-tool-git.spec.ts — commitToRepo + openPullRequest descriptors
  • packages/agent/src/agents/__tests__/agent-tool-plugins.spec.ts — searchWeb / screenshot / extractContent descriptors

Migration from manually-wired services

If you wired one of these surfaces by hand pre-PR-1017 (e.g. an in-house CommitToRepoService that the Agent runtime called directly), the recommended migration is to keep your existing service and bind it via a thin adapter at the API layer:

{
provide: AGENT_GIT_FACADE,
inject: [MyExistingCommitToRepoService],
useFactory: (svc: MyExistingCommitToRepoService): AgentGitFacade => ({
commitToRepo: (input) => svc.legacyCommit(input),
openPullRequest: (input) => svc.legacyPr(input),
}),
}

This keeps the agent package free of any direct dependency on your service while letting the rest of the Agent tool surface activate end-to-end. No agent-side code changes required.