Skip to content

Commit d89a54e

Browse files
NRKZOZWclaude
andcommitted
feat: skip/filter subagent observations (Dynamic Workflows) — thedotmack#2736
Claude Code Dynamic Workflows fan a single prompt out to tens-to-hundreds of parallel subagents (agent_type `workflow-subagent`). Every tool call fires a PostToolUse hook → claude-mem creates one provider-analyzed observation per call, so a single run emits hundreds-to-thousands of low-signal observations. That exhausts the provider's free-tier quota (HTTP 429) and trips the restart guard, dropping the rest of the run — including valuable main-session work. Add two settings, filtered BEFORE any worker HTTP call or provider request so the round-trip and the provider tokens are saved: - CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS (default `false`) — skip every observation carrying an agentId. The robust, type-independent lever. - CLAUDE_MEM_SKIP_AGENT_TYPES (default empty) — comma-separated agent_type skip list, e.g. `workflow-subagent,Explore`. Both default off to preserve current behavior (per the issue's acceptance criteria); `workflow-subagent` is documented as the recommended value. The two combine as a union: skip if the global toggle matches OR the agent_type is listed. Implementation: - New pure helper src/shared/should-skip-agent-observation.ts — single source of truth for both filtering points. - Hook handler (src/cli/handlers/observation.ts) filters ahead of the runtime branch, so it covers both the worker and server-beta runtimes. - Worker ingestObservation (src/services/worker/http/shared.ts) filters again as defense-in-depth for any non-hook caller, before the provider call. - Settings wired into SettingsDefaultsManager (interface + defaults) and the viewer constants; documented in docs/public/configuration.mdx. - Tests: helper unit tests + hook-handler integration covering worker and server-beta runtimes, main-session-unaffected, global toggle, and per-type list. - Rebuilt plugin bundles. Future work (out of scope): an optional per-session/minute throughput guard for runaway bursts from any source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8463689 commit d89a54e

13 files changed

Lines changed: 734 additions & 316 deletions

File tree

