diff --git a/plugins/trace-codex/src/agents/codex/event-processor.test.ts b/plugins/trace-codex/src/agents/codex/event-processor.test.ts index 02c666d..1d58162 100644 --- a/plugins/trace-codex/src/agents/codex/event-processor.test.ts +++ b/plugins/trace-codex/src/agents/codex/event-processor.test.ts @@ -774,6 +774,49 @@ describe("CodexEventProcessor: tool spans", () => { }); describe("CodexEventProcessor: permissions", () => { + test("a SKILL.md read is normalized as a skill load tool span", async () => { + await assertProducesTrace( + [ + sessionStart(), + sessionMeta({ cwd: "/work" }), + taskStarted({ turn_id: "t1" }), + functionCall({ + turn_id: "t1", + name: "exec_command", + call_id: "c1", + arguments: JSON.stringify({ command: "cat /home/user/.agents/skills/review/SKILL.md" }), + }), + functionCallOutput({ call_id: "c1", output: "loaded" }), + taskComplete({ turn_id: "t1", last_agent_message: "done" }), + stop({ turn_id: "t1" }), + ], + { + span_attributes: { name: "codex: work", type: "task" }, + ended: true, + children: [ + { + span_attributes: { name: "turn: t1", type: "task" }, + ended: true, + children: [ + { span_attributes: { name: "llm", type: "llm" }, ended: true }, + { + span_attributes: { name: "skill: review", type: "tool" }, + metadata: { + tool_name: "skill", + original_tool_name: "exec_command", + call_id: "c1", + skill_name: "review", + skill_path: "/home/user/.agents/skills/review/SKILL.md", + }, + ended: true, + }, + ], + }, + ], + }, + ); + }); + test("an escalated tool call is annotated with permission metadata and a tag", async () => { await assertProducesTrace( [ diff --git a/plugins/trace-codex/src/agents/codex/event-processor.ts b/plugins/trace-codex/src/agents/codex/event-processor.ts index edd9f35..0d11a79 100644 --- a/plugins/trace-codex/src/agents/codex/event-processor.ts +++ b/plugins/trace-codex/src/agents/codex/event-processor.ts @@ -24,6 +24,8 @@ // tool calls, later) render in execution order. Buffered spans are delivered via // flush(). Everything is wrapped so tracing can never break a Codex turn. +import { basename, dirname } from "node:path"; + import { defaultSpanFactoryProvider, type ReportingConfig, @@ -410,6 +412,11 @@ interface PermissionInfo { prefix_rule?: unknown; } +interface SkillLoadInfo { + skillName?: string; + skillPath?: string; +} + /** * Parse a tool call's permission/escalation request from its arguments. Codex * records an escalated retry's request inline in the function_call arguments @@ -439,6 +446,81 @@ function permissionInfo(args: unknown): PermissionInfo | undefined { return info; } +function parseArgsObject(args: unknown): Record | undefined { + if (typeof args === "string") { + try { + const parsed = JSON.parse(args) as unknown; + return parsed !== null && typeof parsed === "object" + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } + } + return args !== null && typeof args === "object" ? (args as Record) : undefined; +} + +function stringCandidates(args: unknown): string[] { + const candidates: string[] = []; + if (typeof args === "string") candidates.push(args); + const obj = parseArgsObject(args); + if (obj !== undefined) { + for (const key of ["path", "file_path", "filePath", "file", "command", "cmd", "resource"]) { + const value = obj[key]; + if (typeof value === "string") candidates.push(value); + } + } + return candidates; +} + +function detectCodexSkillLoad( + toolName: string | undefined, + args: unknown, +): SkillLoadInfo | undefined { + if (toolName === "skills.read") { + const obj = parseArgsObject(args); + const skillName = + typeof obj?.name === "string" + ? obj.name + : typeof obj?.package === "string" + ? obj.package + : undefined; + return { + skillName, + }; + } + + for (const candidate of stringCandidates(args)) { + const skillPath = candidate.match(/(?:^|[\s"'])([^\s"']*SKILL\.md)(?:$|[\s"'])/i)?.[1]; + if (skillPath !== undefined) { + return { + skillName: basename(dirname(skillPath)), + skillPath, + }; + } + + const scriptPath = candidate.match( + /(?:^|[\s"'])([^\s"']*[\\/]scripts[\\/][^\s"']+)(?:$|[\s"'])/i, + )?.[1]; + if (scriptPath !== undefined) { + return { + skillName: basename(dirname(dirname(scriptPath))), + skillPath: scriptPath, + }; + } + } + + return undefined; +} + +function skillLoadMetadata(info: SkillLoadInfo | undefined): Record { + if (info === undefined) return {}; + return { + ...(info.skillName !== undefined ? { skill_name: info.skillName } : {}), + ...(info.skillPath !== undefined ? { skill_path: info.skillPath } : {}), + }; +} + // ConversationItems (chat messages / reasoning) are already plain JSON, so they // round-trip through a snapshot unchanged. These two helpers just bridge the // nominal type boundary between the live union and the snapshot's record array @@ -1649,21 +1731,31 @@ export class CodexEventProcessor implements EventProcessor { // If this call requested escalated permissions (Codex records the request // inline in the arguments), surface it on the span as metadata and a tag. const permission = permissionInfo(input); + const skillLoad = detectCodexSkillLoad(name, input); const startTime = isoToUnixSeconds(record.timestamp); const toolName = name ?? "tool"; + const spanName = + skillLoad?.skillName !== undefined ? `skill: ${skillLoad.skillName}` : toolName; try { const toolSpan = this.trackSpan( turnSpan.startSpan({ - name: toolName, + name: spanName, type: "tool", ...(startTime !== undefined ? { startTime } : {}), event: { input, - metadata: { tool_name: name, call_id: callId, turn_id: turnId, permission }, + metadata: { + tool_name: skillLoad !== undefined ? "skill" : name, + ...(skillLoad !== undefined ? { original_tool_name: name } : {}), + call_id: callId, + turn_id: turnId, + permission, + ...skillLoadMetadata(skillLoad), + }, ...(permission !== undefined ? { tags: [PERMISSION_TAG] } : {}), }, }), - toolName, + spanName, "tool", startTime, );