1import type {
2 ModelSelection,
3 OrchestrationV2ThreadShell,
4 ServerProvider,
5} from "@t3tools/contracts";
6
7import { BUNDLED_MODEL_MANIFEST } from "../provider/ModelManifest.ts";
8
9/**
10 * Drivers whose model catalogs are published by the vendor. Other drivers
11 * (OpenCode, Pi, ACP agents) list models from the user's own configuration,
12 * such as local Ollama tags, so their model names stay out of analytics.
13 */
14const VENDOR_CATALOG_DRIVERS: ReadonlySet<string> = new Set([
15 "codex",
16 "claudeAgent",
17 "cursor",
18 "grok",
19 "antigravity",
20]);
21
22export interface ProviderDimensions {
23 readonly provider: string;
24 readonly model?: string;
25}
26
27/**
28 * Anonymous provider and model for a model selection. Instance ids are
29 * user-defined, so only the driver kind is reported. The model is reported
30 * only when it comes from a vendor catalog and is not a user-added custom model.
31 */
32export function providerDimensions(
33 providers: ReadonlyArray<ServerProvider>,
34 selection: Pick<ModelSelection, "instanceId" | "model">,
35): ProviderDimensions {
36 const provider = providers.find((candidate) => candidate.instanceId === selection.instanceId);
37 if (provider === undefined) return { provider: "unknown" };
38 const model = provider.models.find((candidate) => candidate.slug === selection.model);
39 return VENDOR_CATALOG_DRIVERS.has(provider.driver) && model !== undefined && !model.isCustom
40 ? { provider: provider.driver, model: model.slug }
41 : { provider: provider.driver };
42}
43
44const DATED_SNAPSHOT = /-\d{8}$/u;
45
46// The bundled manifest is reviewed in this repo. Codex lists whatever its configured
47// model provider serves, so only the manifest vouches for Codex, Claude, and Antigravity.
48const MANIFEST_LABELS: ReadonlyMap<string, string> = new Map(
49 Object.values(BUNDLED_MODEL_MANIFEST.providers ?? {}).flatMap((catalog) =>
50 catalog.models.flatMap((model) =>
51 [model.slug, ...(model.aliases ?? [])].map((name) => [name, model.slug] as const),
52 ),
53 ),
54);
55const REGISTRY_LABEL_DRIVERS: ReadonlySet<string> = new Set(["cursor", "grok"]);
56
57/**
58 * The vendor catalog slug that a native or routed model id names, such as
59 * `anthropic/claude-opus-5-5` or a dated snapshot. Models outside the bundled
60 * manifest and the Cursor and Grok catalogs, including user-added ones, return undefined.
61 */
62export function catalogModelLabel(
63 providers: ReadonlyArray<ServerProvider>,
64 model: string,
65): string | undefined {
66 const leaf = model.slice(model.lastIndexOf("/") + 1);
67 const candidates = [model, leaf, leaf.replace(DATED_SNAPSHOT, "")];
68 for (const candidate of candidates) {
69 const label = MANIFEST_LABELS.get(candidate);
70 if (label !== undefined) return label;
71 }
72 for (const provider of providers) {
73 if (!REGISTRY_LABEL_DRIVERS.has(provider.driver)) continue;
74 const match = provider.models.find(
75 (entry) => !entry.isCustom && candidates.includes(entry.slug),
76 );
77 if (match !== undefined) return match.slug;
78 }
79 return undefined;
80}
81
82/** The thread fields agent analytics reads. Ids are used for lookups, never reported. */
83export type AgentThread = Pick<
84 OrchestrationV2ThreadShell,
85 "modelSelection" | "runtimeMode" | "interactionMode" | "createdBy" | "creationSource" | "lineage"
86>;
87
88type Fields = Readonly<Record<string, unknown>>;
89
90const asFields = (value: unknown): Fields =>
91 typeof value === "object" && value !== null ? (value as Fields) : {};
92
93const stringField = (fields: Fields, key: string) =>
94 typeof fields[key] === "string" ? (fields[key] as string) : undefined;
95
96/** A string from the tool input only when it is one of the schema's literals. */
97const oneOf = (value: string | undefined, allowed: ReadonlyArray<string>) =>
98 value !== undefined && allowed.includes(value) ? value : undefined;
99
100/**
101 * Whether two resolved providers differ. Unresolved selections compare as
102 * unknown rather than as a cross-provider handoff.
103 */
104export const crossProvider = (caller: string, target: string) =>
105 caller === "unknown" || target === "unknown" ? undefined : caller !== target;
106
107const defined = (entries: Readonly<Record<string, unknown>>) =>
108 Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined));
109
110/**
111 * How deep a thread sits in a delegation tree: 0 for a top-level thread, 1 for
112 * its subagent, and so on. Forks are top-level threads of their own. Depth is
113 * counted by walking parents, capped so a corrupt lineage cannot loop.
114 */
115export const MAX_REPORTED_DEPTH = 5;
116
117/**
118 * Who started the work that made this call. Scheduled runs keep the task
119 * creator's provenance, so the run's message marks them instead.
120 */
121function callerOrigin(input: {
122 readonly thread: Pick<AgentThread, "createdBy">;
123 readonly scheduledRun: boolean;
124}) {
125 if (input.scheduledRun) return "scheduler";
126 if (input.thread.createdBy === "agent") return "agent";
127 return input.thread.createdBy === "system" ? "system" : "user";
128}
129
130/**
131 * Settings the agent chose for a handoff, read only from closed enums and
132 * booleans in the tool's arguments and result. Prompts, titles, and ids are
133 * never read.
134 */
135export function handoffSettings(tool: string, args: unknown, result: unknown): Fields {
136 const input = asFields(args);
137 const output = asFields(result);
138 switch (tool) {
139 case "delegate_task":
140 return defined({
141 targetChosen: input.target !== undefined,
142 // Rejected calls are recorded too, so input strings must match a literal.
143 mode: oneOf(stringField(input, "mode"), ["async", "wait"]) ?? "async",
144 ...(output.waitTimedOut === true ? { waitTimedOut: true } : {}),
145 });
146 case "create_threads": {
147 const threads = Array.isArray(input.threads) ? input.threads : [];
148 return {
149 batchSize: threads.length,
150 targetChosen: threads.some((thread) => asFields(thread).target !== undefined),
151 };
152 }
153 case "t3_thread_launch":
154 return defined({
155 targetChosen: input.modelSelection !== undefined,
156 workspace:
157 input.scratch === true
158 ? "scratch"
159 : (oneOf(stringField(asFields(input.workspaceStrategy), "type"), [
160 "root",
161 "worktree",
162 "existing_worktree",
163 ]) ?? "root"),
164 });
165 case "t3_thread_send":
166 return defined({ delivery: stringField(output, "delivery") });
167 case "t3_thread_send_attachments":
168 return { delivery: "auto" };
169 case "schedule_task":
170 return { bindToCurrentThread: input.bindToCurrentThread !== false };
171 default:
172 return {};
173 }
174}
175
176/** Outcome of a tool call, with the stable failure code when the call failed. */
177export function toolOutcome(
178 result:
179 | { readonly isError?: boolean | undefined; readonly structuredContent?: unknown }
180 | undefined,
181) {
182 if (result === undefined) return { outcome: "error", errorCode: "exception" };
183 const content = asFields(result.structuredContent);
184 // T3 tool failures are declared errors whose structured content carries a
185 // closed `code`; the MCP layer does not always set isError for them.
186 const code = stringField(content, "code") ?? stringField(asFields(content.error), "_tag");
187 if (result.isError === true || (content._tag !== undefined && code !== undefined)) {
188 return { outcome: "error", errorCode: code ?? "unknown" };
189 }
190 return { outcome: "ok" };
191}
192
193/**
194 * Event properties for an agent tool call: the calling agent, and when the call
195 * handed work to other threads, the agents running them. Each target counts
196 * once, so a create_threads batch on two providers reports both.
197 */
198export function agentToolProperties(input: {
199 readonly tool: string;
200 readonly providers: ReadonlyArray<ServerProvider>;
201 readonly caller: AgentThread | undefined;
202 readonly callerProviderInstanceId?: ModelSelection["instanceId"];
203 readonly callerDepth?: number;
204 readonly callerScheduledRun?: boolean;
205 readonly targets: ReadonlyArray<AgentThread>;
206 readonly outcome: Fields;
207 readonly settings?: Fields;
208 readonly durationMs?: number;
209}): ReadonlyArray<Readonly<Record<string, unknown>>> {
210 const caller =
211 input.caller !== undefined
212 ? providerDimensions(input.providers, input.caller.modelSelection)
213 : input.callerProviderInstanceId !== undefined
214 ? {
215 provider: providerDimensions(input.providers, {
216 instanceId: input.callerProviderInstanceId,
217 model: "",
218 }).provider,
219 }
220 : { provider: "unknown" };
221 const base = defined({
222 tool: input.tool,
223 callerProvider: caller.provider,
224 callerModel: caller.model,
225 callerOrigin:
226 input.caller === undefined
227 ? undefined
228 : callerOrigin({
229 thread: input.caller,
230 scheduledRun: input.callerScheduledRun === true,
231 }),
232 callerDepth:
233 input.callerDepth === undefined ? undefined : Math.min(input.callerDepth, MAX_REPORTED_DEPTH),
234 durationMs: input.durationMs,
235 ...input.outcome,
236 ...input.settings,
237 });
238 if (input.targets.length === 0) return [base];
239 return input.targets.map((thread) => {
240 const target = providerDimensions(input.providers, thread.modelSelection);
241 return defined({
242 ...base,
243 targetProvider: target.provider,
244 targetModel: target.model,
245 targetRuntimeMode: thread.runtimeMode,
246 targetInteractionMode: thread.interactionMode,
247 crossProvider: crossProvider(caller.provider, target.provider),
248 });
249 });
250}