Strategy Pattern Everywhere — port / adapter for vendor integrations
v2.6 cache layer's strategy-registry pattern, generalized to every observability / cost / status / lens vendor integration in v2.8+. AWS-first adapter priority — Bedrock AgentCore Observability → CloudWatch → X-Ray — with non-AWS (OTel, Datadog, Pino) following.
In one line: the v2.6 cache layer (one DSL, N vendor strategies, side-effect-import auto-registration, wildcard fallback) is the right shape for every observability / cost / status / lens vendor integration. v2.8+ generalizes the principle as a unified
agent.enable.<group>(opts)+ strategy-slot architecture. One contract, many adapters, swap by config.
The lineage
This is the same pattern in 5 different formal traditions:
| Tradition | Name | What it is |
|---|---|---|
| GoF (1994) | Strategy Pattern | Pluggable algorithm; client picks at runtime |
| GoF (1994) | Bridge Pattern | Decouple abstraction from implementation across N vendor boundaries |
| Cockburn (2005) | Hexagonal / Ports & Adapters | The DSL is the port; each vendor integration is an adapter |
| Microsoft (.NET) | Provider model | Exact same shape: pluggable runtime providers selected by config, default + override |
| Plotkin & Pretnar | Algebraic effects | Strategies are effect handlers; the DSL is the effect signature; composition is handler stacking |
The cache layer in v2.6 is an instance. v2.8 makes it the universal architectural pattern across the library.
Why we know it works — v2.6 cache as proof of concept
DSL on the consumer side:
cache: 'always' | 'while-active' | 'never' | { until: predicate }
Pipeline:
CacheDecisionSubflow → CacheGate → strategy.prepareRequest → wire format
Strategies (auto-registered via side-effect import):
AnthropicCacheStrategy (manual cache_control)
OpenAICacheStrategy (auto-cache pass-through)
BedrockCacheStrategy (model-aware: Claude → Anthropic-style; else pass-through)
NoOpCacheStrategy (wildcard fallback)
Future: GeminiCacheStrategy
Validated outcome:
Sonnet Dynamic ReAct: 36,322 → 6,535 input tokens (−82%) end-to-endProperties the cache layer demonstrated and v2.8 generalizes:
- One DSL — consumers write declarative intent, not provider-specific calls
- N strategies — auto-registered by side-effect import;
agentfootprint/cache-anthropicetc. as subpaths with peer-dep on vendor SDK - Wildcard fallback — unknown provider gets a No-Op strategy that's safe by construction
- Hot-path zero-allocation — when the strategy says "no work to do," the runtime skips marker emission entirely
- CI-testable with mocks — every strategy ships a mock variant; production swaps in the real one
- Lock-in-free — vendor swap is a one-line change in config, not an agent rewrite
AWS adapter map — first ecosystem to support
Library already ships memory-agentcore (@aws-sdk/client-bedrock-agent-runtime peer dep). The AWS pattern is established. The AWS-side observability adapters below shipped in v3.1 — all under the single grouped agentfootprint/observability-providers subpath (one factory per vendor, never a new subpath):
Factory (in observability-providers) | AWS service | Peer dep | What it does | Status |
|---|---|---|---|---|
agentcoreObservability(opts) | AWS Bedrock AgentCore Observability | @aws-sdk/client-cloudwatch-logs (+ @aws-sdk/client-bedrock-agent-runtime, already a peer for memory-agentcore) | Pipe events into AgentCore's native observability surface. Consumers already integrated with AgentCore Memory get observability with the same auth pattern. | Shipped (v3.1) |
cloudwatchObservability(opts) | AWS CloudWatch Logs | @aws-sdk/client-cloudwatch-logs | Generic structured logging. Standard AWS observability sink. | Shipped (v3.1) |
xrayObservability(opts) | AWS X-Ray | @aws-sdk/client-xray | Distributed tracing — spans per LLM call / tool call / iteration. Derives parent-child from runtimeStageId. | Shipped (v3.1) |
audit (CloudTrail) (future) | AWS CloudTrail | — | Tamper-evident audit log for permission decisions, denied tools, capability inventory. | future, when enable.audit ships |
cost (Cost Explorer) (future) | AWS Cost & Billing | — | Pipe agentfootprint cost ticks into Cost Explorer for org-level dashboards. | future |
Why AWS-first:
- Existing precedent —
memory-agentcorealready ships, same peer-dep auth pattern reused - AgentCore observability is the lowest-effort, highest-value first adapter — same SDK consumer is already importing
- CloudWatch covers the "I don't use AgentCore but I'm on AWS" case — broader audience
- X-Ray adds distributed tracing — same pattern OTel uses, but AWS-native (no OTel collector needed)
After AWS lands, OTel + Datadog + Pino follow in v2.9.x (vendor-neutral / non-AWS).
The 4 groups in scope for v2.8
Each group is a port; vendor integrations are adapters. enable.<group>({ strategy, ...opts }) plumbs events to the chosen strategy.
| Group | DSL options | Strategies (shipped / planned) | Default when no strategy given | Out-of-scope per memo |
|---|---|---|---|---|
enable.observability | strategy? · sampleRate: 0..1 · detach? | consoleObservability() · otelObservability(tracer) · agentcoreObservability(opts) · cloudwatchObservability(opts) · xrayObservability(opts) | no-op (no auto-default) | server log shipping (Splunk, etc.) |
enable.cost | strategy? · budget? | inMemorySinkCost({onTick}) · (stripeCost — planned) | inMemorySinkCost() | OpenAI dashboard pipe (later) |
enable.liveStatus | strategy (required) · templates?: StatusTemplates | chatBubbleLiveStatus(cb) | none — strategy is required | server-sent-events helper (later) |
enable.lens (planned) | (rendering target) | noopLens() · (browserLens / cliLens — planned) | noopLens() | Lens-as-service (later) |
Parked for later (same pattern, different time): audit, governance, debug, eval, reflection.
The Strategy interface
Per group, a typed interface (small, focused, no inheritance):
// As shipped — src/strategies/types.ts. Each group has its own concrete version.
interface BaseStrategy {
readonly name: string; // registry key
flush?(): void | Promise<void>; // batch exporters
stop?(): void; // teardown (idempotent)
readonly relevantEventTypes?: readonly AgentfootprintEventType[]; // optional event filter
validate?(): void; // config check at registration
_onError?(error: Error, event?: AgentfootprintEvent): void;
}
interface ObservabilityStrategy extends BaseStrategy {
readonly capabilities: { // OTel's 4-signal model
events?: boolean;
logs?: boolean;
traces?: boolean;
metrics?: boolean;
};
exportEvent(event: AgentfootprintEvent): void; // hot path; sync, side-effect-only
}Properties enforced at the interface level:
- Idempotent registration — registering the same
nametwice replaces, doesn't duplicate - Side-effect-free, non-throwing hot path — the per-group hot-path method (
exportEvent) must not throw, must not block. Errors caught at the dispatch layer and routed to_onError(otherwise one bad exporter kills the agent loop) - Optional
stop/flush— strategies that batch can flush on stop; strategies that don't need it don't implement
Same BaseStrategy shape for CostStrategy (recordCost), LiveStatusStrategy (renderStatus), LensStrategy (renderGraph) — only the hot-path method name and capability bag differ per group. Symmetry is the point.
The composite combinator
Multi-vendor fan-out comes free:
import { otelObservability } from 'agentfootprint/observability-providers';
import { composeObservability, consoleObservability } from 'agentfootprint/observability-providers';
agent.enable.observability({
strategy: composeObservability([
otelObservability(tracer), // ship to your OTel collector (Datadog via OTLP, etc.)
consoleObservability(), // local dev visibility
]),
});Shipped as of v3.1: vendor strategies live under the single grouped subpath
agentfootprint/observability-providers(agentcoreObservability,cloudwatchObservability,xrayObservability,otelObservability) — not per-vendor subpaths. The default sinks (consoleObservability,inMemorySinkCost,chatBubbleLiveStatus,noopLens) and the per-group combinators (composeObservability,composeCost,composeLiveStatus,composeLens) are exported from the same place. There is no singlecompose— each group has its own typed combinator. Datadog ships no native adapter; point an OTel SDK at Datadog's OTLP endpoint and useotelObservability.
composeObservability returns a strategy whose exportEvent fans out to every child. Errors per-child isolated; one bad exporter doesn't break others. Same pattern as withRetry / withFallback in the resilience subsystem.
Package structure
Mirrors the existing grouped parallel-providers layout (llm-providers, tool-providers, memory-providers). One subpath per group; adding a vendor adds a factory export, not a new subpath:
agentfootprint ← core, ships the 4 default strategies
/observability-providers ← SHIPPED (v3.1): agentcore, cloudwatch, xray, otel
peers: @aws-sdk/client-cloudwatch-logs, @aws-sdk/client-xray,
@aws-sdk/client-bedrock-agent-runtime, @opentelemetry/api
/cost-providers ← planned: stripeCost
/lens-providers ← planned: browserLens, cliLensEach vendor factory:
- Lazy-imports its vendor SDK via
lib/lazyRequire.ts(zero bundle weight when unused) - Peer-dep on vendor SDK (consumer brings their version)
peerDependenciesMeta.optional: trueso missing peers don't break install
Why this matters more than "shipping integrations"
Three architectural wins:
1. Vendor swap is a one-line change
// Before
agent.enable.observability({ strategy: cloudwatchObservability({ region, logGroupName }) });
// After (vendor migration)
agent.enable.observability({ strategy: otelObservability(tracer) });Agent code is untouched. The DSL is the contract; the vendor is the implementation. Same one-line property the cache layer offers (provider: anthropic() → provider: openai()) and the memory layer (store: new InMemoryStore() → store: new RedisStore()).
2. Multi-vendor fan-out without rewrites
Production observability is rarely single-vendor. Teams pipe to Datadog AND OTel AND a custom audit log AND a console. compose([...]) handles this without consumers writing fan-out code per call site.
3. CI-testable without vendor accounts
Because a strategy is just an object implementing the typed interface, CI can plug in a capture-only strategy and assert on what was exported — no vendor account or network needed:
import type { ObservabilityStrategy } from 'agentfootprint';
const captured: AgentfootprintEvent[] = [];
const captureStrategy: ObservabilityStrategy = {
name: 'capture',
capabilities: { events: true },
exportEvent: (e) => { captured.push(e); },
};
agent.enable.observability({ strategy: captureStrategy });
// ... run the agent, then assert on `captured`For cost, inMemorySinkCost() already buffers ticks for read-back. CI runs the full pipeline against the in-memory/capture strategy; production swaps in otelObservability() / cloudwatchObservability(). Mocks first, prod second — the same architectural rule as the mock() LLM provider, InMemoryStore, and mockMcpClient.
What survives the panel review (locked-in design decisions)
From the 7-expert design review (AWS IAM, Datadog, OTel, Stripe, Vercel, React, Anthropic):
| # | Decision | Owner |
|---|---|---|
| 1 | Discriminated-union options ({ kind, ...rest }) inside each group, so new sub-features don't break the type | Stripe |
| 2 | Idempotent stop() — halts everything that call enabled, nothing else | Stripe |
| 3 | tier: 'minimal' | 'standard' | 'firehose' knob with cost-of-on docs at the call site | Datadog |
| 4 | sampleRate: 0..1 on every observability enabler | Datadog |
| 5 | OTel exporter as the first non-default vendor (semconv alignment is free interop) | OTel SIG |
| 6 | mode: 'observe' | 'enforce' on audit (when it ships) — dry-run is non-negotiable | AWS IAM |
| 7 | Zero-arg defaults for HelloWorld (enable.observability() works without options) | Vercel |
| 8 | Auto-detect NODE_ENV for sensible dev/prod defaults | Vercel |
| 9 | compose([...]) combinator for multi-vendor fan-out + idempotent re-registration | React |
| 10 | Granular methods stay (additive, deprecated-not-removed); flat namespace marked @deprecated in JSDoc | Stripe |
Migration plan — AWS-first vendor priority
The library already has memory-agentcore (@aws-sdk/client-bedrock-agent-runtime peer dep) shipping. Established pattern. Lean into the same ecosystem for observability strategies.
Status (4.0.0): the grouped enablers (
observability,cost,liveStatus), the strategy slot + the 4 default sinks (importable fromagentfootprint/strategies), the per-groupcompose*combinators, and the AWS observability adapters (agentcoreObservability,cloudwatchObservability,xrayObservability) plusotelObservabilityhave all shipped — under the singleagentfootprint/observability-providerssubpath. The deprecated flat methodsenable.thinking/enable.loggingwere removed in 4.0.0 (migrate toenable.liveStatus/enable.observability);enable.flowchartis kept (it's a real, non-deprecated composition-graph feature).cost/lensvendor subpaths andenable.lensremain planned. The version numbers below are the original plan, kept for the historical record.
| Version | What ships | Breaking? |
|---|---|---|
| v2.8.0 | Add the 4 grouped enablers (observability, cost, liveStatus, lens) + strategy slot + 3 default strategies (console, inMemorySink, chatBubble) + compose([...]) combinator. Granular methods kept, marked @deprecated. | No |
| v2.8.1 | AWS Bedrock AgentCore Observability — agentfootprint/observability-agentcore. Pipes events into AgentCore's native observability. Same peer (@aws-sdk/client-bedrock-agent-runtime) as memory-agentcore, so consumers already integrated with AgentCore Memory get observability for free. | No |
| v2.8.2 | AWS CloudWatch Logs — agentfootprint/observability-cloudwatch. Generic AWS structured logging. Peer: @aws-sdk/client-cloudwatch-logs. | No |
| v2.8.3 | AWS X-Ray — agentfootprint/observability-xray. Distributed tracing. Peer: @aws-sdk/client-xray. | No |
| v2.9.x | OTel exporter (observability-otel) — non-AWS but vendor-neutral; OTel SIG offered to contribute | No |
| v2.10.x | Non-AWS observability: Datadog, Pino | No |
| v2.11.x | cost-stripe, cost-webhook | No |
| v2.12.x | Lens strategies (browser, cliTUI, jsonExport) | No |
| 4.0.0 | Removed deprecated flat enable.thinking / enable.logging (migrate to enable.liveStatus / enable.observability). enable.flowchart kept — not deprecated. | Breaking — single-line migration |
Out of scope (parked)
These will get the same treatment but in separate design memos and future minors:
enable.audit— append-only, tamper-evident. Strategies:cloudtrail(),splunk(),localFile(path),noop(). Per AWS IAM review: must NOT be lumped withgovernance.enable.governance— soft signals: budget tracking, hit-rate floors, skill-churn detection. Per AWS IAM review: separate fromaudit(different threat models).enable.debug— Vercel-style "everything on, console output." Different defaults fromobservability.enable.eval— trajectory scoring, golden-answer matching.enable.reflection— causal-memory replay, self-improvement loops.
Each of these is well-understood structurally; they're parked because v2.8.0 should be small enough to review and ship cleanly.
Why this is a "Liskov-substitutable" architecture
Every strategy is a behavioral subtype of its interface:
ObservabilityStrategy (the supertype — the contract)
├── consoleObservability() (substitutable: prints structured events)
├── otelObservability(tracer) (substitutable: forwards to OTel tracer)
├── cloudwatchObservability() (substitutable: ships to CloudWatch Logs)
├── agentcoreObservability() (substitutable: pipes to AgentCore)
└── composeObservability([...]) (substitutable: fans out to N children)Agent code calls strategy.exportEvent(event) knowing only the interface. Swap any in, agent works. That's LSP enforced at runtime, not just at type-check. Same property as LLMProvider, MemoryStore, the cache strategies, and ToolProvider.
The library has one architectural pattern, applied recursively. Strategy + LSP + side-effect-import auto-registration is the spine. Cache was the first instance; v2.8 makes it the universal principle.
Pairing with other inspiration pages
- Modularity — the Liskov lineage — why substitutability is sound (the abstraction theory)
- Connected data — the Palantir lineage — why connected data matters (the user value)
- Strategy everywhere (this page) — how the library scales to N vendor integrations without architectural drift
The three together form the load-bearing architecture: connected data inside clean boundaries, with vendor integrations as substitutable adapters. Adapter substitutability is what keeps the data-connection guarantees from leaking across vendor boundaries.
Approval gates
Before v2.8.0 implementation starts:
- ✅ Design memo (this document) committed and reviewed
- ✅ Strategy interface signatures locked in
src/strategies/types.ts - ✅ Vendor adapters shipped end-to-end (AgentCore, CloudWatch, X-Ray, OTel) under
observability-providers - 🔲 Mock-strategy contract test — validates that ANY strategy implementing the interface plugs in cleanly
- 🔲 Performance baseline —
compose([...])of 5 children must add ≤ 5% overhead vs single strategy
Once gate 5 passes, we know the architecture scales linearly. Then ship the rest in independent minors.
Further reading
- Design Patterns (Gamma et al., 1994) — Strategy + Bridge chapters
- Cockburn, Hexagonal Architecture (2005) — port/adapter formalism
- Plotkin & Pretnar, Handlers of Algebraic Effects (2009) — handler-stacking semantics
- The v2.6 CHANGELOG entry — strategy pattern in our codebase, retrospective
Modularity — the Liskov lineage
THE HOW behind agentfootprint's design. Liskov's ADT (1974) and LSP (1987) work, applied to flowcharts. Subflows are CLU clusters. CacheStrategy / LLMProvider / ToolProvider are LSP-substitutable. Locality of reasoning is enforced as a runtime invariant.
Citations & papers
The research foundation of agentfootprint — every named pattern shipped as a recipe is grounded in a paper. References + how the recipe in examples/patterns/ relates to the original work.
