-
-
Notifications
You must be signed in to change notification settings - Fork 7k
feat: skip/filter subagent observations (Dynamic Workflows workflow-subagent) — #2736
#2741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| // Shared, pure decision helper for skipping subagent observations (issue #2736). | ||
| // | ||
| // Two filtering points call this with the SAME logic so behavior can't drift: | ||
| // 1. The PostToolUse hook handler (src/cli/handlers/observation.ts) — runs in | ||
| // the short-lived hook process BEFORE any worker HTTP call or provider | ||
| // request, so a skipped observation costs nothing. This covers both the | ||
| // `worker` and `server-beta` runtimes because it sits ahead of the runtime | ||
| // branch. | ||
| // 2. The worker ingest path (src/services/worker/http/shared.ts) — defense in | ||
| // depth for any caller that reaches the worker without going through the | ||
| // hook handler (direct API callers, future ingestion paths). | ||
| // | ||
| // Motivation: Claude Code Dynamic Workflows fan a single prompt out to | ||
| // tens-to-hundreds of parallel subagents (agent_type `workflow-subagent`), each | ||
| // tool call firing a PostToolUse hook → one provider-analyzed observation. A | ||
| // single run can emit hundreds-to-thousands of low-signal observations, which | ||
| // exhausts the configured provider's quota (HTTP 429) and trips the restart | ||
| // guard, dropping the rest of the run — including valuable main-session work. | ||
|
|
||
| export type AgentSkipReason = 'subagent_observation' | 'agent_type_excluded'; | ||
|
|
||
| export interface AgentSkipSettings { | ||
| /** When 'true', skip every observation carrying an agentId (any subagent). */ | ||
| CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: string; | ||
| /** Comma-separated agent_type values to skip (e.g. "workflow-subagent,Explore"). */ | ||
| CLAUDE_MEM_SKIP_AGENT_TYPES: string; | ||
| } | ||
|
|
||
| export interface AgentSkipDecision { | ||
| skip: boolean; | ||
| reason?: AgentSkipReason; | ||
| } | ||
|
|
||
| const NO_SKIP: AgentSkipDecision = { skip: false }; | ||
|
|
||
| /** Parse a comma-separated agent_type skip list into a trimmed, de-duped set. */ | ||
| export function parseSkipAgentTypes(raw: string | undefined | null): Set<string> { | ||
| return new Set( | ||
| (raw ?? '') | ||
| .split(',') | ||
| .map(t => t.trim()) | ||
| .filter(Boolean) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Decide whether an observation should be skipped based on its agent context. | ||
| * | ||
| * Union semantics (simpler and strictly safer than the issue's "priority" | ||
| * framing — the global toggle is a superset of any per-type list): | ||
| * - If CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS === 'true' AND an agentId is | ||
| * present → skip (the robust lever; independent of the exact type string). | ||
| * - Else if agentType is in CLAUDE_MEM_SKIP_AGENT_TYPES → skip (surgical). | ||
| * | ||
| * Defaults preserve current behavior: with the global toggle off and an empty | ||
| * skip list, this never skips. | ||
| */ | ||
| export function shouldSkipAgentObservation( | ||
| agentId: string | undefined | null, | ||
| agentType: string | undefined | null, | ||
| settings: AgentSkipSettings, | ||
| ): AgentSkipDecision { | ||
| if (settings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS === 'true' && agentId) { | ||
| return { skip: true, reason: 'subagent_observation' }; | ||
|
Comment on lines
+55
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The global toggle ( Prompt To Fix With AIThis is a comment left during a code review.
Path: src/shared/should-skip-agent-observation.ts
Line: 55-64
Comment:
**Per-type list can silently drop main-session observations**
The global toggle (`CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS`) correctly gates on `agentId`, so it is provably safe for main-session events. The per-type list path (`CLAUDE_MEM_SKIP_AGENT_TYPES`) checks only `agentType` — no `agentId` is required — so any main-session observation that carries a matching `agentType` (without an `agentId`) is dropped. The test at line 71 of the test file explicitly validates this: `shouldSkipAgentObservation(undefined, 'workflow-subagent', s).skip === true`. This contradicts the acceptance criterion "Main-session observations are unaffected" and the docs' claim under `CLAUDE_MEM_SKIP_AGENT_TYPES`. If Claude Code ever emits `agentType` on a main-session hook payload (even without `agentId`), those observations would be silently lost.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| } | ||
|
|
||
| if (agentType) { | ||
| const skipTypes = parseSkipAgentTypes(settings.CLAUDE_MEM_SKIP_AGENT_TYPES); | ||
| if (skipTypes.has(agentType)) { | ||
| return { skip: true, reason: 'agent_type_excluded' }; | ||
| } | ||
| } | ||
|
|
||
| return NO_SKIP; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import { describe, it, expect, beforeEach, afterEach, afterAll, spyOn, mock } from 'bun:test'; | ||
|
|
||
| // Capture real exports before mock.module mutates the live namespace, then | ||
| // re-register the snapshots in afterAll so these mocks do not leak into later | ||
| // test files (bun's mock.module is process-global; mock.restore() does NOT undo it). | ||
| import * as realHookSettings from '../../../src/shared/hook-settings.js'; | ||
| import * as realWorkerUtils from '../../../src/shared/worker-utils.js'; | ||
| import * as realRuntimeSelector from '../../../src/services/hooks/runtime-selector.js'; | ||
| const realHookSettingsSnapshot = { ...realHookSettings }; | ||
| const realWorkerUtilsSnapshot = { ...realWorkerUtils }; | ||
| const realRuntimeSelectorSnapshot = { ...realRuntimeSelector }; | ||
|
|
||
| // Mutable settings the handler sees via loadFromFileOnce() (used by both | ||
| // shouldTrackProject and shouldSkipAgentObservation). Tests reset it per case. | ||
| let mockSettings: Record<string, string> = { | ||
| CLAUDE_MEM_EXCLUDED_PROJECTS: '', | ||
| CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: 'false', | ||
| CLAUDE_MEM_SKIP_AGENT_TYPES: '', | ||
| }; | ||
|
|
||
| mock.module('../../../src/shared/hook-settings.js', () => ({ | ||
| loadFromFileOnce: () => mockSettings, | ||
| })); | ||
|
|
||
| const workerCallLog: Array<{ path: string; method: string; body: unknown }> = []; | ||
| mock.module('../../../src/shared/worker-utils.js', () => ({ | ||
| executeWithWorkerFallback: (path: string, method: string, body: unknown) => { | ||
| workerCallLog.push({ path, method, body }); | ||
| return Promise.resolve({ status: 'queued' }); | ||
| }, | ||
| isWorkerFallback: () => false, | ||
| })); | ||
|
|
||
| // Mutable runtime context so individual cases can flip between the `worker` and | ||
| // `server-beta` runtimes. The skip check must run BEFORE this branch, so a | ||
| // skipped subagent observation must reach neither dispatchToWorker nor recordEvent. | ||
| const recordEventLog: Array<unknown> = []; | ||
| let mockRuntime: Record<string, unknown> = { runtime: 'worker' }; | ||
| const serverBetaRuntime = () => ({ | ||
| runtime: 'server-beta', | ||
| projectId: 'proj-test', | ||
| serverBaseUrl: 'http://127.0.0.1:0', | ||
| client: { | ||
| recordEvent: (evt: unknown) => { | ||
| recordEventLog.push(evt); | ||
| return Promise.resolve(); | ||
| }, | ||
| }, | ||
| }); | ||
| mock.module('../../../src/services/hooks/runtime-selector.js', () => ({ | ||
| resolveRuntimeContext: () => mockRuntime, | ||
| logServerBetaFallback: () => {}, | ||
| })); | ||
|
|
||
| import { logger } from '../../../src/utils/logger.js'; | ||
|
|
||
| let loggerSpies: ReturnType<typeof spyOn>[] = []; | ||
|
|
||
| beforeEach(() => { | ||
| workerCallLog.length = 0; | ||
| recordEventLog.length = 0; | ||
| mockRuntime = { runtime: 'worker' }; | ||
| mockSettings = { | ||
| CLAUDE_MEM_EXCLUDED_PROJECTS: '', | ||
| CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: 'false', | ||
| CLAUDE_MEM_SKIP_AGENT_TYPES: '', | ||
| }; | ||
| loggerSpies = [ | ||
| spyOn(logger, 'info').mockImplementation(() => {}), | ||
| spyOn(logger, 'debug').mockImplementation(() => {}), | ||
| spyOn(logger, 'warn').mockImplementation(() => {}), | ||
| spyOn(logger, 'error').mockImplementation(() => {}), | ||
| spyOn(logger, 'dataIn').mockImplementation(() => {}), | ||
| ]; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| loggerSpies.forEach(spy => spy.mockRestore()); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| mock.module('../../../src/shared/hook-settings.js', () => realHookSettingsSnapshot); | ||
| mock.module('../../../src/shared/worker-utils.js', () => realWorkerUtilsSnapshot); | ||
| mock.module('../../../src/services/hooks/runtime-selector.js', () => realRuntimeSelectorSnapshot); | ||
| }); | ||
|
|
||
| const baseInput = (over: Record<string, unknown> = {}) => ({ | ||
| sessionId: 'session-abc', | ||
| cwd: '/tmp', | ||
| platform: 'claude-code', | ||
| toolName: 'Bash', | ||
| toolInput: { command: 'ls' }, | ||
| toolResponse: { stdout: '' }, | ||
| ...over, | ||
| }); | ||
|
|
||
| describe('observationHandler — subagent observation filtering (#2736)', () => { | ||
| it('dispatches to the worker for a main-session observation (defaults)', async () => { | ||
| const { observationHandler } = await import('../../../src/cli/handlers/observation.js'); | ||
| const result = await observationHandler.execute(baseInput()); | ||
| expect(result.continue).toBe(true); | ||
| expect(workerCallLog.length).toBe(1); | ||
| expect(workerCallLog[0].path).toBe('/api/sessions/observations'); | ||
| }); | ||
|
|
||
| it('dispatches subagent observations by default (no silent behavior change)', async () => { | ||
| const { observationHandler } = await import('../../../src/cli/handlers/observation.js'); | ||
| const result = await observationHandler.execute( | ||
| baseInput({ agentId: 'agent-1', agentType: 'workflow-subagent' }) | ||
| ); | ||
| expect(result.continue).toBe(true); | ||
| expect(workerCallLog.length).toBe(1); | ||
| }); | ||
|
|
||
| it('skips ALL subagent observations when CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS=true', async () => { | ||
| mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true'; | ||
| const { observationHandler } = await import('../../../src/cli/handlers/observation.js'); | ||
| const result = await observationHandler.execute( | ||
| baseInput({ agentId: 'agent-1', agentType: 'workflow-subagent' }) | ||
| ); | ||
| expect(result.continue).toBe(true); | ||
| expect(result.exitCode).toBe(0); | ||
| expect(workerCallLog.length).toBe(0); // no HTTP round-trip, no provider call | ||
| }); | ||
|
|
||
| it('does NOT skip the main session when the global toggle is on', async () => { | ||
| mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true'; | ||
| const { observationHandler } = await import('../../../src/cli/handlers/observation.js'); | ||
| const result = await observationHandler.execute(baseInput()); // no agentId | ||
| expect(result.continue).toBe(true); | ||
| expect(workerCallLog.length).toBe(1); | ||
| }); | ||
|
|
||
| it('skips only the listed agent_type values', async () => { | ||
| mockSettings.CLAUDE_MEM_SKIP_AGENT_TYPES = 'workflow-subagent,Explore'; | ||
| const { observationHandler } = await import('../../../src/cli/handlers/observation.js'); | ||
|
|
||
| const skipped = await observationHandler.execute( | ||
| baseInput({ agentId: 'a', agentType: 'workflow-subagent' }) | ||
| ); | ||
| expect(skipped.continue).toBe(true); | ||
| expect(workerCallLog.length).toBe(0); | ||
|
|
||
| const kept = await observationHandler.execute( | ||
| baseInput({ agentId: 'b', agentType: 'Plan' }) | ||
| ); | ||
| expect(kept.continue).toBe(true); | ||
| expect(workerCallLog.length).toBe(1); | ||
| }); | ||
|
|
||
| // The skip check sits AHEAD of the runtime branch, so it must protect the | ||
| // server-beta runtime too — not just the worker dispatch. These cases would | ||
| // fail if the check were ever moved down into the worker-only branch. | ||
| it('skips before the server-beta runtime branch — recordEvent is never called', async () => { | ||
| mockRuntime = serverBetaRuntime(); | ||
| mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true'; | ||
| const { observationHandler } = await import('../../../src/cli/handlers/observation.js'); | ||
| const result = await observationHandler.execute( | ||
| baseInput({ agentId: 'agent-1', agentType: 'workflow-subagent' }) | ||
| ); | ||
| expect(result.continue).toBe(true); | ||
| expect(result.exitCode).toBe(0); | ||
| expect(recordEventLog.length).toBe(0); // never reached the provider via server-beta | ||
| expect(workerCallLog.length).toBe(0); | ||
| }); | ||
|
|
||
| it('still records main-session observations on the server-beta runtime', async () => { | ||
| mockRuntime = serverBetaRuntime(); | ||
| mockSettings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS = 'true'; | ||
| const { observationHandler } = await import('../../../src/cli/handlers/observation.js'); | ||
| const result = await observationHandler.execute(baseInput()); // no agentId | ||
| expect(result.continue).toBe(true); | ||
| expect(recordEventLog.length).toBe(1); | ||
| expect(workerCallLog.length).toBe(0); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
skipistruethereasonfield is always populated, but theAgentSkipDecisiontype declares it as optional (reason?: AgentSkipReason). The non-null assertion silences TypeScript here but will throw at runtime if the invariant is ever broken by a future change toshouldSkipAgentObservation. Replacing with?? 'agent_skip'makes the fallback explicit.Prompt To Fix With AI