feat(agent-runs): capture client + model identity on claim/complete
Adds level-B agent attribution: every MCP tool call can identify itself
with a `client` (e.g. cursor-ide, codex, echodo-orchestrator) and `model`
(e.g. claude-4.6-sonnet) string. Both optional, both stored in
agent_runs.metadata as JSONB so the schema doesn't move.
- claim_task: new optional `client` + `model` args. Written into the
inserted agent_runs row's metadata, and mirrored into the audit log
entry. On idempotent re-claim, incoming values are merged into
existing metadata (existing keys override only when explicit), so a
mid-session model switch updates attribution without losing earlier
context.
- complete_task: same optional args, with merge-on-close semantics —
late-bound values override the earlier claim's values so the run row
reflects whichever model actually closed the session. Audit row also
carries the merged identity.
- /settings/runs UI: stack the client/model under the actor name in the
table so attribution is visible at a glance without adding a column.
Smoke-tested: claim with {cursor-ide, claude-4.6-sonnet} then complete
with only model={claude-4.6-sonnet-medium-thinking} yields final
metadata {client: cursor-ide, model: claude-4.6-sonnet-medium-thinking}
on both the run row and the task.completed audit entry.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e99aa733c9
commit
fc2235a346
3 changed files with 126 additions and 9 deletions
|
|
@ -60,8 +60,39 @@ const claimTaskInputSchema = z.object({
|
||||||
.max(2_000)
|
.max(2_000)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Short opener text attached to the run (e.g. session intent)."),
|
.describe("Short opener text attached to the run (e.g. session intent)."),
|
||||||
|
client: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Caller identifier — e.g. 'cursor-ide', 'cursor-cloud', 'codex', 'echodo-orchestrator'. Persisted in agent_runs.metadata so the runs UI can slice by client.",
|
||||||
|
),
|
||||||
|
model: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Model handle driving the session — e.g. 'claude-4.6-sonnet', 'gpt-5-codex'. Free-form because new models ship faster than we can rev a zod enum.",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the metadata object stored on agent_runs at claim time. Returns
|
||||||
|
* `null` (not an empty object) when nothing identifying was passed so
|
||||||
|
* JSONB `metadata IS NULL` queries stay clean.
|
||||||
|
*/
|
||||||
|
function buildClaimMetadata(input: {
|
||||||
|
client?: string;
|
||||||
|
model?: string;
|
||||||
|
}): Record<string, unknown> | null {
|
||||||
|
const meta: Record<string, unknown> = {};
|
||||||
|
if (input.client) meta.client = input.client;
|
||||||
|
if (input.model) meta.model = input.model;
|
||||||
|
return Object.keys(meta).length === 0 ? null : meta;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerClaimTaskTool(mcp: McpServer): void {
|
export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
mcp.registerTool(
|
mcp.registerTool(
|
||||||
"claim_task",
|
"claim_task",
|
||||||
|
|
@ -112,6 +143,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
actorUserId: agentRuns.actorUserId,
|
actorUserId: agentRuns.actorUserId,
|
||||||
startedAt: agentRuns.startedAt,
|
startedAt: agentRuns.startedAt,
|
||||||
notes: agentRuns.notes,
|
notes: agentRuns.notes,
|
||||||
|
metadata: agentRuns.metadata,
|
||||||
})
|
})
|
||||||
.from(agentRuns)
|
.from(agentRuns)
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -131,10 +163,26 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
}
|
}
|
||||||
// Idempotent re-claim. Refresh `notes` if the caller provided
|
// Idempotent re-claim. Refresh `notes` if the caller provided
|
||||||
// new ones; leave timing untouched (started_at is sacred).
|
// new ones; leave timing untouched (started_at is sacred).
|
||||||
if (input.notes && input.notes !== existing.notes) {
|
// For client/model, merge into existing metadata so a fresh
|
||||||
|
// claim from a different model (e.g. user switched models in
|
||||||
|
// Cursor mid-session) updates the attribution without losing
|
||||||
|
// prior keys.
|
||||||
|
const incomingMeta = buildClaimMetadata(input);
|
||||||
|
const mergedMeta =
|
||||||
|
incomingMeta === null
|
||||||
|
? existing.metadata
|
||||||
|
: { ...(existing.metadata ?? {}), ...incomingMeta };
|
||||||
|
const notesChanged = Boolean(
|
||||||
|
input.notes && input.notes !== existing.notes,
|
||||||
|
);
|
||||||
|
const metaChanged = incomingMeta !== null;
|
||||||
|
if (notesChanged || metaChanged) {
|
||||||
await db
|
await db
|
||||||
.update(agentRuns)
|
.update(agentRuns)
|
||||||
.set({ notes: input.notes })
|
.set({
|
||||||
|
...(notesChanged ? { notes: input.notes! } : {}),
|
||||||
|
...(metaChanged ? { metadata: mergedMeta } : {}),
|
||||||
|
})
|
||||||
.where(eq(agentRuns.id, existing.id));
|
.where(eq(agentRuns.id, existing.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +211,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
const shouldTransition =
|
const shouldTransition =
|
||||||
priorStatus === "ready" || priorStatus === "draft";
|
priorStatus === "ready" || priorStatus === "draft";
|
||||||
|
|
||||||
|
const claimMeta = buildClaimMetadata(input);
|
||||||
const result = await db.transaction(async (tx) => {
|
const result = await db.transaction(async (tx) => {
|
||||||
const [created] = await tx
|
const [created] = await tx
|
||||||
.insert(agentRuns)
|
.insert(agentRuns)
|
||||||
|
|
@ -171,6 +220,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
backlogItemId: input.backlogItemId,
|
backlogItemId: input.backlogItemId,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
|
metadata: claimMeta,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -191,6 +241,7 @@ export function registerClaimTaskTool(mcp: McpServer): void {
|
||||||
backlog_item_id: input.backlogItemId,
|
backlog_item_id: input.backlogItemId,
|
||||||
prior_status: priorStatus,
|
prior_status: priorStatus,
|
||||||
transitioned: shouldTransition,
|
transitioned: shouldTransition,
|
||||||
|
...(claimMeta ?? {}),
|
||||||
...(actorUserId === null ? { system_actor: "mcp:claim_task" } : {}),
|
...(actorUserId === null ? { system_actor: "mcp:claim_task" } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,22 @@ const completeTaskInputSchema = z.object({
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
error: z.string().max(2000).optional(),
|
error: z.string().max(2000).optional(),
|
||||||
finalStatus: finalStatusSchema.optional(),
|
finalStatus: finalStatusSchema.optional(),
|
||||||
|
client: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Late-bound client identifier — only needed if you didn't supply one at claim time, or if it changed mid-session. Merged into existing run metadata; existing keys take precedence unless overridden.",
|
||||||
|
),
|
||||||
|
model: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
|
type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
|
||||||
|
|
@ -89,6 +105,19 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
||||||
const finalStatus = resolveFinalStatus(input);
|
const finalStatus = resolveFinalStatus(input);
|
||||||
const finishedAt = new Date();
|
const finishedAt = new Date();
|
||||||
|
|
||||||
|
// Build the merged metadata object once, outside the txn, so we
|
||||||
|
// can also stamp the same shape into the audit log without
|
||||||
|
// re-reading the row. Late-bound `client`/`model` from this call
|
||||||
|
// override whatever claim_task wrote; that's the right policy
|
||||||
|
// because the latest caller is the one we can verify.
|
||||||
|
const incomingMeta: Record<string, unknown> = {};
|
||||||
|
if (input.client) incomingMeta.client = input.client;
|
||||||
|
if (input.model) incomingMeta.model = input.model;
|
||||||
|
const hasIncomingMeta = Object.keys(incomingMeta).length > 0;
|
||||||
|
const mergedMeta = hasIncomingMeta
|
||||||
|
? { ...(run.metadata ?? {}), ...incomingMeta }
|
||||||
|
: run.metadata;
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
.update(agentRuns)
|
.update(agentRuns)
|
||||||
|
|
@ -100,6 +129,7 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
||||||
tokensTotal,
|
tokensTotal,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
error: input.error ?? null,
|
error: input.error ?? null,
|
||||||
|
...(hasIncomingMeta ? { metadata: mergedMeta } : {}),
|
||||||
})
|
})
|
||||||
.where(eq(agentRuns.id, input.runId));
|
.where(eq(agentRuns.id, input.runId));
|
||||||
|
|
||||||
|
|
@ -121,6 +151,7 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
|
||||||
finalStatus,
|
finalStatus,
|
||||||
backlogItemId: run.backlogItemId,
|
backlogItemId: run.backlogItemId,
|
||||||
tokensTotal,
|
tokensTotal,
|
||||||
|
...(mergedMeta ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,28 @@ function describeOutcome(
|
||||||
return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP };
|
return { label: outcome ?? "open", key: key as keyof typeof OUTCOME_CHIP };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `client` / `model` out of a run's metadata blob. The column is
|
||||||
|
* JSONB so we're treating it as unstructured here — short-circuit on
|
||||||
|
* null and ignore values that aren't strings (defensive against
|
||||||
|
* hand-inserted rows from psql).
|
||||||
|
*/
|
||||||
|
function describeAgent(metadata: unknown): {
|
||||||
|
client: string | null;
|
||||||
|
model: string | null;
|
||||||
|
label: string | null;
|
||||||
|
} {
|
||||||
|
if (!metadata || typeof metadata !== "object") {
|
||||||
|
return { client: null, model: null, label: null };
|
||||||
|
}
|
||||||
|
const m = metadata as Record<string, unknown>;
|
||||||
|
const client = typeof m.client === "string" ? m.client : null;
|
||||||
|
const model = typeof m.model === "string" ? m.model : null;
|
||||||
|
if (!client && !model) return { client, model, label: null };
|
||||||
|
if (client && model) return { client, model, label: `${client} · ${model}` };
|
||||||
|
return { client, model, label: client ?? model };
|
||||||
|
}
|
||||||
|
|
||||||
export default function RunsPage() {
|
export default function RunsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||||
|
|
@ -206,6 +228,7 @@ export default function RunsPage() {
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{runsQuery.data.rows.map((row) => {
|
{runsQuery.data.rows.map((row) => {
|
||||||
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
|
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
|
||||||
|
const agent = describeAgent(row.metadata);
|
||||||
const isOpen = expanded === row.id;
|
const isOpen = expanded === row.id;
|
||||||
const hasDetail = Boolean(row.notes || row.error);
|
const hasDetail = Boolean(row.notes || row.error);
|
||||||
return (
|
return (
|
||||||
|
|
@ -246,13 +269,25 @@ export default function RunsPage() {
|
||||||
{formatDuration(row.startedAt, row.finishedAt)}
|
{formatDuration(row.startedAt, row.finishedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs">
|
<td className="px-4 py-2 text-xs">
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
{row.actorUserId ? (
|
{row.actorUserId ? (
|
||||||
<span>
|
<span>
|
||||||
{row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)}
|
{row.actorName ??
|
||||||
|
row.actorEmail ??
|
||||||
|
row.actorUserId.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="italic text-muted-foreground">—</span>
|
<span className="italic text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
|
{agent.label ? (
|
||||||
|
<span
|
||||||
|
className="truncate text-[10px] text-muted-foreground"
|
||||||
|
title={agent.label}
|
||||||
|
>
|
||||||
|
{agent.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue