Skip to content

fix(agents): EPMCDME-15968- forward forked skill's name on SubagentStart/Stop - #642

Merged
Bondimm merged 6 commits into
codemie-ai:mainfrom
Oleh-Prusak-epam:EPMCDME-15968_subagent-skill-name
Oct 23, 2026
Merged

Bondimm merged 6 commits into
codemie-ai:mainfrom
Oleh-Prusak-epam:EPMCDME-15968_subagent-skill-name

Conversation

@Oleh-Prusak-epam

@Oleh-Prusak-epam Oleh-Prusak-epam commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Forward a forked skill's name from its .forked-skill.marker.json sidecar onto the SubagentStart/SubagentStop hook payload as skill_name, scope its usage as scope_kind: 'skill' / scope_name: <skillName> (instead of 'agent'/<agentType>), and keep the main session's api_calls correctly excluding that subagent usage — so CodeMie CLI Analytics classifies a skill invoked via slash command and forked as a background subagent (e.g. /code-review) as a skill instead of a generic agent dispatch.

Motivation

EPMCDME-15968: skills run as slash commands (forked skills) are not counted — skill_count is 0 and the skill subagent is not reflected in CLI Analytics.

Changes

  • transcript/subagent-usage.ts: SubagentFile.skillName is read from the subagent's .forked-skill.marker.json sidecar (readForkedSkillMarker, only when forkedSkill === true) — not from .meta.json's name, which is the Agent tool's generic addressing name and is also set for any named agent, forked skill or not.
  • claude-code-otlp.plugin.ts: onSubagentStartEvent forwards skill_name on SubagentStart (building the subagent's transcript path from the triggering hook's transcript_path + agent_id, since SubagentStart's real payload has no agent_transcript_path); onSubagentStopEvent does the same on SubagentStop. Both now guard on agent_type so an internal Claude Code check-in SubagentStart is never forwarded.
  • transcript/orchestrator.ts: deriveSubagentEvents scopes a forked skill's subagent usage as scope_kind: 'skill' / scope_name: <skillName> (falling back to agentType consistently when skillName is empty), matching deriveMainEvents' existing pattern for an inline skill. deriveMainEvents' api_calls filter now also requires an empty agentId, so a forked skill's subagent requests (which carry their own agentId despite scope_kind: 'skill') are excluded from the main session's api_calls, same as a regular agent's always were.
  • Hardening found by a follow-up multi-lens code review of this branch (blind + edge-case + acceptance lenses):
    • claude-code-otlp.plugin.ts: onSubagentStopEvent's agentId fallback (hookInput.agent_id || basename(agentTranscriptPath)-stripped) could resolve to '' for a degenerate transcript path, which would satisfy the new !r.agentId guard above and reopen the exact api_calls double-count it was added to close. Now falls back further to the (already confirmed non-empty) agentTranscriptPath itself.
    • transcript/subagent-usage.ts: the new subagentFilePath(mainTranscriptPath, agentId) joined agentId straight into a file path with no validation. Now confines the result with isPathWithinDirectory (the project's documented path-security pattern, .ai-run/guides/security/security-practices.md), falling back to a safe, guaranteed-absent in-bounds path rather than ever resolving outside the subagents directory — never throwing, matching this module's existing "never throws" contract.

Testing

  • subagent-usage.test.ts, claude-code-otlp.plugin.test.ts, orchestrator.test.ts: regression tests for the forked-skill-marker fix, the SubagentStart path fix, the scope_kind/scope_name attribution, the agent_type check-in guard, the api_calls exclusion, and the two hardening fixes above (each confirmed to fail against the pre-fix code before passing after).
  • Live, end-to-end, against the real compiled build: skill_name/scope_kind/scope_name confirmed correct against genuine past /code-review dispatches found on disk on this machine; agent.session.summary.api_calls confirmed no longer inflated (pre-fix: 132 = 90 main + 42 subagent; post-fix: 90); subagentFilePath fed actual ../../../etc/passwd-style payloads and confirmed to stay in-bounds; the agentId fallback fed a genuinely degenerate transcript path with no agent_id and confirmed api_calls still held correct end-to-end through openSubagentPass → openMainTranscriptPass.
  • npx tsc --noEmit -p .: clean. eslint --max-warnings=0: clean. claude-code-otlp suite: 215/215.

Not covered by this PR

Related

Backend companion fix (classifies this new signal, un-gates existing skill counting): https://gitbud.epam.com/epm-cdme/codemie/-/merge_requests/4550

Relates to EPMCDME-15968

Oleh-Prusak-epam and others added 2 commits October 15, 2026 16:33
EPMCDME-15968: a forked skill's .meta.json sidecar already carries the
skill's name under `name` (confirmed from a real local session), but
SubagentMeta silently dropped it. Parse it so a follow-up commit can
forward it on the SubagentStart/Stop hook payload.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
EPMCDME-15968: the subagent's .meta.json sidecar was already read at the
right point in SubagentStop's lifecycle, but its skill name was only
threaded into the sibling agent.subagent.usage event, never into the
SubagentStop hook payload itself, so the backend never saw it. Thread it
in, and add the same sidecar read to SubagentStart, which previously had
no handling at all.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
@Oleh-Prusak-epam
Oleh-Prusak-epam marked this pull request as ready for review October 16, 2026 00:23
@Oleh-Prusak-epam Oleh-Prusak-epam changed the title fix(agents): forward forked skill's name on SubagentStart/Stop fix(agents): EPMCDME-15968 - forward forked skill's name on SubagentStart/Stop Oct 16, 2026
@Oleh-Prusak-epam Oleh-Prusak-epam changed the title fix(agents): EPMCDME-15968 - forward forked skill's name on SubagentStart/Stop fix(agents): EPMCDME-15375- forward forked skill's name on SubagentStart/Stop Oct 19, 2026
@Oleh-Prusak-epam Oleh-Prusak-epam changed the title fix(agents): EPMCDME-15375- forward forked skill's name on SubagentStart/Stop fix(agents): EPMCDME-15968- forward forked skill's name on SubagentStart/Stop Oct 19, 2026

@Bondimm Bondimm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • CI hasn't run on this PR. Both CI runs, for d0deaa24 and for the earlier push, are waiting for maintainer approval (action_required), so there are no Build / Test (Ubuntu) / Test (Windows) results yet. The branch is 3 commits behind main and merges cleanly.
  • Checked locally on d0deaa24: npm run typecheck, eslint on the 4 changed files, and commitlint on the commits and the title pass. vitest --project unit on claude-code-otlp, agents/core, the connector test and otlp-forward passes (40 files, 582 tests). On a local merge with main, npm run typecheck and the claude-code-otlp and agents/core tests also pass (38 files, 535 tests).
  • Backend contract: the top-level skill_name field lands in the typed hook_events.skill_name column (HOOK_TEXT_FIELDS in vocabulary.py), so the name matches. The backend side of this ticket is codemie !4550, which was merged into the branch of !4468, and !4468 is still open against main. It reads the skill only from agent.subagent.start (see the thread on onSubagentStartEvent). The field is additive, so either deploy order is safe.
  • Checked against the ticket's Expected result (the ticket has no AC list). skill_count=1 and skills_invoked for /code-review are not reached end to end (thread on onSubagentStartEvent). scope_kind=skill and the inline slash-command skills are not covered (PR comment). "The dispatch appears in AGENTS/timeline" can't be checked here. !4468 moves a skill-tagged dispatch out of AGENTS ("never also an agent"). Should the forked skill show under SKILLS only, or under both SKILLS and AGENTS as the ticket's wording suggests?
  • Feature flag: none. Revert: code only and clean. Events already stored keep their skill_name.
  • #634 conflicts with this PR in claude-code-otlp.plugin.ts (evaluate), so whichever merges second needs a rebase. #634 also sends agent.skill.dispatch with skill_name for every slash command. If that hook also fires for a forked skill's /code-review, the backend would count it twice once the start event carries skill_name, because the skill branch of tool_facts_daily counts both events. Worth checking against !4468. #641, #646, #647 and #651 merge cleanly with this PR.

What needs rechecking after the next push:

  • CI on the new head, once approved;
  • a SubagentStart payload without agent_transcript_path gets skill_name for a forked skill;
  • a named regular agent is not tagged as a skill.

@Bondimm

Bondimm commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Two expected results of EPMCDME-15968 are not covered

Problem: The ticket has no AC list. Its Expected result reads: "Skills invoked through slash commands count as skills (skill_count=1 for /code-review), appear in skills_invoked, and the forked skill is attributed to scope_kind=skill / scope_name=code-review; the dispatch appears in AGENTS/timeline."

  • scope_kind=skill / scope_name=code-review: the forked skill's token usage is still scoped as an agent. deriveSubagentEvents in transcript/orchestrator.ts parses every subagent line with scope agent and the agent type as the name. The agent.usage.request and agent.subagent.usage events for /code-review therefore keep scope_kind: "agent" and scope_name: "general-purpose". Neither this PR nor !4468 changes that.
  • The inline slash-command skills from the ticket's second session (/skill-doctor, /anthropic-skills:pdf) get no skill signal from this PR. The CLI on main doesn't register UserPromptExpansion, and nothing else sets skill_name for them. feat(analytics): Enrich existing hook events and add new synthetic ones #634 adds UserPromptExpansion → agent.skill.dispatch with skill_name, which !4468 reads, but feat(analytics): Enrich existing hook events and add new synthetic ones #634 belongs to another ticket (EPMCDME-15301) and is still open.

Impact: With this PR and !4468 merged, cost and token views still attribute a forked skill's tokens to general-purpose. Inline slash-command skills still give skill_count=0 unless #634 merges too.

Recommendation: For the scope, consider passing a skill scope in deriveSubagentEvents when the sidecar marks a forked skill. The main transcript already does this (scopeKind = state.activeSkill ? 'skill' : 'main' in deriveMainEvents). Keep the agentId on these records and check that the backend's per-agent cost still joins. For the inline case, could the PR description name #634 as a dependency of this ticket? If the PO prefers to move either part to another ticket, a note in EPMCDME-15968 would settle it.

…EPMCDME-15968)

Two real bugs confirmed by running the real evaluate() against this machine's actual subagent
sidecar files:

- onSubagentStartEvent gated on agent_transcript_path, a field SubagentStart's real payload
  never has (confirmed against the SDK's own SubagentStartHookInput type and real marker-file
  mtimes), so skill_name never actually reached agent.subagent.start. Now builds the subagent's
  own transcript path from the triggering hook's transcript_path + agent_id instead.
- Both handlers read .meta.json's name field as the skill signal, but that is the Agent tool's
  generic addressing name (set for any named agent, forked skill or not) — confirmed with a real
  agent named 'otel-deep-analysis' on this machine that would have been misclassified. Now reads
  the real <agent>.forked-skill.marker.json sidecar instead.

Also scopes a forked skill's usage as scope_kind:'skill'/scope_name:<skillName> in
deriveSubagentEvents, instead of 'agent'/<agentType>, per the ticket's Expected result.

Verified: tsc --noEmit clean, eslint clean, 210/210 vitest pass (new regression tests for both
bugs using the real file shapes found on disk). Confirmed live end-to-end against the local
analytics backend after linking this build into a real Claude Code session: a real /code-review
forked-skill dispatch's agent.subagent.start hook event now carries skill_name='code-review',
and its usage_requests rows carry scope_kind='skill'/scope_name='code-review'.

Not addressed here (separate ticket, EPMCDME-15301/codemie-ai#634): inline slash-command skills
(/skill-doctor, /anthropic-skills:pdf) still need a UserPromptExpansion -> agent.skill.dispatch
mapping that does not exist on main yet.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
@Oleh-Prusak-epam

Oleh-Prusak-epam commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Fixed the scope_kind/scope_name part in bfb6c73: deriveSubagentEvents in orchestrator.ts now scopes a forked skill's usage as scope_kind:'skill'/scope_name:<skillName> (mirroring deriveMainEvents's existing activeSkill pattern for the main transcript), instead of always 'agent'/<agentType>. agentId is kept either way. Added a test asserting this on both agent.usage.request and agent.subagent.usage events; confirmed live too — a real /code-review dispatch's usage_requests rows in the backend now show scope_kind='skill', scope_name='code-review'.

Also fixed the two line-level bugs youfound, which were the actual reason the ticket's main case stayed unfixed: SubagentStart never got skill_name at all (gated on a field it doesn't have), and the signal being read (.meta.json's name) wasn't a skill marker in the first place. Both confirmed and fixed against real sidecar files on disk, and live end-to-end through an actual /code-review run.

Not touched here: the inline slash-command case (/skill-doctor, /anthropic-skills:pdf) still depends on #634 (EPMCDME-15301) landing UserPromptExpansion → agent.skill.dispatch. Agree that should be named as an explicit dependency in EPMCDME-15968's description — flagged the same on the backend MR's matching discussion.

@Oleh-Prusak-epam

Copy link
Copy Markdown
Contributor Author

Test results after the latest push (bfb6c73)

  • npx tsc --noEmit -p .: clean.
  • npx eslint --max-warnings=0 on the 6 changed files: clean.
  • npx vitest run --project unit src/agents/plugins/claude-code-otlp: 210/210 passed (11 test files) — includes the new/updated tests for both line-level bugs and the scope_kind attribution fix.
  • Broader scope matching your own verification method (claude-code-otlp + agents/core): 534/535 passed. The 1 failure (AgentCLI-model-source.test.ts, expects env.CODEMIE_MODEL_SOURCE to be 'default', gets 'env') is pre-existing and unrelated — confirmed via git stash that it fails identically on the unmodified branch (a CODEMIE_MODEL_SOURCE env var leaking from this machine's shell into the test process), not something this change introduced.
  • Commit hooks (husky/lint-staged) ran the same eslint/typecheck/vitest/gitleaks chain automatically on commit bfb6c73f and all passed before the commit was allowed through.

Live end-to-end verification (beyond unit tests) is in my earlier replies on this PR: a real /code-review forked-skill dispatch through this build, traced into the actual backend Postgres.

CI itself (GitHub Actions) is still `action_required" — pending maintainer approval on run https://github.com/codemie-ai/codemie-code/actions/runs/39271966994, same as the earlier runs on this PR.

…MCDME-15968)

Two bugs found by review and confirmed by source inspection:

- deriveSubagentEvents mixed a truthy check (scope_kind) with a nullish
  check (scope_name) on SubagentFile.skillName, so an empty-string
  skillName would produce scope_kind='agent' with scope_name='' instead
  of falling back to agentType. Both now use the same truthy check.
- onSubagentStartEvent had no agent_type guard, unlike onSubagentStopEvent,
  which filters out Claude Code's internal non-subagent check-ins. Added
  the same guard so a check-in SubagentStart is never forwarded or read.

Verified live against a real forked-skill subagent from this session
(/code-review dispatch): skill_name and scope_kind/scope_name still
correct after the fix, and a synthetic no-agent_type SubagentStart
produces zero spool entries, confirming the new guard works end-to-end
through the linked CLI, not just in unit tests.

212/212 vitest passing (2 new regression tests), tsc --noEmit clean,
eslint clean.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
@Bondimm

Bondimm commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

A forked skill is counted twice once #634 is merged too

Problem: Claude Code runs the UserPromptExpansion hook for a forked skill's slash command before it starts the forked subagent. In the 2.1.x binary, the slash-command prompt path calls the expansion hook (command_name = the skill name) and only then dispatches the context: fork run. #634 forwards every UserPromptExpansion as agent.skill.dispatch with skill_name: command_name, and this PR adds skill_name to the same skill's agent.subagent.start. One /code-review then produces two skill events with skill_name: "code-review". In !4468 (462f0deaf), tool_facts_daily (skill_count), invocations_hourly (kind 2) and the dispatch timeline in reader.py count both, and nothing in #634, this PR or !4468 removes either one. UserPromptExpansionHookInput has no field that marks a fork, so #634 can't tell the two cases apart from the payload alone.

Impact: With #634, this PR and !4468 merged (all three are needed for the ticket: #634 for the inline slash-command skills), /code-review gives skill_count=2, two calls in Skills invoked and two timeline rows. The ticket expects skill_count=1.

Recommendation: The backend seems the simplest place to fix this. !4468 already has the same kind of guard for spans: _NO_SKILL_SPAN_OF_NAME in rollups.py drops a hook skill event when a TOOL span already names the same skill. Something similar could skip an agent.skill.dispatch when a skill-tagged agent.subagent.start with the same skill_name follows it in the session shortly afterwards (or skip the start instead). A test in #634 or !4468 for the combined sequence would cover it. Could you also name #634 as a dependency in the PR description or in EPMCDME-15968, as you suggested?

…PMCDME-15968)

deriveSubagentEvents scopes a forked skill's subagent usage as scopeKind:'skill',
same as a main-thread inline skill, but deriveMainEvents' api_calls filter only
checked scopeKind, not agentId, so a forked skill's subagent requests (which carry
their own agentId) were double-counted into the main session's api_calls once
persisted by their own SubagentStop.

Added the missing agentId guard: main-thread records always carry an empty
agentId, so this only changes behavior for the forked-skill case.

Verified live against two real past /code-review forked-skill sessions found on
this machine (28 and 14 subagent requests): pre-fix api_calls was inflated by
exactly that amount (132 instead of 90); post-fix it matches the main-thread
count (90). Also added a unit regression test reproducing the same sequence
(SubagentStop persists first, then a later Stop's summary must not inflate),
confirmed to fail against the pre-fix filter and pass after.

213/213 claude-code-otlp tests passing, 538/538 across claude-code-otlp +
agents/core, tsc --noEmit clean, eslint clean.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
@Oleh-Prusak-epam

Copy link
Copy Markdown
Contributor Author

Pushed fbd9e58 fixing the remaining open thread: api_calls was double-counting a forked skill's subagent requests once persisted by its own SubagentStop — added an agentId guard, reproduced the bug live against real /code-review sessions on disk (132 vs the correct 90) and with a unit regression test, then verified both clean after the fix. Full details in my reply on that thread. Also updated the PR description to reflect the current full scope and explicitly named #634 as the dependency for both the inline slash-command case and the future double-count risk you flagged — thanks for raising that twice, agreed it belongs on record here rather than just in the ticket.

…lePath (EPMCDME-15968)

Found by a multi-lens code review (blind + edge-case + acceptance) of this branch, each
independently verified against real data/behavior before landing:

- claude-code-otlp.plugin.ts: onSubagentStopEvent's agentId fallback
  (hookInput.agent_id || basename(agentTranscriptPath)-stripped) could resolve to '' when both
  are empty (a degenerate transcript path). An empty agentId satisfies deriveMainEvents' recent
  `!r.agentId` guard, so a forked skill with this fallback would silently reopen the exact
  api_calls double-count that guard exists to close. Now falls back further to the (already
  confirmed non-empty) agentTranscriptPath itself.

- transcript/subagent-usage.ts: the new subagentFilePath(mainTranscriptPath, agentId) joined
  agentId straight into a file path with no validation, so a crafted agent_id could resolve
  outside the subagents directory and have its arbitrary JSON content trusted as a skill marker.
  Now confines the result with isPathWithinDirectory (the project's documented path-security
  pattern), falling back to a safe, guaranteed-absent in-bounds path — never throwing, matching
  this module's existing "never throws" contract for every other sidecar reader.

Verified both unit and live:
- Unit: one regression test per fix, each confirmed to fail against the pre-fix code (stashed
  just the two source changes, kept the new tests) before passing after. Full suite 215/215,
  tsc --noEmit clean, eslint --max-warnings=0 clean.
- Live, against the real compiled build: fed subagentFilePath actual "../../../etc/passwd"-style
  payloads — all resolved in-bounds. Fed the real onSubagentStopEvent fallback formula a genuinely
  degenerate "agent-.jsonl" transcript path with no agent_id, ran it through the real compiled
  openSubagentPass -> openMainTranscriptPass, and confirmed api_calls stayed correct (main-thread
  count only) instead of being inflated.

Both fixes are isolated to code this PR's own commits introduced/rely on; neither touches or
conflicts with the earlier api_calls filter fix.

Generated with AI

Co-Authored-By: codemie-ai <codemie.ai@gmail.com>
@Bondimm
Bondimm merged commit 6b47a8c into codemie-ai:main Oct 23, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants