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
2 changes: 1 addition & 1 deletion .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.14.0"
".": "1.15.0"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.15.0](https://github.com/trycompai/crm/compare/v1.14.0...v1.15.0) (2026-08-20)


### Features

* **agent:** scope field backfill tasks to records missing values ([#163](https://github.com/trycompai/crm/issues/163)) ([3be7bbd](https://github.com/trycompai/crm/commit/3be7bbd69fe8b814ee2599a29b33e73f02a66245))

## [1.14.0](https://github.com/trycompai/crm/compare/v1.13.0...v1.14.0) (2026-08-18)


Expand Down
5 changes: 4 additions & 1 deletion apps/agent/agent/instructions/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export default defineDynamic({

if (budget) setBudget(budget);

const fieldKeys = attributeText.parse(attributes.fieldKeys);

const { markdown, focus } = await sessionPreamble(
{
contactId: attributeText.parse(attributes.contactId),
Expand All @@ -45,10 +47,11 @@ export default defineDynamic({
kind,
reason: attributeText.parse(attributes.reason),
budget,
fieldKeys: fieldKeys ? fieldKeys.split(",") : null,
},
);

focusOn({ ...focus, sessionId: ctx.session.id });
focusOn({ ...focus, sessionId: ctx.session.id, taskKind: kind });

return defineInstructions({
markdown: `${RESEARCH_INSTRUCTIONS}\n\n${markdown}`,
Expand Down
4 changes: 4 additions & 0 deletions apps/agent/agent/lib/blank-facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const CONTACT_SELECT = {
firstName: true,
lastName: true,
title: true,
seniority: true,
function: true,
linkedinUrl: true,
twitterUrl: true,
githubUrl: true,
Expand Down Expand Up @@ -137,6 +139,8 @@ type Proposal = {
firstName: string;
lastName: string | null;
title: string | null;
seniority: string | null;
function: string | null;
linkedinUrl: string | null;
twitterUrl: string | null;
githubUrl: string | null;
Expand Down
19 changes: 17 additions & 2 deletions apps/agent/agent/lib/dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EnrichmentStatus } from "@crm/db";
import { fieldBackfillPayload } from "@crm/validation/field-backfill";
import { APP_AUTH, type AppAuth } from "./app-auth";
import { brandOutcome, runBrand } from "./brand";
import { queueEventAgentRuns } from "./custom-agent-dispatch";
Expand Down Expand Up @@ -243,6 +244,11 @@ export function taskAuth(task: LeasedTask, base: AppAuth = APP_AUTH): AppAuth {
if (task.companyId) records.companyId = task.companyId;
if (task.dealId) records.dealId = task.dealId;

if (task.kind === "field-backfill") {
const parsed = fieldBackfillPayload.safeParse(task.payload);
if (parsed.success) records.fieldKeys = parsed.data.keys.join(",");
}

return {
...base,
attributes: {
Expand Down Expand Up @@ -388,10 +394,14 @@ export function brief(task: LeasedTask): string {
? `This is attempt ${task.attempts}; the earlier one did not finish. Carry on from what is already in this thread rather than starting again. `
: "";

return again + work(task.kind, task.reason);
return again + work(task.kind, task.reason, task.payload);
}

function work(kind: string, reason: string): string {
function work(
kind: string,
reason: string,
payload: LeasedTask["payload"],
): string {
switch (kind) {
case "identify":
return "Work out who this contact actually is, and record what you find. Read what we already have before spending anything.";
Expand All @@ -404,6 +414,11 @@ function work(kind: string, reason: string): string {
return "This company's brand, industry, location and links are filled in separately and may already be there. Read the account, fill anything still missing, and write a brief if there is something worth saying.";
case "workspace-profile":
return "Write the profile of the company you work for, so that every other session knows who we are. Read our own site and keep it short.";
case "field-backfill": {
const parsed = fieldBackfillPayload.safeParse(payload);
const keys = parsed.success ? parsed.data.keys.join(", ") : reason;
return `This record is missing a value for the custom field(s) ${keys}. Call list_fields for this record's type, read each field's brief, and call set_field_value only where you find real evidence — leave it blank rather than guess.`;
}
default:
return `Handle this: ${reason}`;
}
Expand Down
6 changes: 4 additions & 2 deletions apps/agent/agent/lib/facts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const FIELDS = {
twitterUrl: { column: "twitterUrl" },
githubUrl: { column: "githubUrl" },
employer: { column: null },
seniority: { column: null },
function: { column: null },
seniority: { column: "seniority" },
function: { column: "function" },
location: { column: null },
tenure: { column: null },
} as const;
Expand Down Expand Up @@ -96,6 +96,8 @@ export async function recordFact(
firstName: true,
lastName: true,
title: true,
seniority: true,
function: true,
linkedinUrl: true,
twitterUrl: true,
githubUrl: true,
Expand Down
34 changes: 30 additions & 4 deletions apps/agent/agent/lib/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
FieldValueError,
fieldKeyFromLabel,
type RecordField,
readValue,
recordColumn,
type SerializedField,
serializeField,
usesOptions,
writeValues,
} from "@crm/db/fields";
import { lockIdempotencyKey } from "@crm/db/idempotency";
import { currentFocus } from "./focus";

const WITH_OPTIONS = { options: { orderBy: { position: "asc" } } } as const;

Expand Down Expand Up @@ -75,9 +78,34 @@ export async function writeField(input: {
};
}

const isBackfill = currentFocus().taskKind === "field-backfill";

try {
await writeValues(db, input.entity, input.recordId, definitions, {
[input.key]: input.value,
return await db.$transaction(async (tx) => {
if (isBackfill) {
const column = recordColumn(input.entity);
await lockIdempotencyKey(
tx,
`field-value:${definition.id}:${input.recordId}`,
);

const row = await tx.fieldValue.findFirst({
where: { fieldId: definition.id, [column]: input.recordId },
});

if (readValue(definition, row ?? undefined) !== null) {
return {
written: false,
reason: `"${input.key}" already has a value on this record. Someone filled it since this task was queued — leave it as is.`,
};
}
}

await writeValues(tx, input.entity, input.recordId, definitions, {
[input.key]: input.value,
});

return { written: true, key: input.key, value: input.value };
});
} catch (error) {
if (error instanceof FieldValueError) {
Expand All @@ -86,8 +114,6 @@ export async function writeField(input: {

throw error;
}

return { written: true, key: input.key, value: input.value };
}

export async function createField(input: {
Expand Down
12 changes: 10 additions & 2 deletions apps/agent/agent/lib/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const focus = defineState("crm.focus", () => ({
contactId: null as string | null,
companyId: null as string | null,
sessionId: null as string | null,
taskKind: null as string | null,
spent: 0,
budget: 4,
exhausted: false,
Expand All @@ -13,27 +14,34 @@ export const focus = defineState("crm.focus", () => ({
export type CurrentFocus = {
contactId: string | null;
sessionId: string | null;
taskKind: string | null;
};

export function currentFocus(): CurrentFocus {
try {
const state = focus.get();
return { contactId: state.contactId, sessionId: state.sessionId };
return {
contactId: state.contactId,
sessionId: state.sessionId,
taskKind: state.taskKind,
};
} catch {
return { contactId: null, sessionId: null };
return { contactId: null, sessionId: null, taskKind: null };
}
}

export function focusOn(input: {
contactId?: string | null;
companyId?: string | null;
sessionId?: string | null;
taskKind?: string | null;
}): void {
focus.update((current) => ({
...current,
contactId: input.contactId ?? current.contactId,
companyId: input.companyId ?? current.companyId,
sessionId: input.sessionId ?? current.sessionId,
taskKind: input.taskKind ?? current.taskKind,
}));
}

Expand Down
26 changes: 24 additions & 2 deletions apps/agent/agent/lib/preamble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type Opened = {
kind?: string | null;
reason?: string | null;
budget?: number | null;
/** Set only for a `field-backfill` task — the custom field key(s) still blank on this record. */
fieldKeys?: string[] | null;
};

export type Preamble = {
Expand Down Expand Up @@ -42,6 +44,19 @@ async function closing(): Promise<string> {
return composeClosing(await identity());
}

function fieldBackfillLine(opened: Opened): string {
if (!opened.fieldKeys || opened.fieldKeys.length === 0) return "";

return [
`**This is a field-backfill task.** This record is missing a value for`,
`the custom field key(s) ${opened.fieldKeys.map((key) => `\`${key}\``).join(", ")}.`,
"Call `list_fields` for this record's entity type to read each field's",
"label, options and brief (what counts as an answer), then call",
"`set_field_value` with this record's id for any you find real evidence",
"for. Leave one blank rather than guess.",
].join(" ");
}

function opening(opened: Opened, questions: string): string {
if (opened.dispatched) {
return [
Expand Down Expand Up @@ -113,6 +128,7 @@ export async function contactPreamble(
opened.budget
? `Budget: **${opened.budget}** vendor calls. Spend them where they matter.`
: "",
fieldBackfillLine(opened),
"",
opening(
opened,
Expand Down Expand Up @@ -197,6 +213,7 @@ export async function companyPreamble(
`You are working on **${company.name}**${
company.domain ? ` (${company.domain})` : ""
}${company.industry ? `, ${company.industry}` : ""} — company id \`${companyId}\`.`,
fieldBackfillLine(opened),
"",
opening(
opened,
Expand Down Expand Up @@ -285,6 +302,7 @@ export async function dealPreamble(
? [`The rep's own description of it: "${deal.description}"`]
: []),
people ? `People on it: ${people}` : "Nobody is attached to it yet.",
fieldBackfillLine(opened),
"",
opening(
opened,
Expand All @@ -293,10 +311,14 @@ export async function dealPreamble(
"",
"Start with `read_deal_history` on this deal id. It returns the stage clock, every stage this deal has moved through, the last reply from their side and the next meeting — which is how you answer *where does this stand* rather than reciting the stage field back.",
"",
"You can research the people and the company behind it with the usual tools — a deal itself has no fields to enrich, so anything you learn is recorded against them.",
opened.fieldKeys && opened.fieldKeys.length > 0
? "You can research the people and the company behind it with the usual tools too — most of what you learn about them is recorded against them, not the deal."
: "You can research the people and the company behind it with the usual tools — a deal itself has no fields to enrich, so anything you learn is recorded against them.",
"",
await closing(),
].join("\n");
]
.filter(Boolean)
.join("\n");

return { markdown, focus: { companyId: deal.company?.id ?? null } };
}
Expand Down
33 changes: 33 additions & 0 deletions apps/agent/agent/lib/stale-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const LANDED_OUTCOME =
const RETIRED_ERROR =
"Research was attempted several times and never completed.";

const UNTARGETED_OUTCOME =
"No record was ever attached to this task, so it can never be worked. Retired.";

export type StaleTaskSweep = {
scanned: number;
closed: number;
Expand All @@ -27,6 +30,7 @@ type OpenTask = {
kind: string;
contactId: string | null;
companyId: string | null;
dealId: string | null;
attempts: number;
leasedUntil: Date | null;
startedAt: Date | null;
Expand Down Expand Up @@ -96,6 +100,7 @@ async function runSweep(sweep: StaleTaskSweep): Promise<void> {
kind: true,
contactId: true,
companyId: true,
dealId: true,
attempts: true,
leasedUntil: true,
startedAt: true,
Expand All @@ -109,9 +114,15 @@ async function runSweep(sweep: StaleTaskSweep): Promise<void> {
const completed = await completedSubjects(tasks);

const landed: string[] = [];
const untargeted: string[] = [];
const dead: string[] = [];

for (const task of tasks) {
if (isUntargetedFieldBackfill(task)) {
untargeted.push(task.id);
continue;
}

if (finishedElsewhere(task, completed)) {
landed.push(task.id);
continue;
Expand All @@ -136,6 +147,19 @@ async function runSweep(sweep: StaleTaskSweep): Promise<void> {
sweep.closed = count;
}

if (untargeted.length > 0) {
const { count } = await db.agentTask.updateMany({
where: {
id: { in: untargeted },
finishedAt: null,
OR: [{ leasedUntil: null }, { leasedUntil: { lt: now } }],
},
data: { finishedAt: now, outcome: UNTARGETED_OUTCOME },
});

sweep.closed += count;
}

sweep.retired = (await retireAbandoned()).length;

if (dead.length > 0) {
Expand Down Expand Up @@ -202,6 +226,15 @@ function finishedElsewhere(
return enrichedAt.getTime() >= task.startedAt.getTime();
}

function isUntargetedFieldBackfill(task: OpenTask): boolean {
return (
task.kind === "field-backfill" &&
!task.contactId &&
!task.companyId &&
!task.dealId
);
}

function unique(ids: readonly (string | null)[]): string[] {
return [...new Set(ids.filter((id): id is string => id !== null))];
}
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.7",
"@thallesp/nestjs-better-auth": "^2.7.0",
"@trpc/server": "^11.18.0",
"@vercel/blob": "^2.6.1",
Expand All @@ -46,6 +47,7 @@
"nestjs-trpc": "^2.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"trpc-to-openapi": "^3.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
Loading