docs/public/configuration.mdx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,43 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
2222
| `CLAUDE_MEM_WORKER_HOST` | `127.0.0.1` | Worker service host address |
2323
| `CLAUDE_MEM_DATA_DIR` | `~/.claude-mem` | Data root — every other path (database, chroma, logs, settings.json, worker.pid) derives from this |
2424
| `CLAUDE_MEM_SKIP_TOOLS` | `ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion` | Comma-separated tools to exclude from observations |
25+
| `CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS` | `false` | When `true`, skip **every** subagent observation (any tool call carrying an `agentId`). See [Subagent & Dynamic Workflow filtering](#subagent--dynamic-workflow-filtering). |
26+
| `CLAUDE_MEM_SKIP_AGENT_TYPES` | _(empty)_ | Comma-separated `agent_type` values to skip, e.g. `workflow-subagent,Explore,Plan`. Main-session observations are unaffected. |
27+
28+
### Subagent & Dynamic Workflow filtering
29+
30+
Claude Code [Dynamic Workflows](https://code.claude.com/docs/en/workflows) fan a single
31+
prompt out to tens-to-hundreds of parallel subagents (`agent_type: workflow-subagent`).
32+
Every tool call by every subagent fires a `PostToolUse` hook, so claude-mem creates one
33+
provider-analyzed observation per call — a single run can emit **hundreds-to-thousands**
34+
of low-signal observations. That can exhaust the configured provider's free-tier quota
35+
(HTTP 429) and drop the rest of the run, including valuable main-session work.
36+
37+
Two settings let you filter these out **before** any worker round-trip or provider call.
38+
Both default to off, so existing behavior is preserved.
39+
40+
- **Drop all subagent noise (recommended for heavy workflow users):**
41+
42+
```json
43+
{ "CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS": "true" }
44+
```
45+
46+
This is the robust lever — it keys off the presence of an `agentId`, so it catches
47+
every subagent regardless of type. Main-session observations are kept.
48+
49+
- **Surgically drop specific agent types** (keep some subagents, drop others):
50+
51+
```json
52+
{ "CLAUDE_MEM_SKIP_AGENT_TYPES": "workflow-subagent" }
53+
```
54+
55+
Add a comma-separated list of `agent_type` values. The two settings combine as a
56+
union: an observation is skipped if the global toggle matches **or** its `agent_type`
57+
is in the list.
58+
59+
> Subagent **summaries** are already skipped automatically; these settings add the same
60+
> control for **observations**. Durable cross-session knowledge generally comes from the
61+
> main session, so dropping ephemeral subagent steps usually improves signal-to-noise.
2562
2663
### Gemini Provider Settings
2764

plugin/scripts/context-generator.cjs

Lines changed: 7 additions & 7 deletions
Large diffs are not rendered by default.

plugin/scripts/mcp-server.cjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

plugin/scripts/server-beta-service.cjs

Lines changed: 139 additions & 123 deletions
Large diffs are not rendered by default.

plugin/scripts/worker-service.cjs

Lines changed: 170 additions & 170 deletions
Large diffs are not rendered by default.

plugin/ui/viewer-bundle.js

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/handlers/observation.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { executeWithWorkerFallback, isWorkerFallback } from '../../shared/worker
77
import { logger } from '../../utils/logger.js';
88
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
99
import { shouldTrackProject } from '../../shared/should-track-project.js';
10+
import { shouldSkipAgentObservation } from '../../shared/should-skip-agent-observation.js';
11+
import { loadFromFileOnce } from '../../shared/hook-settings.js';
1012
import { normalizePlatformSource } from '../../shared/platform-source.js';
1113
import { resolveRuntimeContext, logServerBetaFallback } from '../../services/hooks/runtime-selector.js';
1214
import { isServerBetaClientError } from '../../services/hooks/server-beta-client.js';
@@ -60,6 +62,20 @@ export const observationHandler: EventHandler = {
6062
return { continue: true, suppressOutput: true };
6163
}
6264

65+
// #2736 — drop subagent observations BEFORE any worker HTTP call or provider
66+
// request. Placed ahead of the runtime branch so it covers both the worker
67+
// and server-beta runtimes. Saves the round-trip and the provider tokens,
68+
// and prevents Dynamic Workflows fan-out from exhausting provider quota.
69+
const skip = shouldSkipAgentObservation(input.agentId, input.agentType, loadFromFileOnce());
70+
if (skip.skip) {
71+
logger.debug('HOOK', `Skipping observation: ${skip.reason}`, {
72+
toolName,
73+
agentId: input.agentId,
74+
agentType: input.agentType,
75+
});
76+
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
77+
}
78+
6379
const runtime = resolveRuntimeContext();
6480
if (runtime.runtime === 'server-beta') {
6581
try {

src/services/worker/http/shared.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { SessionEventBroadcaster } from '../events/SessionEventBroadcaster.
66
import type { ParsedSummary } from '../../../sdk/parser.js';
77
import { stripMemoryTagsFromJson } from '../../../utils/tag-stripping.js';
88
import { isProjectExcluded } from '../../../utils/project-filter.js';
9+
import { shouldSkipAgentObservation } from '../../../shared/should-skip-agent-observation.js';
910
import { SettingsDefaultsManager } from '../../../shared/SettingsDefaultsManager.js';
1011
import { USER_SETTINGS_PATH } from '../../../shared/paths.js';
1112
import { getProjectContext } from '../../../utils/project-name.js';
@@ -114,6 +115,15 @@ export async function ingestObservation(payload: ObservationPayload): Promise<In
114115
return { ok: true, status: 'skipped', reason: 'tool_excluded' };
115116
}
116117

118+
// #2736 — defense in depth: the hook handler already filters subagent
119+
// observations before this HTTP call, but skip again here so any non-hook
120+
// caller (direct API, future ingestion paths) is filtered before the
121+
// queueObservation → provider request below.
122+
const agentSkip = shouldSkipAgentObservation(payload.agentId, payload.agentType, settings);
123+
if (agentSkip.skip) {
124+
return { ok: true, status: 'skipped', reason: agentSkip.reason! };
125+
}
126+
117127
const fileOperationTools = new Set(['Edit', 'Write', 'Read', 'NotebookEdit']);
118128
if (fileOperationTools.has(payload.toolName) && payload.toolInput && typeof payload.toolInput === 'object') {
119129
const input = payload.toolInput as { file_path?: string; notebook_path?: string };

src/shared/SettingsDefaultsManager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ export interface SettingsDefaults {
99
CLAUDE_MEM_WORKER_PORT: string;
1010
CLAUDE_MEM_WORKER_HOST: string;
1111
CLAUDE_MEM_SKIP_TOOLS: string;
12-
CLAUDE_MEM_PROVIDER: string;
12+
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: string; // #2736 — skip ALL subagent observations (any hook event carrying an agentId)
13+
CLAUDE_MEM_SKIP_AGENT_TYPES: string; // #2736 — comma-separated agent_type values to skip (e.g. workflow-subagent,Explore)
14+
CLAUDE_MEM_PROVIDER: string;
1315
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string;
1416
CLAUDE_MEM_GEMINI_API_KEY: string;
1517
CLAUDE_MEM_GEMINI_MODEL: string;
@@ -89,6 +91,8 @@ export class SettingsDefaultsManager {
8991
CLAUDE_MEM_WORKER_PORT: String(37700 + ((process.getuid?.() ?? 77) % 100)),
9092
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
9193
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
94+
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: 'false', // #2736 — default off preserves current behavior; set 'true' to skip every subagent observation (recommended for heavy Dynamic Workflows users)
95+
CLAUDE_MEM_SKIP_AGENT_TYPES: '', // #2736 — default empty preserves current behavior; recommended value 'workflow-subagent' to drop Dynamic Workflows fan-out noise
9296
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
9397
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'subscription', // Default to logged-in Claude SDK auth (not API key)
9498
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Shared, pure decision helper for skipping subagent observations (issue #2736).
2+
//
3+
// Two filtering points call this with the SAME logic so behavior can't drift:
4+
// 1. The PostToolUse hook handler (src/cli/handlers/observation.ts) — runs in
5+
// the short-lived hook process BEFORE any worker HTTP call or provider
6+
// request, so a skipped observation costs nothing. This covers both the
7+
// `worker` and `server-beta` runtimes because it sits ahead of the runtime
8+
// branch.
9+
// 2. The worker ingest path (src/services/worker/http/shared.ts) — defense in
10+
// depth for any caller that reaches the worker without going through the
11+
// hook handler (direct API callers, future ingestion paths).
12+
//
13+
// Motivation: Claude Code Dynamic Workflows fan a single prompt out to
14+
// tens-to-hundreds of parallel subagents (agent_type `workflow-subagent`), each
15+
// tool call firing a PostToolUse hook → one provider-analyzed observation. A
16+
// single run can emit hundreds-to-thousands of low-signal observations, which
17+
// exhausts the configured provider's quota (HTTP 429) and trips the restart
18+
// guard, dropping the rest of the run — including valuable main-session work.
19+
20+
export type AgentSkipReason = 'subagent_observation' | 'agent_type_excluded';
21+
22+
export interface AgentSkipSettings {
23+
/** When 'true', skip every observation carrying an agentId (any subagent). */
24+
CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS: string;
25+
/** Comma-separated agent_type values to skip (e.g. "workflow-subagent,Explore"). */
26+
CLAUDE_MEM_SKIP_AGENT_TYPES: string;
27+
}
28+
29+
export interface AgentSkipDecision {
30+
skip: boolean;
31+
reason?: AgentSkipReason;
32+
}
33+
34+
const NO_SKIP: AgentSkipDecision = { skip: false };
35+
36+
/** Parse a comma-separated agent_type skip list into a trimmed, de-duped set. */
37+
export function parseSkipAgentTypes(raw: string | undefined | null): Set<string> {
38+
return new Set(
39+
(raw ?? '')
40+
.split(',')
41+
.map(t => t.trim())
42+
.filter(Boolean)
43+
);
44+
}
45+
46+
/**
47+
* Decide whether an observation should be skipped based on its agent context.
48+
*
49+
* Union semantics (simpler and strictly safer than the issue's "priority"
50+
* framing — the global toggle is a superset of any per-type list):
51+
* - If CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS === 'true' AND an agentId is
52+
* present → skip (the robust lever; independent of the exact type string).
53+
* - Else if agentType is in CLAUDE_MEM_SKIP_AGENT_TYPES → skip (surgical).
54+
*
55+
* Defaults preserve current behavior: with the global toggle off and an empty
56+
* skip list, this never skips.
57+
*/
58+
export function shouldSkipAgentObservation(
59+
agentId: string | undefined | null,
60+
agentType: string | undefined | null,
61+
settings: AgentSkipSettings,
62+
): AgentSkipDecision {
63+
if (settings.CLAUDE_MEM_SKIP_SUBAGENT_OBSERVATIONS === 'true' && agentId) {
64+
return { skip: true, reason: 'subagent_observation' };
65+
}
66+
67+
if (agentType) {
68+
const skipTypes = parseSkipAgentTypes(settings.CLAUDE_MEM_SKIP_AGENT_TYPES);
69+
if (skipTypes.has(agentType)) {
70+
return { skip: true, reason: 'agent_type_excluded' };
71+
}
72+
}
73+
74+
return NO_SKIP;
75+
}

0 commit comments

Comments
 (0)