Skip to content

Architecture Decision Records

ADRs capture the reasoning behind major design decisions. Each decision is permanent unless explicitly superseded.


ADR-001: Dapr as Infrastructure Backbone

Status: Accepted

Context: Need durable execution, state management, pub/sub, security, and observability without building from scratch. Building these correctly (with mTLS, ETag concurrency, distributed locks, workflow checkpointing) would require 6–12 months of distributed systems engineering.

Decision: Use Dapr runtime as the infrastructure layer. Agent code talks only to Dapr APIs, never directly to databases or message brokers.

Consequences: - Requires Dapr sidecar running alongside agent process - Adds operational complexity in Kubernetes (two containers per pod) - All infrastructure is swappable via Dapr component YAML — no code changes to switch from Redis to Kafka for pub/sub - mTLS between services is zero-configuration - Dapr emits OTEL traces automatically for all state and messaging operations


ADR-002: PostgreSQL + pgvector as Primary State Store

Status: Accepted

Context: Need ACID-compliant, auditable storage for memory with vector search capability. Running separate databases for relational data (facts, events) and vector data (embeddings) increases operational complexity.

Decision: PostgreSQL 16 with pgvector extension as primary state store. Redis as cache layer for working memory and tool result caching.

Consequences: - Single database for relational + vector data reduces operational complexity - pgvectorscale benchmarks show competitive performance (471 QPS at 99% recall on 50M vectors) - Graph queries use recursive CTEs in PostgreSQL rather than a separate graph database (revisit if proven insufficient) - ACID transactions enable reliable provenance and audit trails without saga complexity


ADR-003: Pydantic v2 for All Data Models

Status: Accepted

Context: Need strict validation, serialization, and schema generation for agent definitions, tool parameters, memory records, and API contracts. Inconsistent validation across the codebase is a primary source of subtle bugs in agent frameworks.

Decision: All public types are Pydantic v2 BaseModel subclasses. Configuration uses pydantic-settings.

Consequences: - Strict mode catches construction-time bugs (wrong types, missing required fields) - JSON schema generation from ToolDefinition.to_function_schema() enables automatic tool documentation for LLMs - Serialization is consistent: one code path for storage, HTTP, and logging - Adds ~100ms import time (negligible for agent workflows where LLM calls dominate)


ADR-004: Async-First Architecture

Status: Accepted

Context: Agents are I/O-bound: LLM API calls (1–30s), tool execution (10ms–30s), database operations (1–50ms). Synchronous Python would block the event loop on every I/O call, preventing concurrency.

Decision: All I/O operations use async/await. httpx for HTTP, asyncpg for PostgreSQL, aioredis for Redis. Synchronous tool functions run in thread pool executors.

Consequences: - Entire call chain must be async; synchronous callers use asyncio.run() - Test fixtures require pytest-asyncio with asyncio_mode = "auto" - Enables serving many concurrent agent runs in a single process - CPU-bound tool execution (e.g., image processing) runs in loop.run_in_executor()


ADR-005: Event Sourcing for Agent Actions

Status: Accepted

Context: Need full auditability, replay capability, and time-travel debugging for agent executions. Compliance requirements in financial, healthcare, and legal domains require verifiable audit trails.

Decision: Every agent action (LLM call, tool call, memory read/write, decision point) is stored as an immutable event in an append-only log via Dapr state store.

Consequences: - Current state is derived from event replay - Enables forensic debugging: "why did the agent do X at step 5?" - Adds write amplification (every action writes to both operational state and event log) - Enables compliance-ready audit trails without additional infrastructure


ADR-006: Memory Write Provenance as Non-Negotiable

Status: Accepted

Context: Memory poisoning attacks (MINJA, MemoryGraft) achieve 95%+ success rates against unprotected agents. Memory is the primary attack surface for persistent agent compromise — a successful poisoning persists across sessions and survives context window limits.

Decision: Every memory write must include provenance metadata (source_type, source_id, trust_level, content_hash). Memory writes without provenance are rejected. This is enforced at the DaprStateStore wrapper level, not at the application level, so it cannot be bypassed by application code.

Consequences: - Adds ~2ms overhead per memory write (SHA-256 hash computation + metadata storage) - All retrieval queries can filter by trust level - Memory auditor can verify integrity via content hashes - Makes memory poisoning attacks significantly harder — tampered content changes the hash, triggering auditor alerts


ADR-007: Sandbox by Default for Tool Execution

Status: Accepted

Context: Agents execute LLM-generated code and call external APIs. Unsandboxed execution grants LLM-generated actions full access to the host system — filesystem, network, environment variables, and other processes. OpenClaw (2024) found 190 security advisories in popular agent frameworks due to unsandboxed execution.

Decision: All tool code execution runs in Docker container sandbox by default. Network access, filesystem access, and resource limits are configured per-tool. Opt-out requires explicit configuration.

Consequences: - Adds ~200ms cold-start latency for first tool call in a session (container spin-up) - Warm container reuse reduces subsequent calls to ~10ms overhead - Prevents host system compromise via prompt injection → code execution - Requires Docker daemon running alongside agent


ADR-008: OpenTelemetry for All Observability

Status: Accepted

Context: Need distributed tracing, metrics, and logs that work with any backend (Jaeger, Prometheus, Grafana, Datadog, Honeycomb, etc.). Vendor lock-in to a specific observability platform would prevent adoption in organizations with existing tooling.

Decision: OpenTelemetry is the observability standard. Dapr provides infrastructure-level OTEL automatically. Grampus adds agent-specific custom spans: agent.run, agent.llm_call, agent.tool_call, agent.memory_read, agent.memory_write, agent.decision.

Consequences: - Any OTEL-compatible backend works out of the box — change the exporter endpoint, not the code - Custom spans enable agent-specific debugging that generic APM tools cannot provide - Agents running in Kubernetes benefit from Dapr's automatic service mesh tracing - Token cost and model information are captured as span attributes, enabling cost analysis via trace queries


ADR-009: Code Agents as Primary, JSON Tool Calling as Fallback

Status: Accepted

Context: Smolagents research (2024) demonstrated that agents writing Python code compose tools more flexibly and handle data transformations more naturally than JSON tool calling. Code agents can chain tool calls, use Python data structures, and perform calculations without additional LLM calls.

Decision: Support both code agents (LLM writes Python executed in sandbox) and JSON tool calling (standard function calling). Code agents are the recommended default for complex, multi-step tasks.

Consequences: - Requires robust sandboxing (ADR-007) - Code execution captures stdout, stderr, and return values - Sandbox Python namespace includes registered tools as callable functions - Simpler tasks can use JSON tool calling to avoid sandbox overhead


ADR-010: MCP + A2A Protocol Support from Day One

Status: Accepted

Context: MCP (Model Context Protocol) is becoming the standard for tool integration (97M monthly SDK downloads as of 2025). A2A (Agent-to-Agent) enables cross-framework agent discovery. Building custom tool protocols creates ecosystem lock-in and prevents Grampus agents from using the growing ecosystem of MCP-compatible tools.

Decision: Implement MCP client in the tool layer. Implement A2A discovery in the orchestration layer. Both are standards-compliant implementations, not custom protocols.

Consequences: - Grampus agents can use any MCP-compatible tool server (filesystem, browser, databases, APIs) - Other frameworks' agents can discover and invoke Grampus agents via A2A - Avoids ecosystem lock-in — Grampus works alongside LangGraph, CrewAI, and Autogen - Requires tracking protocol evolution as both MCP and A2A mature


ADR-011: Consolidated HTMX + Jinja2 Web UI

Status: Accepted

Context: Multiple post-launch phases require visual interfaces: memory inspector, eval dashboard, cost analytics, alert management, and an execution trace viewer. Two alternative approaches were considered: (a) separate CLI commands for each feature, or (b) separate web apps or SPAs per feature. Both create fragmentation — users must remember different URLs or commands, state cannot be shared across views (e.g., filtering by agent_id in the sidebar should filter all pages), and each feature reimplements the same table/chart components.

Decision: All web UI phases build into a single consolidated web app served at /ui/ from the existing FastAPI server. Technology stack: HTMX (loaded from CDN — no npm, no build step) + Jinja2 templates for server-side rendering. The memory inspector phase builds the shell (base template, sidebar navigation, layout system) and all subsequent UI phases add pages to it. One new optional dependency: jinja2>=3.0 added to the server extras group (already a transitive FastAPI dependency in practice).

Exception: The Visual Agent Builder (drag-and-drop graph editor) requires rich interactivity — sortable nodes, canvas pan/zoom, live edge drawing — that HTMX cannot support. That feature uses a minimal React SPA bundled at src/grampus/server/ui/static/builder/ and served at /ui/builder/. It is the only component permitted to introduce a frontend build step.

Consequences: - No Node.js toolchain required to run or develop the UI — uv sync is sufficient - Single URL entry point; sidebar navigation shared across all views - HTMX partial endpoints (/ui/<feature>/_<partial>) enable dynamic updates (live cost tickers, SSE-driven agent status) without full page reloads - HTMX has limits on complex client-side interactivity — sufficient for developer dashboards, not for visual graph editors (see exception above) - Static assets (CSS, minimal JS helpers) live in src/grampus/server/ui/static/ and are served by FastAPI's StaticFiles mount - Jinja2 templates live in src/grampus/server/ui/templates/ with a base.html that all pages extend


ADR-012: Multi-Agent Debate as a First-Class Orchestration Primitive

Status: Accepted

Context: High-stakes agent tasks (legal analysis, medical triage, financial decisions) cannot rely on a single LLM call because (a) individual models hallucinate on specialised questions and (b) there is no confidence signal that a single model can reliably self-report. Two prior approaches exist: prompt-level self-consistency (same model, multiple samples) and multi-agent crews (different agents, different roles). Self-consistency degrades on hard questions because sampling diversity is bounded by a single model's knowledge. Crews require pre-defined pipelines and do not provide a convergence signal. Research (Du et al. ICML 2024; M3MAD-Bench ICLR 2025) demonstrates that heterogeneous models arguing toward a shared answer reach substantially higher accuracy than either alternative.

Decision: Implement DebateOrchestrator as a standalone orchestration primitive in src/grampus/orchestration/debate/. It operates on a single question rather than a task pipeline, runs all debaters concurrently per round via asyncio.gather, and integrates with the existing Graph engine via debate_node(). Four specific research findings are baked into the design:

  1. Heterogeneous panelsDebaterConfig.model_id allows mixing model families, not just temperatures. The aggregator uses debater.weight to handle unequal capability.
  2. Sycophancy resistance — Round 2+ prompts require debaters to restate their prior answer verbatim before evaluating peers, and to cite specific logical evidence for any position change (ACL 2025 CONSENSAGENT).
  3. Adaptive routing — If a fast routing model reports confidence ≥ threshold, the full debate is bypassed. This eliminates ~40% of unnecessary calls with no quality loss (arXiv 2504.05047).
  4. Act-vs-escalate — When the final convergence score is below escalate_threshold, the result sets escalate_to_human=True rather than silently returning a low-confidence answer ("From Debate to Decision", April 2026).

Consequences: - Zero new runtime dependencies — stdlib json, asyncio, re, time plus existing Pydantic and OTEL - debate_node() integrates cleanly with the existing Graph conditional-edge API; human escalation uses the existing human_node - Concurrent debaters within a round mean latency is bounded by the slowest debater, not the sum — no worse than a single LLM call per round - Cost scales as num_debaters × num_rounds but adaptive routing mitigates this for easy questions - The convergence detector uses Jaccard word-overlap clustering (no ML model, no embedding calls) — fast and deterministic


ADR-013: Dual-Process Uncertainty Quantification as a First-Class Runner Feature

Status: Accepted

Context: Agents produce unreliable outputs at unknown rates. Single-call verbalized confidence (asking the model to write "confidence": 0.8) has a documented ECE of 0.377+ even on frontier models (arXiv 2412.14737, KDD 2025 survey) — aligned models cluster at 90–100% confidence regardless of factual accuracy. Existing frameworks either ignore this or apply per-call thresholds that do not account for how uncertainty accumulates across sequential steps. A grounding error in step 1 biases all downstream reasoning (the "Spiral of Hallucination"), so per-step overconfidence checking is insufficient. There is also no standard mechanism for agents to escalate irreversible actions (send_email, delete, deploy) to humans when confidence is too low.

Decision: Implement UncertaintyMonitor as an optional hook in AgentRunner, not as a separate layer. Four research findings are baked directly into the implementation:

  1. Dual-process estimation (arXiv 2601.15703, Jan 2026) — System 1 (fast): P(True) self-evaluation fused with verbalized confidence, both calibrated. System 2 (slow, opt-in): adaptive semantic entropy sampling when fused confidence is in the uncertain middle zone.
  2. P(True) as primary fast signal (Kadavath et al. 2022) — A single follow-up call asking "Is your answer correct?" achieves ECE ≈ 0.10 on frontier models without logit access. Verbalized confidence (weight 0.4) remains a weak supporting signal alongside P(True) (weight 0.6).
  3. Adaptive semantic entropy (arXiv 2504.03579, 2025) — Start with 2 samples; early-stop if Jaccard ≥ 0.60 (saves ~47% cost); extend to max_samples on disagreement. Pessimistic fusion min(fast, entropy_conf) prevents over-optimism.
  4. SAUP propagation (arXiv 2412.01033, ACL 2025 pp. 6064–6073) — Per-step situational weights (decision=0.70, llm=0.55, tool=0.45, memory_read=0.35) ensure a confident step cannot erase uncertain history. 20% AUROC improvement over single-step UQ.

The three-tier escalation ladder (Zylos Research, April 2026) maps propagated confidence → action: PROCEED → PROCEED_WITH_LOG → PAUSE_FOR_HUMAN → ABORT. Irreversible tool names trigger PAUSE at MEDIUM uncertainty. A System-2 reflection prompt is injected before PAUSE so the next LLM call sees explicit uncertainty acknowledgment.

Consequences: - Zero new required dependencies — stdlib math, json, re, asyncio plus existing Pydantic and OTEL - uncertainty_monitor=None (the default) means zero overhead for agents that don't need UQ - Two hooks in the runner loop: post-LLM (checks response confidence) and pre-tool (checks before irreversible actions); both break the loop cleanly with hit_limit = False - UncertaintyError (code UNCERTAINTY_CRITICAL) gives callers a machine-readable signal on ABORT - uncertainty_guard_node() provides an explicit graph checkpoint between nodes — composable with the existing debate_node() and human_node() primitives - OTEL spans (uncertainty.estimate, uncertainty.semantic, uncertainty.escalate) are emitted per step when a tracer is provided, enabling confidence dashboards alongside cost and latency metrics


ADR-014: Long-Horizon Planning as a First-Class Orchestration Layer

Status: Accepted

Context: The existing AgentRunner implements a greedy ReAct loop where each step is chosen independently from the prior step's observation. Research shows this is fundamentally broken for long-horizon tasks: locally optimal step choices lead to early commitments that compound — the longer the task, the worse the degradation ("Why Reasoning Fails to Plan", arXiv 2601.22311, Jan 2026). Existing mitigation strategies — increasing max_iterations, adding chain-of-thought — do not address the core problem of myopic greedy selection. Two additional failure modes motivated this decision: (a) passing full conversation history to every LLM call is the dominant token-cost driver for multi-step tasks, and (b) there is no recovery mechanism when an intermediate step fails other than starting over.

Decision: Implement PlanningRunner as a distinct orchestration layer that wraps AgentRunner without modifying it. Four research findings are baked directly into the implementation:

  1. Task-Decoupled Planning / scoped context (arXiv 2601.07577, Jan 2026) — Each subgoal executor receives only: global task + one-line summaries of completed steps + current subgoal description. The full conversation history is never passed. This reduces token usage by ~82% on long plans and confines error propagation to the active node.
  2. Fallback before replanning (ReAcTree, arXiv 2511.02424, AAMAS 2026) — When a subgoal fails after max_retries, a pre-specified fallback_strategy is tried once before triggering a full (partial) replan. This doubles success rate (61% vs 31%) at negligible cost.
  3. Partial replan only (Google DeepMind Subgoal Framework, arXiv 2603.19685, Mar 2026) — When replanning is triggered, only the downstream unfinished subgoals are regenerated. Completed subgoals and their outputs are preserved. This reduces replan cost and eliminates the "restart from scratch" failure mode.
  4. Adaptive engagement ("Learning When to Plan", arXiv 2509.03581) — A cheap complexity estimate call gates planning engagement. Tasks estimated at ≤ complexity_threshold tool calls delegate directly to AgentRunner, eliminating planning overhead (~40% of queries in typical workloads).

An optional FLARE-inspired lookahead (arXiv 2601.22311) generates n candidate execution paths before each subgoal and selects the highest-scoring approach. It is advisory only: parse failures are silently swallowed and execution continues without a hint.

Consequences: - AgentRunner is unchanged — PlanningRunner wraps it, so all existing ReAct agents continue to work without modification - Subgoal DAG topology is validated at plan creation: unique IDs, no missing dependency references, no cycles (Kahn's algorithm); PlanningError(code="CIRCULAR_DEPENDENCY") is raised on cycle detection - PostconditionVerifier introduces one extra LLM call per subgoal; with the fast model tier this is negligible relative to subgoal execution cost - Parallel wave execution via asyncio.gather matches the existing Graph engine's parallel branch model — the same event loop runs both - planning_node() integrates cleanly with the existing Graph conditional-edge API; failure escalation uses the existing human_node pattern - Zero new required dependencies — stdlib asyncio, json, re, collections plus existing Pydantic and structlog - PlanningError is a top-level peer of OrchestrationError, not a subclass, because planning failures are structurally different from runner failures (they occur before execution begins or during plan maintenance, not during the ReAct loop)


ADR-015: Artifact-Centric Collaboration as a First-Class Orchestration Pattern

Status: Accepted

Context: Multi-agent workflows that pass text strings between agents cannot enforce structure, detect conflicts, or guarantee consistency. Agents working on the same document or codebase independently create silently incompatible outputs. The Specification Gap paper (arXiv 2603.24284, March 2026) showed that implicit shared specifications reduce two-agent integration accuracy by 25–39 percentage points. STORM (arXiv 2605.20563, May 2026) showed that post-hoc conflict resolution is worse than write-time detection by 18.7 points on Commit0-Lite. Existing frameworks have no native artifact primitive — they pass strings or serialize to JSON ad hoc.

Decision: Implement ArtifactStore, SectionLockManager, ArtifactCollaborator, and ArtifactCrew in src/grampus/orchestration/artifact/. Key design choices:

  1. Schema-first (Specification Gap): every artifact section has an explicit SectionSchema with description, content_type, and required_fields before any agent is assigned. Implicit specs are rejected at artifact creation time.

  2. MESI-inspired ownership states (Token Coherence, arXiv 2603.15183): UNOWNED → CLAIMED → REVIEWING → MERGED. Prevents any silent writes and converts synchronization cost from O(n×S×|D|) to O((n+W)×|D|).

  3. Write-time conflict detection (STORM): schema validation + dependency version check runs inside ArtifactStore.write_section() before persisting. Conflicts surface at write time, not post-hoc merge.

  4. TODO-claim via Dapr distributed lock (CodeCRDT, arXiv 2510.18893): atomic, at-most-one-winner section claiming reuses the existing Dapr lock primitive.

  5. Scoped per-agent context (CAID, arXiv 2603.21489): each agent receives only the artifact schema + its assigned section + one-line summaries of completed dependencies. Full artifact history is never passed, preventing error propagation across sections.

  6. Wave-based parallel execution: sections within the same topological wave execute concurrently via asyncio.gather. Integration checks run between waves.

Consequences: - Zero new required dependencies — Dapr lock already provided by the Dapr integration layer; all else is stdlib + existing Pydantic - ArtifactCrew(agents=[...]) is the primary API; artifact_node() enables single-section graph integration - Artifact.schema is immutable after creation; sections are mutable only through the claim/write/release lifecycle - Circular dependencies in section DAGs are detected at wave-build time via Kahn's algorithm with ArtifactConflictError(code="CIRCULAR_DEPENDENCY") - Content type validation is strict: JSON sections must pass required_fields check; TEXT/MARKDOWN accept any string; CODE sections accept any string - ArtifactConflictError and ArtifactSectionNotFoundError are top-level peers of OrchestrationError in the error hierarchy


ADR-016: Dual-Tier Agent Self-Improvement as a First-Class Runner Feature

Status: Accepted

Context: Agents repeat mistakes across sessions because each run starts from the same static system prompt with no memory of past failures. Reflexion (NeurIPS 2023) demonstrated that verbal self-reflection stored in persistent memory enables agents to improve without weight updates. The 2025 SAGE framework (arXiv 2512.17102) extended this by showing that extracting validated reusable skills from successes produces compounding improvement (+8.9% goal completion, 26% fewer steps). ME-ICPO (arXiv 2603.01335, March 2026) established a theoretical grounding for self-reflection as in-context policy optimization. No competitor framework has shipped both failure reflection and success skill extraction as built-in primitives.

Decision: Implement ReflexionEngine and SkillLibrary in src/grampus/memory/reflexion/ as optional hooks in AgentRunner. Three integration points: (1) post-failure hook generates and stores a verbal reflection, (2) post-success hook attempts skill extraction, (3) pre-LLM-call hook retrieves and injects relevant reflections + skills. The PromptOptimizer completes the loop by automatically proposing and evaluating system prompt mutations when an EvalSuite is available.

Key design choices: 1. Both hooks are opt-in and suppressed (reflexion_engine=None by default; all hooks wrapped in contextlib.suppress(Exception)) — self-improvement never crashes the core execution path. 2. Skill lifecycle (SAGE): new skills start unvalidated; promote to validated after ≥3 successful uses; demote below success_rate=0.4 after ≥5 uses; delete below 0.2. 3. Quality confidence for reflections (ME-ICPO): a second LLM call rates reflection quality on 0–1; low-quality reflections (< 0.3) are stored but not surfaced, preventing low-signal noise from polluting context. 4. ProceduralMemory reuse: skills and reflections are stored as Procedure records with procedure_type=SKILL/REFLECTION — no new Dapr key namespace, no new storage infrastructure.

Consequences: - Zero new required dependencies — stdlib only plus existing Pydantic, Dapr, OTEL - AgentRunner with reflexion_engine=None, skill_library=None (the defaults) is behaviorally identical to a runner without self-improvement - PromptOptimizer.optimize() calls EvalSuite N+1 times (1 baseline + N candidates) — only use on non-production agents or with fast/cheap model configs - SkillLibrary.run_sequential() enables SAGE-style batch improvement where skills from earlier tasks in a sequence accelerate later tasks


ADR-017: Three-Tier User Memory Hierarchy as a First-Class Memory Layer

Status: Accepted

Context: Agents currently have no persistent model of the individual user. Each session starts cold — the agent cannot remember that this user is a senior engineer who prefers concise answers and is currently migrating a legacy system. Single-layer key-value user profiles (e.g., {expertise: "high"}) fail in practice because: (1) facts become stale without temporal validity metadata (Beyond Dialogue Time, arXiv 2601.07468); (2) extracting facts from noisy conversations without a reflective correction pass amplifies hallucinations during clustering (Bi-Mem, arXiv 2601.06490); (3) a flat profile has no mechanism to promote actively-relevant facts above infrequently-accessed background context (HMO, arXiv 2604.01670).

Decision: Implement UserFact, UserProfile, UserMemoryStore, FactExtractor, and ProfileSynthesizer in src/grampus/memory/user/. The design uses three tiers: Tier 3 (UserEpisodes — raw interactions in existing EpisodicMemory), Tier 2 (UserFacts — extracted, temporally-grounded facts about the user), and Tier 1 (UserProfile — synthesized persona, rebuilt from facts every N new extractions). UserMemoryAdapter integrates both hooks into AgentRunner as opt-in, zero-crash additions.

Key design choices: 1. Temporal validity on every fact (Beyond Dialogue Time): each UserFact has valid_from and valid_until. Contradicted facts are expired rather than overwritten, preserving history. 2. Bidirectional construction (Bi-Mem): inductive agent (FactExtractor) works bottom-up; reflective agent (ProfileSynthesizer) works top-down. This prevents hallucination amplification. 3. Deduplication by cosine similarity before storing: existing facts with similarity > 0.90 get a confidence update (EMA) rather than a duplicate record. 4. Synthesis threshold (HMO): ProfileSynthesizer only fires every 10 new facts (configurable) — prevents thrashing on rapid-fire short sessions while ensuring the profile stays fresh. 5. Context injection is selective: get_context() uses cosine similarity to surface only the facts most relevant to the current query. The full UserFact list is never injected wholesale. 6. Zero behavioral change when disabled: user_memory_adapter=None (the default) means the AgentRunner behaves identically to an agent without user memory. user_id=None silently skips all hooks.

Consequences: - Zero new required dependencies — existing Dapr state store, embedding_service, and model_client - FactExtractor and ProfileSynthesizer each make 1 LLM call post-session; total overhead is 2 cheap LLM calls (temperature=0.2, max_tokens=400/300) per session end - UserFacts and UserProfile persist independently of the agent — the same user model is available to any agent that shares the same UserMemoryStore instance - user_id is explicit — there is no implicit user tracking; the caller must pass it


ADR-018: Graph-Structured Memory Consolidation and Lifecycle Tiers

Status: Accepted

Context: The existing four-layer memory system (working, episodic, semantic, procedural) treats memory as a flat store of records. Two failure modes emerge at scale: (1) flat vector search over tens of thousands of episodic records degrades in quality and speed — relevant concepts buried under irrelevant matches; (2) all records are treated equally regardless of how often they're accessed, wasting retrieval overhead on rarely-used cold memories. The 2026 research produced two complementary solutions. GAM (arXiv 2604.12285) showed that building a two-level knowledge graph — a transient event-progression-graph per session and a stable topic-associative-network triggered by semantic shift — improves reasoning accuracy on long-horizon tasks. MemOS (arXiv 2505.22101, May 2025) showed that managing memory as a hot/warm/cold lifecycle resource achieves 35.24% token savings in production. FluxMem (arXiv 2602.14038) showed that adaptive routing per query type — graph traversal vs. flat vector vs. sequential — outperforms any fixed retrieval strategy.

Decision: Implement two new sub-packages: src/grampus/memory/graph/ (GraphBuilder, SemanticConsolidator, GraphRetriever) and src/grampus/memory/lifecycle/ (LifecycleTierManager, AdaptiveRetriever). Both are additive enhancements — the existing four memory layers are unchanged. MemoryManager receives three optional params (graph_consolidator, lifecycle_manager, adaptive_router); when all three are None, behavior is identical to the flat memory system. AgentRunner receives one optional graph_builder param.

Key design choices: 1. Semantic-shift-triggered consolidation (GAM): the EventGraph integrates into the MemoryGraph only when cosine distance between current and last-consolidated state exceeds 0.30 — prevents transient noise from contaminating stable knowledge. Time-based consolidation is explicitly rejected. 2. Hot/warm/cold tiers map to existing infrastructure (MemOS): HOT = in-context (working memory), WARM = Redis cache (already in Dapr components), COLD = Postgres/Dapr state. No new infrastructure. 3. Adaptive routing is keyword-based, not ML-based (FluxMem inspiration): simple heuristics classify query type (sequential keywords → SEQUENTIAL; long queries or causal keywords → GRAPH; else → FLAT). This avoids adding an embedding call just to route queries. 4. SchematicMemory is implemented as tagged SemanticFacts not a new layer: ConceptNodes confirmed by

= 5 episodes with high frequency are tagged category="schematic" in SemanticMemory and always surfaced at the top of recall results. No new Dapr key namespace.

Consequences: - Zero new required dependencies — stdlib collections (for BFS deque), math, json, uuid, datetime, plus existing Pydantic, Dapr client, embedding_service, model_client - MemoryGraph is persisted as a single Dapr key per agent — no graph database required (adjacency list in JSON); revisit if graphs exceed 10K nodes per agent - SemanticConsolidator makes 1 LLM call per consolidation trigger; with semantic-shift gating this averages 1–3 calls per 30-minute session, not per event - LifecycleTierManager.sweep() should be called at session start to demote stale HOT records from the previous session — add to AgentRunner.run() pre-loop via contextlib.suppress


ADR-019: Two-Tier Causal Analysis — Trace Tracing + Lightweight SCM

Status: Accepted

Context: Agents have no mechanism to distinguish root causes from cascading effects in failures, and no persistent model of what actions cause what outcomes. Two distinct failure modes motivated this layer: (1) post-failure diagnosis — when an agent fails at step 8, it is non-obvious whether step 2 or step 6 caused it; cascading failures look identical to root failures in flat logs; (2) proactive intervention reasoning — agents cannot answer "what would have happened if I had skipped that tool call?" without re-executing. The Rung Collapse proof (arXiv 2602.11675) established that LLMs cannot perform causal inference natively. However, two 2026 papers showed practical paths that do not require solving the LLM-native causal reasoning problem: AgentTrace (arXiv 2603.14688, March 2026) showed that causal graphs reconstructed from execution logs localize root causes with sub-second latency and high accuracy without any LLM inference at debug time. Causal-aware LLMs (IJCAI 2025, arXiv 2505.24710) showed that LLMs as graph-labelers (not causal reasoners) combined with code-level do-calculus produces reliable interventional answers.

Decision: Implement a two-tier causal analysis layer in src/grampus/causal/: Tier 1 (CausalTracer) reconstructs causal graphs from the existing observability event log and diagnoses root causes post-hoc with no LLM inference. Tier 2 (CausalWorldModel) builds a persistent SCM the LLM populates during execution; SimpleCausalInference answers P(Y|do(X)) queries via pure-Python backdoor adjustment. Both tiers are additive opt-ins to AgentRunner — when both params are None, behavior is identical to an agent without causal analysis.

Key design choices: 1. LLM labels, code reasons — the LLM's job is only to identify and name causal relationships from text. SimpleCausalInference does all causal inference. This circumvents the Rung Collapse limitation entirely. 2. CausalTracer uses the existing event log (ADR-005) — no new storage infrastructure. Three edge types (sequential, data-dependency, failure-cascade) are reconstructed purely from log structure. 3. Root cause composite score = 0.6 × structural + 0.4 × positional, matching the AgentTrace and CHIEF signal weighting from the papers. 4. SimpleCausalInference is zero-new-deps — pure Python backdoor adjustment over small DAGs (< 200 variables). For larger graphs, the optional causal extras group can wrap DoWhy instead. 5. Tier 1 feeds Tier 2absorb_diagnosis() converts structurally validated causal chains from failure diagnosis into WorldModelGraph edges, giving the SCM ground-truth signal that bypasses LLM extraction uncertainty. 6. WorldModelGraph storage follows the MemoryGraph pattern (ADR-018): one Dapr key per agent, entity = "causal_world_model". No graph database required for typical agent world models.

Consequences: - Zero new required dependencies — stdlib re, uuid, math, collections.deque, json, contextlib, asyncio plus existing Pydantic, Dapr client, model_client - CausalTracer.diagnose() requires the observability event store to expose get_events_for_session(session_id, agent_id) -> list[dict]; if that method is not yet present on the event store, add it as part of this phase - SimpleCausalInference assumes a DAG; is_dag() should be checked before running intervene() on user-provided graphs; cyclic world models are silently handled by returning is_identifiable=False - The post-session failure hook in AgentRunner requires AgentState.last_event_id (optional field); if the event log does not surface this, the hook falls back to session_id as a proxy failure marker


ADR-020: Adversarial Red-Teaming as a First-Class Evaluation Primitive

Status: Accepted

Context: Agent safety testing in the industry is largely manual, expert-driven, and non-reproducible. Two developments in 2026 changed this calculus: (1) OWASP released the first dedicated Agentic Top 10 (ASI01–ASI10:2026), providing a standardised taxonomy for agent-specific attacks distinct from classic LLM jailbreaks; (2) automated red-teaming frameworks (AgenticRed arXiv 2601.13518, Dreadnode arXiv 2605.04019) demonstrated 85–100% attack success rates with sub-hour campaign execution, making manual red-teaming insufficient. Grampus has a uniquely rich attack surface: four memory layers (including the reflexion, user modeling, and graph memory additions), sandboxed code execution, multi-agent crews with A2A, and the causal world model — all of which are novel attack vectors not covered by classic LLM red-teaming.

Decision: Implement src/grampus/evaluation/red_team/ as a first-class evaluation primitive alongside the existing EvalSuite. Architecture: Attacker (generates payloads) → Target (Grampus agent under test) → Judge (evaluates success) with an optional mutation feedback loop for failed attempts. Six attack strategy implementations cover the highest-impact OWASP Agentic Top 10 categories. Every finding maps to both the OWASP category and one of the four security properties formalized in arXiv 2603.19469 (task alignment, action alignment, source authorization, data isolation).

Key design choices: 1. Strategy + Judge separation: strategies generate payloads deterministically (reproducible); the judge evaluates success with LLM + rule-based fallback. 2. target_fn decoupling: RedTeamRunner takes any async (messages) -> str callable, not an AgentRunner instance. This lets users red-team agents running as HTTP servers, not just local instances. 3. Rule-based judge always runs: even with LLM judge enabled, rule-based regex patterns provide a fallback when LLM confidence < 0.5 or when the model is unavailable. 4. One mutation retry: when a payload fails and a model_client is available, AttackerAgent generates one adaptive mutation (AgenticRed pattern) before recording the result. This doubles ASR on rule-based targets without significant overhead. 5. CLI exit code 1 on CRITICAL/HIGH: grampus redteam exits non-zero on high-severity findings, enabling CI/CD pipeline integration (block merges that introduce vulnerabilities).

Consequences: - Zero new required dependencies — all stdlib + existing Pydantic, structlog, model_client - grampus redteam agent.py requires the agent file to expose get_agent_config() and run_conversation(messages) — a thin adapter contract - The RedTeamRunner is intentionally decoupled from AgentRunner to avoid re-initializing Dapr and memory infrastructure for each attack payload; the target_fn handles that - Multi-turn attacks (ReasoningHijackStrategy) require the target_fn to maintain conversation state across the prior_turns list — stateless target_fns will see reduced multi-turn ASR


ADR-021: Document Processing Tools — Optional Extras with Graceful Degradation

Status: Accepted

Context: Agents need to ingest PDF, Word (.docx), and Excel (.xlsx) documents for RAG pipelines and episodic memory. The three document libraries (pymupdf4llm, python-docx, openpyxl) add ~50 MB to the install. Not every Grampus deployment needs document ingestion — a pure API agent, a CLI tool, or a code-generation agent has no use for these libraries and should not be penalized with extra install weight.

Decision: All document libraries live under pip install grampus-ai[documents] as optional extras. The three tool functions (read_pdf, read_docx, read_excel) check for their respective imports at call time and return ToolError(code="MISSING_DEPENDENCY") with a clear install hint when the extra is absent. The chunking layer (DocumentChunker) is pure Python and always available — agents can chunk arbitrary text without the extras installed.

Chunking strategy: Recursive chunking (2026 benchmark winner, 69% E2E accuracy over 50 papers) is the default. The context_header field stores the heading breadcrumb ("Title > Section > Sub") separately from content — embedding layers concatenate them for self-contained retrieval without polluting the stored text. Target: 512 tokens, FIXED strategy supports 10% overlap for sliding-window retrieval.

PDF reader priority: PyMuPDF (fitz) is preferred over pypdf because it is faster and handles complex layouts better. pypdf is the fallback when PyMuPDF is absent.

Consequences: - Core Grampus install stays lean; document-capable deployments add ~50 MB with [documents]. - [documents] is the established pattern for all future heavy optional dependency groups. - All three tool functions always return {"ok": bool, ...} — never raise; callers need no try/except. - Excel sheets are capped at 1000 rows to prevent runaway memory usage on large spreadsheets; truncation is noted in the chunk content. - The documents group is also included in [all] for convenience.


ADR-022: Code Analysis Tools — stdlib AST Engine + Subprocess Lint Runners

Status: Accepted

Context: Agents analyzing code need targeted structural queries, not raw file reads. Research (arXiv 2603.27277, Codebase-Memory, March 2026) demonstrated that structured code analysis tools reduce agent token usage by 10x and tool calls by 2.1x versus grep+file-read patterns. The tool surface must answer: "what's in this file?", "where is X defined?", "what are the lint issues?", "what are the type errors?"

Decision: Five tools built in two tiers: (1) pure-stdlib AST engine for symbol extraction, complexity, import analysis, and symbol search — zero dependencies; (2) subprocess thin wrappers around ruff and mypy — both already in the Grampus toolchain — with graceful degradation when not on PATH. No tree-sitter (binary dep, overkill for Python-first framework). No radon/lizard (cyclomatic complexity computed directly from ast.NodeVisitor in ~20 lines). No new entries in [project.dependencies].

Consequences: - All five tools work on any Grampus installation — no [analysis] extras required - Lint and type-check tools degrade gracefully: return ok with available=False + install hint - Subprocess runners are tested with mocked subprocess — integration against real ruff/mypy is handled implicitly by the existing CI which runs ruff and mypy on every push - Symbol search is O(files) — the 200-file default cap keeps it interactive-speed for typical repos


ADR-023: Multi-Provider Embedding Service with Per-Memory-Type Routing

Status: Accepted

Context: EmbeddingService was hardwired to a single OpenAI client. Three production problems motivated this change: (1) no way to use cheaper/faster local embeddings (Ollama) for low-stakes memory types like working memory while keeping a higher-quality model for semantic memory; (2) no way to use Cohere's domain-tuned Embed v3 models; (3) a silent dimension-mismatch bug — switching providers without updating the pgvector column dimensions silently drops all writes with no error, confirmed in multiple production incident reports (2025–2026). Additionally, Cohere Embed v3+ requires an input_type parameter ("search_document" vs "search_query") that the old single- provider API had no mechanism to expose — omitting it is a silent quality degradation.

Decision: Introduce EmbeddingProvider ABC with three concrete implementations (OpenAIEmbeddingProvider, CohereEmbeddingProvider, OllamaEmbeddingProvider). Refactor EmbeddingService to wrap any provider while preserving the existing .embed() / .embed_batch() interface exactly — all call sites are unchanged. Add .dimensions property to surface the provider's output dimension for pgvector validation at setup time, not at write time. Add EmbeddingRouter for optional per-memory-type provider routing (opt-in; existing code that passes a single EmbeddingService is unaffected). Add an optional input_type parameter to embed() and embed_batch() so Cohere's search_query / search_document distinction is correctly handled without leaking provider-specific concepts into callers that don't need it.

Consequences: - All existing .embed(text) call sites continue to work without modification - OllamaEmbeddingProvider uses httpx (already a core dep) — zero new required dependencies - OpenAIEmbeddingProvider and CohereEmbeddingProvider require their respective optional extras ([openai], [cohere]) - .dimensions property enables pgvector setup code to validate column width before the first write, converting the silent dimension-mismatch bug into a startup-time error - EmbeddingRouter is duck-type compatible with EmbeddingService for the three shared methods (.embed(), .embed_batch(), .dimensions), so it can be injected anywhere an EmbeddingService is accepted - Cache keys now include a provider name prefix — existing cached embeddings are invalidated on upgrade (acceptable: the cache is a performance optimisation, not ground truth)


ADR-024: Lifecycle Hook Plugin System — stdlib Entry Points with Async-Native Registry

Status: Accepted

Context: Production deployments of Grampus require observability integrations (Datadog, Splunk), compliance controls (PII redaction, audit logging, HIPAA content filtering), and cross-cutting concerns (rate limiting, cost allocation, canary routing) that cannot be baked into the core framework without creating vendor coupling. Three prior approaches were considered: (a) subclassing AgentRunner / MemoryManager — brittle, requires forking for each integration; (b) middleware wrapping via httpx-style transports — applies only to HTTP calls, misses in-process hooks; (c) event callbacks via asyncio.Queue — decoupled but no pre-hook mutation capability, no blocking support. None of these patterns cover the full lifecycle (start → LLM call → tool call → memory write → end → error) with both observational and mutating semantics.

Decision: Implement a src/grampus/plugins/ package providing a two-tier hook system: (1) pre-hooks (pre_llm_call, pre_tool_call, pre_memory_write) run sequentially in priority order, thread their return values as a transformation pipeline, and surface HookBlockedError as SafetyError/MemorySecurityError with code="PLUGIN_BLOCKED"; (2) observational hooks (on_agent_start, on_agent_end, post_llm_call, post_tool_call, post_memory_write, on_error) run concurrently via asyncio.gather, with individual plugin failures logged and suppressed — a broken plugin never crashes agent execution. Third-party plugins are discovered via importlib.metadata.entry_points(group="grampus.plugins").

Key design choices: 1. plugin_manager=None defaultAgentRunner and MemoryManager with no plugin manager are behaviorally identical to the plugin-free code path. Zero overhead for deployments that don't use plugins. 2. HookBlockedError is the only bubbling exception — all other plugin exceptions are suppressed in both pre-hooks (caught, logged, chain continues) and observational hooks (gathered, logged, suppressed). This asymmetry is intentional: mutations must succeed cleanly or be skipped, but observation failures must never crash the agent. 3. Frozen context dataclasses — all 7 context objects are @dataclass(frozen=True). Plugins receive read-only contexts; they cannot modify agent state through the context object. 4. Inline imports under TYPE_CHECKINGPluginManager appears only in TYPE_CHECKING blocks in runner.py and memory/manager.py; actual plugin types are imported inline inside if self._plugins: guards. This eliminates any circular import risk. 5. Priority controls sequential order — lower priority integer runs earlier in pre-hooks. Observational hooks use insertion order (priority-independence for concurrent dispatch). 6. GrampusPlugin base class with no-op hooks — subclasses override only the hooks they need; all others are silent pass-throughs by default.

Consequences: - Zero new required dependencies — stdlib importlib.metadata, asyncio, dataclasses only - Third-party plugins ship as Python packages with [project.entry-points."grampus.plugins"] in their pyproject.toml; create_manager_from_entry_points() loads them automatically - Pre-hook mutation (messages, tool arguments, memory content) enables compliance plugins to redact PII, inject system context, or rewrite arguments without modifying agent code - The HookBlockedErrorSafetyError(code="PLUGIN_BLOCKED") / MemorySecurityError(code= "PLUGIN_BLOCKED") mapping gives callers a machine-readable signal that is distinct from model errors, tool errors, and budget errors - contextlib.suppress(Exception) wraps all observational hook calls in runner and memory manager — plugin failures in on_agent_start, post_llm_call, etc. are logged but never surface to the caller


ADR-025: Content-Addressed Agent Versioning with Deterministic A/B Routing

Status: Accepted

Context: Agent definitions (system prompt, tools, temperature, model) change over time and teams need to track what was deployed when, roll back safely, and run controlled experiments. Three specific failure modes motivated this design: (1) without version identity, a prompt regression is invisible until users complain — there is no diff, no audit trail, and no rollback path; (2) existing A/B testing patterns require a separate routing service and database, creating operational overhead for a single toggle; (3) user assignment to A/B buckets is often non-sticky — the same user sees different agent behaviors on consecutive calls — which contaminates experiment results and degrades user experience.

Decision: Implement a self-contained versioning layer in src/grampus/versioning/ with four interlocking components:

  1. Content-addressed version IDscompute_version_id(definition) produces a deterministic SHA-256 over a canonicalized (key-sorted, tool-list-sorted) JSON representation of the AgentDefinition. The same definition always produces the same ID regardless of when or where it is created. Identical definitions are deduplicated at save time without special logic.

  2. Dapr-backed persistenceVersionStore stores versions and deployments via the existing DaprStateStore abstraction. Two internal Pydantic wrappers (_VersionIndex, _DeploymentHistory) store the per-agent version index and capped (50-entry) deployment history as first-class state entries, enabling resilient list_versions() that skips corrupt records rather than failing.

  3. Sticky deterministic A/B routingVersionRouter.resolve() assigns users to control or treatment by computing SHA-256(experiment_id:user_id) % 100 < int(split * 100). The hash is deterministic: the same user always lands in the same bucket for a given experiment, with no server-side session state required. The routing logic is wrapped in contextlib.suppress so a broken experiment never crashes agent execution.

  4. Pure-Python significance testingtwo_proportion_z_test (for eval pass rate) and welch_t_test (for continuous metrics) are implemented from scratch using stdlib math.erfc and Lentz's continued-fraction regularized incomplete beta function. No scipy dependency. Auto- promotion fires when p < auto_promote_threshold and both groups have >= min_samples runs.

Consequences: - Zero new required dependencies — stdlib hashlib, difflib, math, uuid plus existing Pydantic and Dapr client - AgentRunner gains one optional version_router parameter; default None means no behavioral change — all existing callers are unaffected - VersionRouter is duck-type injectable: any object with async resolve(agent_id, user_id) can be substituted in tests without importing the full Dapr stack - compute_version_id is pure (no I/O, no randomness) — identical inputs always produce identical outputs, making version IDs reproducible across environments and process restarts - Deployment history is capped at 50 entries per agent; older entries are silently dropped — acceptable since the audit trail in the Dapr event log (ADR-005) is the authoritative record - The welch_t_test fallback for continuous metrics (avg_cost_usd, avg_latency_seconds) does not store raw sample arrays, so it uses a 10%-difference heuristic rather than a true p-value; teams needing rigorous continuous-metric significance should call record_eval_result on a discretized pass/fail threshold and use the eval_pass_rate metric path instead


ADR-026: RAG Pipeline as a First-Class Demo Template

Status: Accepted

Context: RAG (Retrieval-Augmented Generation) is the highest-demand agent use case in production deployments. Every team building with Grampus needs to index documents and answer questions from them. Without a complete, working reference implementation, each team reinvents the same pipeline — often making the same mistakes: dense-only retrieval (misses keyword queries), IVFFlat indexing (requires training, poor incremental performance), context concatenation without position-aware ordering, and no citation grounding. A complete template that avoids these mistakes removes the most common adoption barrier.

Decision: Implement demos/rag/ as a production-ready RAG template using the full Grampus stack. Key design choices baked in from 2025-2026 research and benchmarks:

  1. Hybrid BM25 + vector search with RRF — PostgreSQL tsvector provides BM25 at zero additional infrastructure cost. RRF constant k=60 is the research-validated default.
  2. HNSW over IVFFlat — no training pass, better recall, handles incremental inserts.
  3. Lost-in-the-middle reordering — interleaves high-scoring chunks to start and end of context window based on 2023 Stanford findings replicated across 2024-2025.
  4. Namespace scoping as a hard requirement — every SQL query filters by namespace, preventing cross-tenant data leakage without application-level enforcement.
  5. Dimension mismatch detection at setup timeRAGStore.setup() checks existing table dimensions against the embedding service, converting a silent data corruption bug into a clear startup error.
  6. Closure-based tool factorymake_retrieve_tool() binds store and embedding service into the tool function without global state, demonstrating the correct pattern for stateful tools in Grampus.

Consequences: - Template uses asyncpg directly for pgvector operations — Dapr state store API does not expose arbitrary SQL needed for hybrid search. Added as [rag] optional extra. - Template works without Dapr sidecar (only PostgreSQL required) to minimize quickstart friction. Production deployments add Dapr for embedding caching via Redis. - demos/ is not type-checked by mypy (demo code, not library code). New RAGError in src/grampus/core/errors.py is the only library addition from this phase. - Evaluation script uses LLM-as-judge (Claude) — requires Anthropic API key. The scoring is intentionally simplified (2 metrics) to remain readable as a reference, not to replace a full RAGAS setup in production.


ADR-027: Dual-Version MCP Client with Stateless 2026-07-28 Support

Status: Accepted

Context: The MCP 2026-07-28 specification is the largest protocol revision since launch: a stateless core (no session IDs; protocol version, client info, and capabilities travel in _meta per request with Mcp-Method/Mcp-Name headers), Tasks redesigned as an official extension (io.modelcontextprotocol/tasks, SEP-2663) with polling via tasks/get instead of blocking, server/discover for capability discovery, response caching via ttlMs/cacheScope, and authorization hardening (RFC 9207 iss validation per SEP-2468; OIDC application_type in DCR per SEP-837). Servers on the finalized 2025-11-25 spec will coexist with 2026-07-28 servers for at least a year (deprecation policy SEP-2596 guarantees twelve months).

Decision: MCPClient negotiates the highest mutually supported spec version per server: pinned config value (GRAMPUS_TOOLS__MCP_SPEC_VERSION) > server/discover probe > best-effort initialize handshake > legacy fallback. Both wire formats live behind the unchanged public interface (list_tools(), invoke()); extension methods use the public rpc() helper which applies the negotiated conventions. The Tasks extension requestor (MCPTaskManager) persists task handles in Dapr state under a per-agent index, so pending long-running calls survive process crashes — resume_pending_tasks() reloads them and resumes polling with the server's pollInterval hint. invoke_as_future() surfaces long-running calls to the agent loop as awaitable asyncio tasks.

Consequences: - Callers are unaffected: negotiation is lazy (first call), cached per client instance, and degrades gracefully to legacy behavior against servers that implement neither discovery nor initialize (the pre-Phase-13 behavior) - Task-augmented calls against a legacy server raise ToolError(code="MCP_TASKS_UNSUPPORTED") rather than sending a task param the server would misinterpret - The exact _meta key layout targets the release candidate and must be re-verified against the final spec published July 28, 2026 — the wire-format code is isolated in _meta()/rpc() so a rename is a two-line change - TaskStateStore is a duck-typed Protocol, so tests run without a Dapr sidecar and any conforming store can back task persistence - No new dependencies — httpx and Pydantic only


ADR-028: Taint-Tracking Enforcement — Injection Containment Outside the Model

Status: Accepted

Context: Grampus's injection defense led with detection (regex + heuristic + semantic classifier). Detection is probabilistic and defeated by adaptive attacks; the 2024–2026 research consensus (CaMeL, Google DeepMind, IEEE SaTML 2026, arXiv 2503.18813; FIDES, Microsoft Research, May 2025) is to enforce security outside the model with a deterministic policy that mediates actions based on data provenance. Grampus already writes provenance labels with trust levels on every memory write (ADR-006) — the raw material for information flow control existed but never propagated through the agent loop.

Decision: Implement FIDES-style label propagation plus policy enforcement in src/grampus/safety/taint.py — not a full CaMeL capability interpreter (recorded decision: the interpreter's policy-authoring and approval-fatigue costs are not justified at v0.2). Design:

  1. TaintLabel wraps the existing provenance SourceType + trust level; conservative join — the lowest-trust label in a context wins, so an LLM output generated after EXTERNAL_DATA entered context inherits the untrusted label.
  2. TaintTracker keeps only the running join per context (O(1) memory, no per-value bookkeeping), reset per turn/session.
  3. TaintGuard is a deterministic reference monitor at the tool-call boundary: YAML rules map tools or tool classes (default egress = send_email/http_post/publish) to a minimum context trust. Tool→threshold lookups are precomputed for the <5ms safety budget. Violations block by default (default_action: escalate requests human approval instead) and emit a structured audit log line, an optional audit hook event, and grampus.taint.violation OTEL span attributes.
  4. SafetyPipeline integration: check_input records USER_INPUT taint, check_tool_result records TOOL_RESULT taint, and check_tool_call raises SafetyError(code="TAINT_BLOCKED") on violation — before the existing action guard runs. The regex/heuristic injection detector remains as defense-in-depth (secondary layer).

Consequences: - Injected instructions inside tool results deterministically cannot trigger egress-class tools — verified with AgentDojo-style payload fixtures and the detector disabled - taint_guard=None (default) is zero-overhead and behaviorally identical to pre-Phase-16 - Property-based tests (Hypothesis) verify the conservative-join invariant over arbitrary source mixes - LLM outputs are not auto-recorded as new taint: they inherit the context join (recording LLM_GENERATED unconditionally would block all egress after any model turn) - Code mode (Phase 14) composes at the same chokepoint: proxied tool calls from sandboxed scripts pass through the identical pipeline check - Zero new dependencies — PyYAML, Pydantic, and the existing OTEL API only


ADR-029: Code Mode — Programmatic Tool Calling Through a Reference-Monitor Proxy

Status: Accepted

Context: Round-tripping every tool call through the model puts every intermediate payload into model context. The strongest-evidenced harness pattern of 2025–2026 is the opposite: the model writes one script that orchestrates tools inside an execution context, and only the final (capped) output re-enters context. Anthropic reports 98.7% token reduction on a representative workflow (reported); Cloudflare Code Mode exposes 2,500 endpoints in ~1,000 tokens; independent replications report 53–92.8% reductions, workload-dependent. The pre-existing CodeExecutor injected raw tool callables into the namespace — bypassing ToolExecutor and every guard.

Decision: Implement CodeModeExecutor (src/grampus/tools/code_mode.py) with a hard security invariant: every tool invocation inside generated code proxies back through ToolExecutor and, when provided, the full SafetyPipeline — ActionGuard denylists, rate limits, taint policy (ADR-028), idempotency, and retries apply exactly as for a direct call. Design details:

  1. Execution model: the script runs on a dedicated daemon thread with a minimal builtins namespace and a wall-clock timeout; a runaway script is abandoned (daemon) rather than blocking interpreter shutdown. Sync proxies hop back into the event loop via run_coroutine_threadsafe — the RPC chokepoint where policy runs.
  2. Model-facing output is capped (max_output_bytes, truncation marker, truncated flag); intermediate tool outputs never leave the execution context.
  3. Typed stubs (generate_tool_stubs) are prompt-facing: signatures from ToolDefinition parameters (MCP tools) or introspected callables (local tools), docstrings from descriptions.
  4. Deferred loading: register(..., defer_loading=True) keeps tools out of to_definitions(include_deferred=False); the tool_search meta-tool (src/grampus/tools/tool_search.py) finds them by embedding similarity (lazy, cached per-tool vectors) and returns full definitions for expansion. deferred_summaries() gives the prompt cheap name+truncated-description hints.
  5. RunnerConfig.execution_mode: react | code | auto declares orchestration intent; react remains the default and pre-Phase-14 behavior is unchanged.

Evaluated alternative (recorded): a Starlark-style restricted interpreter (Bifrost pattern — no imports, no I/O, tool calls + control flow only) for untrusted deployments. Deferred; the daemon-thread model plus proxy-only namespace covers trusted/dev deployments, and the planned hardening step is a Docker RPC back-channel so generated code gains full ADR-007 isolation (network deny, read-only FS, resource limits) with the same proxy chokepoint.

Consequences: - A denied tool call from inside generated code is blocked identically to a direct call — verified by tests asserting the denied tool's side effect never ran - The 180 KB-intermediate-payload test proves the privacy property: only the final aggregate crosses back into model context (structural token benchmark: code mode < ReAct context) - Scripts get no open, no __import__, no exec — the namespace holds only safe builtins, print (buffered), and tool proxies; this is defense-in-depth, not a security boundary, until the Docker RPC backend lands - Zero new dependencies — stdlib threading, io, uuid plus existing Pydantic


ADR-030: Security-Gated Agent Skills Interop (SKILL.md)

Status: Accepted

Context: The Agent Skills open standard (agentskills.io, published December 18, 2025; adopted by 30+ products including OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, VS Code) standardizes what Grampus's procedural memory learned privately: a skill is a folder with a SKILL.md (YAML frontmatter name + description as the activation trigger, markdown body, optional scripts/). But third-party skills are an empirical supply-chain attack vector — Snyk ToxicSkills (Feb 5, 2026) found 36.8% of 3,984 scanned skills flawed, 13.4% critical, and 76 confirmed malicious payloads, most embedded as natural-language instructions in the SKILL.md body itself; the ClawHavoc campaign shipped 1,184 malicious skills. No mainstream framework gates skill import through a memory-security pipeline.

Decision: Implement src/grampus/memory/skills.py reusing the Phase 5 security machinery:

  1. Import is security-gated. Every skill passes MemoryValidator injection screening over frontmatter AND body, gets EXTERNAL_DATA provenance with a SHA-256 content hash, and is quarantined (recorded, never loaded, audit event emitted) on any spec or validation failure. Skill scripts/ execute only inside the Phase 6 sandbox, never on the host.
  2. Progressive disclosure. Only name + description are stored as a Procedure (procedure_type=SKILL); the body stays on disk and loads on demand via SkillLoader.load_body(), which re-verifies the import-time SHA-256 — MemorySecurityError(code="SKILL_TAMPERED") on mismatch converts post-import tampering into a hard failure.
  3. Export. procedure_to_skill_md() / export_skill() render learned Procedures as spec-compliant portable skill folders; validate_skill_directory() vendors the format checks (name pattern/length, description length, non-empty body). CLI: grampus memory export-skill <agent> <procedure_id> and grampus memory import-skills.

Consequences: - Grampus agents can consume the public skill ecosystem with a screening layer no mainstream framework provides, and learned procedures become portable artifacts usable in 30+ tools - An OWASP-style injected skill is quarantined at import; a skill modified after import fails hash verification at body-load time — both paths covered by tests - Optional embedding_service embeds name+description at import so ProcedureMatcher finds skills alongside learned procedures - Zero new dependencies — PyYAML, Pydantic, stdlib re/hashlib/pathlib


ADR-031: Context Editing and Recoverable Compaction for Working Memory

Status: Accepted

Context: Working memory offered truncate/summarize/hybrid. The field since late 2025 split "context editing" (surgically clearing stale tool results while keeping the action record) from "compaction" (whole-history summarization, now available server-side from Anthropic — reported 39%/29% agentic-search improvements and 84% token reduction on a 100-turn eval). Two hard lessons shape the design: (1) compaction that drops a tool_use record while keeping its tool_result produces orphaned tool_use_id references that crash provider API validation — a documented ecosystem failure mode; (2) irreversible retain/discard decisions cause failures because you cannot know at eviction time which tokens future turns need, so evicted content must stay recoverable.

Decision: Four additions, all opt-in:

  1. CLEAR_TOOL_USES strategy (SummarizationStrategy.CLEAR_TOOL_USES): deterministic, no LLM call. Replaces old tool-result payloads with placeholders naming a recovery reference; keeps every message and every tool_use record, so pairing cannot break structurally. The N most recent tool results stay full-fidelity; optional clear_thinking strips metadata["thinking"] from older turns.
  2. Pairing-integrity invariant enforced across strategies. TRUNCATE now drops any tool-result message whose tool_use record was truncated away (_drop_orphaned_tool_results). Property-based tests (Hypothesis, random conversation shapes) guard the invariant for both strategies — written before the implementation, per the known failure mode.
  3. Recoverability: make_expand_context_tool(working_memory) builds an agent-facing expand_context(tool_call_id=...) tool that recovers cleared payloads from the full uncompressed history WorkingMemory already persists to Dapr state — the audit log becomes a recoverable context store.
  4. Server-side compaction passthrough (CompactionConfig on AnthropicClient): beta header compact-2026-01-12, compact_20260112 edit with trigger/instructions/ pause_after_compaction; trigger validated >= 50_000 at construction (API minimum); unsupported models rejected with COMPACTION_UNSUPPORTED_MODEL unless allow_unsupported_model=True (the supported list — currently Opus/Sonnet 4.6 — will evolve faster than this client). Usage accounting sums across usage.iterations. Grampus's own Summarizer remains the provider-agnostic fallback.

Consequences: - Context strategies are configuration per model tier (ModelRouter(context_strategies=...)), never hardcoded per model — model-specific workarounds rot across generations (the Managed Agents lesson) - ModelRouter.clients became optional (defaults to {}) to allow config-only construction - Suite B integration test exists but is doubly gated (RUN_COMPACTION_TESTS) because triggering compaction needs >=50K input tokens per call — most of the nightly budget - Zero new dependencies


ADR-032: Trajectory Evaluation — pass^k, World-State Grading, Hardened Judges

Status: Accepted

Context: Output-only scoring misses looping, wrong-tool-then-recover, inefficiency, and — worst — false success: agents confidently claim completion without doing the work, and an LLM judge reading the transcript is convinced by the closing prose (arXiv 2606.09863). A single-run pass rate also hides reliability problems: an agent that solves a task 70% of the time looks fine at pass@1 but fails pass^3 two-thirds of the time (tau-bench, Sierra).

Decision: Three additions to the evaluation layer:

  1. Trajectory assertions (evaluation/assertions.py): world_state (grade the final environment — Dapr state, files, DB — via a sync/async callable, independent of transcript), tool_sequence (ordered subsequence or unordered set match), step_efficiency (steps vs annotated minimum × tolerance), loop_detected (negative: N identical consecutive tool calls, same name AND arguments).
  2. Reliability metrics (evaluation/suite.py): EvalSuite(repeat_k=K) runs each case K times; SuiteResult reports pass@k (any attempt) and pass^k (every attempt — distinct from pass@k) per case and suite. gating_pass_rate feeds grampus eval --fail-under: pass^k when repeated, else pass rate — CI gates on reliability, not luck.
  3. Hardened judge modes (evaluation/judge.py): blind | checklist (verify concrete per-case criteria before any verdict) | tool_only (agent prose stripped; the judge sees only the user request + tool calls/results, so the agent's own closing message cannot vouch for it). Malformed judge output fails closed. cost_hook wires judge spend into CostTracker. The pre-existing scoring-style llm_judge assertion is unchanged; the new factory is judge_assertion.

Consequences: - The false-success pattern is reproducible in tests: a fixture whose closing message claims success passes under blind and fails under tool_only with the same fake model - A seeded 70%-success agent measures pass@3 ≈ 0.97 vs pass^3 ≈ 0.34 in the test suite — the visibility gap the metric exists to expose - Judge limits (bias, non-determinism, cost) are documented in the module: offline on samples, never per-production-turn - Zero new dependencies


ADR-033: OTel GenAI Semantic Conventions — Dual Emission Behind a Flag

Status: Accepted

Context: Grampus emits custom span names (agent.run, agent.llm_call, …). The ecosystem standardized on the OTel GenAI semantic conventions (gen_ai.*), consumed natively by Datadog, New Relic, Honeycomb, and MLflow. But the GenAI and MCP conventions are still Development status (v1.36 transition baseline; OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental opts into newer revisions) — attribute names can change, so adoption must be reversible.

Decision: GrampusTracer(genai_semconv=True) dual-emits: each operation opens a gen_ai-named span (invoke_agent {agent} with gen_ai.agent.name; chat {model} CLIENT-kind with request.model/usage tokens/finish_reasons; execute_tool {tool} with gen_ai.tool.name plus mcp.* attributes for MCP-backed tools) wrapping the unchanged legacy agent.* span. Flag off (default) is byte-identical to pre-Phase-20 output. agent.memory_read/memory_write /decision have no gen_ai equivalent and remain custom-only — spec-compliant. Metrics: gen_ai.client.operation.duration (required) and gen_ai.client.token.usage histograms in the Prometheus exposition; callers must pass billable tokens from the API, never estimates. Content capture (gen_ai.input/output.messages, tool I/O) is opt-in and routes through PIIDetector redaction when configured; the preferred production pattern remains external storage with a reference attribute.

Consequences: - Grampus traces render as first-class agent traces in every semconv-aware backend while the legacy dashboards keep working — nothing to migrate - One extra span per operation when the flag is on (nesting preserves the invoke_agent → inference/execute_tool tree the conventions expect) - _tracer injection enables in-memory-exporter testing without global provider mutation - Zero new dependencies — the OTel API/SDK were already core deps


ADR-034: Bi-Temporal Validity Windows on Semantic Facts

Status: Accepted

Context: SemanticFacts resolved conflicts by confidence-weighted replacement — history was destroyed and the system could not answer "what was true in Q1". Temporal knowledge modeling is the measured differentiator in memory systems: Zep's bi-temporal Graphiti scores 63.8% on LongMemEval vs Mem0's 49.0% with GPT-4o, a gap driven specifically by temporal reasoning (arXiv 2501.13956). Separately, long-horizon memory failure modes are documented and unsolved (arXiv 2603.10062): drift, hallucinated recall, and bias propagation need named regression coverage, and tokens-per-recall became the headline cost metric.

Decision:

  1. Schema: SemanticFact gains valid_from, valid_until (None = currently valid), and superseded_by. Conflict handling in SemanticMemory.store() changes from replacement to window-closing: a new conflicting value closes the open window (valid_until = incoming valid_from, superseded_by = incoming id) and both facts are retained. Identical values still merge (deduplication unchanged). Only the open-window fact participates in conflict detection, so supersession chains form naturally.
  2. Retrieval: SemanticRetriever.retrieve_by_subject/retrieve_by_predicate gain as_of: datetime | None; default returns currently-valid facts, a datetime answers point-in-time queries against the windows (fact_valid_at helper).
  3. Failure-mode regression suite (named categories): drift (repeated identical consolidation must not mutate values or duplicate records), bias propagation (repetition must not inflate confidence), hallucinated recall (every recalled record's content hash must match its write-time provenance SHA-256 — uniquely checkable in Grampus because ADR-006 hashes every write).
  4. Benchmark harness (evaluation/memory_benchmark.py): drives MemoryManager remember/recall over LongMemEval-format dataset files; reports accuracy + mean tokens-per-recall. Grading is substring match (the reproducible LLM-free subset); methodology docs forbid comparing against vendor numbers without matched conditions.

Consequences: - "User moved from London to Tokyo": as_of=Q1 returns London, current recall returns Tokyo, both records retained with correct windows — the scenario is a test - Two legacy dedup tests encoding confidence-weighted replacement were updated to the bi-temporal contract (intended behavior change of this phase) - Storage grows with supersession history; acceptable — facts are small and the event log (ADR-005) already retains full history elsewhere - Zero new dependencies


ADR-035: A2A Inbound Thin Slice — Canonical Well-Known Path + REMOTE_AGENT Trust Tier

Status: Accepted

Context: A2A v1.0 (first stable spec, April 2026, Linux Foundation, 150+ supporting organizations) settled the architecture: MCP for vertical tool integration, A2A for horizontal agent-to-agent coordination across trust boundaries. Grampus already ships the outbound A2A client, executor, registry, and JSON-RPC inbound endpoint (orchestration/a2a/, D-phase work). Two gaps remained for the v0.2 thin slice: the canonical v1.0 well-known path, and — more importantly — a trust model for inbound task content. A remote agent's request is USER-adjacent but crosses a trust boundary; treating it as USER_INPUT (0.9) would let a compromised or malicious peer trigger egress tools, while EXTERNAL_DATA (0.3) would be needlessly paranoid for an authenticated peer.

Decision:

  1. SourceType.REMOTE_AGENT (trust 0.5) — between TOOL_RESULT (0.6) and EXTERNAL_DATA (0.3). GrampusA2AExecutor gains an optional taint_guard; every inbound task records REMOTE_AGENT taint before the runner executes (wrapped in contextlib.suppress — a broken guard never fails a task). Under the default egress policy (min_trust 0.9), inbound A2A content deterministically cannot trigger send_email/http_post/publish without human approval — ADR-028 containment extends across the agent-to-agent boundary.
  2. Canonical path: /.well-known/agent.json now serves the Agent Card alongside the pre-existing /.well-known/agent-card.json; cards always carry a skills list (protobuf serialization drops empty repeated fields — normalized in the route) and, without a registry, skills derive from the agent's tools.
  3. Deferred (watch items): outbound Signed Agent Card verification, AP2 payments extension, the Q3 2026 MCP/A2A joint interoperability spec, and multi-tenant card scoping (Part II P14).

Consequences: - External A2A clients (LangGraph, ADK, Copilot Studio) discover and invoke Grampus agents at the canonical v1.0 path; SSE streaming and task lifecycle were already in place - taint_guard=None (default) keeps the executor behaviorally identical to pre-Phase-21 - Zero new dependencies — the a2a-sdk remains optional ([a2a] extra)


ADR-036: Multi-Repo Segregation — Decided Now, Executed Later

Status: Accepted (execution deferred)

Context: The remaining roadmap contains work that is not framework code: a hosted SaaS (P15), deployment infrastructure (P1 Helm chart, P2 azd/Bicep, Grafana dashboards), a TypeScript SDK (P9), a React SPA (P16), benchmark harnesses with datasets and published results (P5, P6's measurement half), and community template content. Keeping these in the framework repo would drag in foreign toolchains (Node/npm for the SDK and SPA), private code (SaaS), high-churn artifacts (datasets, dashboards, results), and coupled release cadences. ADR-011 previously permitted exactly one in-repo frontend build step for the Visual Agent Builder — a compromise that a multi-repo layout makes unnecessary.

Decision: Full split, recorded now, executed when the respective phase starts. This repo remains the pure-Python framework (core, dapr, memory, tools, orchestration, safety, observability, evaluation, CLI, server incl. the HTMX /ui/ shell, docs site).

Future repo Takes From phases
grampus-cloud (private) SaaS control plane, Stripe billing, tenant management, hosted UI P15
grampus-infra Helm chart, azd/Bicep templates, K8s manifests, Grafana dashboards, docker-compose profiles P1 (chart half), P2, grafana/
grampus-sdk-ts @grampus/sdk TypeScript SDK, OpenAPI type generation, npm CI P9
grampus-builder Visual Agent Builder React SPA; publishes prebuilt static assets the framework serves at /ui/builder/ P16
grampus-benchmarks GAIA/AgentBench/τ-bench harness, datasets, published results, load-test scripts P5, P6 (measurement half)
grampus-templates Hub gallery content (agent templates), demos Tier 3 seeding, demos/

Boundary rules: - grampus deploy (the CLI command generating manifests/values) stays in the framework; the chart it targets lives in grampus-infra - P14 multi-tenancy primitives (TenantContext, tenant-scoped stores) stay in the framework; grampus-cloud consumes them as a dependency - P6 framework-side performance fixes stay here; the measurement harness moves out - Supersedes ADR-011's build-step exception: the builder's Node build step moves to grampus-builder entirely; the framework only serves prebuilt assets. No frontend build step ever runs in this repo.

Consequences: - The framework repo's uv sync + uv run pytest remains the complete developer loop — no Node, no Docker registries, no datasets - Phase specs for P1/P2/P5/P9/P15/P16 remain in PLAN.md as the source of truth until each split executes; their headers carry destination annotations - Cross-repo versioning: satellite repos pin a minimum grampus-ai version; the framework never depends on satellites - Release cadences decouple — a dashboard tweak or new template no longer implies a PyPI release


ADR-037: Typed Structured Outputs — Provider-Guaranteed Where Possible, Validated Everywhere

Status: Accepted

Context: Every mainstream framework comparison leads with typed outputs (Pydantic AI's output_type=MyModel, LangChain's with_structured_output), yet Grampus — Pydantic v2 throughout — returned untyped result.output: str. Provider support matured to guarantees: Anthropic's GA structured outputs (output_config.format, no beta header) compile the schema to a grammar and constrain decoding; strict: true on tool definitions guarantees schema-valid tool arguments; OpenAI's response_format json_schema + strict does the same. Verified against current docs (July 2026); anthropic SDK bumped to >=0.116 for the GA parameter.

Decision: src/grampus/core/output.py OutputSpec compiles a Pydantic model or raw JSON Schema to (a) Anthropic output_config.format, (b) OpenAI response_format, (c) a JSON- instruction prompt block for providers without native support. Design points:

  1. Capability matrix: ModelClient.structured_output_support: native | tool_based | none (Anthropic/OpenAI = native; base default = none). The runner passes the wire param to native clients and injects the prompt block for the rest.
  2. Schema compatibility validator (Anthropic limits are the strictest superset): every object gets additionalProperties: false and all properties listed in required (constrained decoding always emits every field); recursive schemas are rejected (OUTPUT_SCHEMA_RECURSIVE); numeric/string-length constraints are stripped with a warning. Grammar scope is the final output only — tool-use and thinking turns are unaffected, so the spec rides along on every loop turn safely.
  3. Validate-and-retry (the Pydantic AI ergonomic, safety net on all tiers): on parse or validation failure post-loop, errors are fed back to the model up to RunnerConfig.output_retries (default 2); each retry flows through CostTracker and usage accumulation. A user output_validator may raise ModelRetry with a message the model sees. Exhaustion raises OutputValidationError(code="OUTPUT_RETRIES_EXHAUSTED").
  4. Public API: AgentDefinition.output_type (class serialized as its JSON Schema — dict schemas round-trip, classes degrade to schemas by design), AgentDefinition.output_validator (excluded from serialization), ExecutionResult.output_parsed, ToolDefinition.strict (provider passthrough with schema strictification; executor-side validation stays as defense in depth). output_type=None is behaviorally identical to pre-P17.
  5. Evaluation: output_type_valid(Model) assertion validates output_parsed (or parses raw output) against an expected model.

Consequences: - Parity with Pydantic AI's core ergonomic, plus provider-native guarantees where available (which Pydantic AI also relies on) - Schemas cost ~50–200 system-prompt tokens and are cached server-side up to 24h — documented: no sensitive data in schema property names/enums - Forcing all-properties-required changes semantics for defaulted fields (constrained decoding always emits them) — standard practice for strict modes, noted in docs - Suite B tests cover the native path, strict-tool round trip, and runner end-to-end (gated)


ADR-038: MemoryGraph Storage at Scale — Structure in One Key, Embeddings Out-of-Line

Status: Accepted (refines ADR-018)

Context: ADR-018 stored the per-agent MemoryGraph as a single Dapr key (JSON adjacency list) with an explicit revisit threshold: "if graphs exceed 10K nodes per agent." P6 measured that threshold (scripts/benchmark_performance.py, 10K nodes / 20K edges): structure-only graphs serialize to 6.4 MB in 0.03 s with 1.5 ms full-BFS traversal — comfortably fine. But inlining node embeddings breaks the design: 53 MB at 256 dims, 289 MB and a 1.74 s deserialize at production 1536 dims — not viable in a single state-store value.

Decision: Keep the single-key adjacency-list design for graph structure up to and beyond 10K nodes. Node embeddings must not be inlined at scale: beyond ~1K nodes with real embedding widths, embeddings belong in the configured vector store (the Phase D27 pgvector/Pinecone/Qdrant/Weaviate adapters), keyed by node_id, with embedding=None in the persisted graph. In practice the SemanticConsolidator's node-merging keeps graphs far below the threshold, so this is recorded guidance plus a follow-up (embedding-offload flag on SemanticConsolidator.save_graph) rather than an immediate code change.

Also recorded from the same run — the Phase 12a framework-overhead number: an AgentRunner turn adds ~0.09 ms over a raw model call (p95 0.108 ms) — below 0.01% of any real LLM call. Published in docs/guides/performance.md.

Consequences: - No behavior change today; the benchmark script is repo-tracked and reproducible - Serve-mode load testing (QPS) moves to grampus-benchmarks per ADR-036


ADR-039: Consolidated Repo Layout — Amends ADR-036

Status: Accepted (amends ADR-036)

Context: ADR-036 recorded a full six-repo split, deferred until each phase starts. Two facts prompted a revision before any split executed. First, the wheel build already isolates the package: [tool.hatch.build.targets.wheel] packages = ["src/grampus"] means top-level directories (helm/, benchmarks/, azure/, …) never ship to PyPI — repo contents and package contents are independent, so "keep the repo pure for the wheel's sake" was never a real constraint. Second, for a solo maintainer pre-launch, six repos mean six issue trackers, a cross-repo version-pinning matrix, and coordinated releases for changes that are naturally atomic (a server endpoint change plus its infra/bench updates). The genuine ADR-036 motives — foreign toolchains and private code — apply to only three of the six satellites.

Decision: Three-way layout, replacing the six-repo plan:

  1. This repo absorbs the toolchain-free satellites. The Helm chart + K8s manifests (P1, now whole again: chart and CLI together), the azd/Bicep one-click deploy (P2), the Grafana dashboards, the benchmark harness + results (P5 and P6's measurement half), and the hub template gallery content + demos. All live in top-level directories outside src/grampus/ — zero effect on the wheel; uv sync + pytest remains the Python dev loop with path-filtered CI jobs for the rest.
  2. grampus-cloud stays a separate private repo (P15) — private code, billing secrets, different license. Non-negotiable regardless of layout. Consumes P14 tenancy primitives from the framework as a dependency, as before.
  3. The two Node citizens defer their layout decision to their start date. The TypeScript SDK (P9) and the Visual Agent Builder (P16) are the only components that would bring npm/vite into a repo. Default expectation: one shared JS repo (grampus-js) or two separate repos, decided when the first of them begins — after P13 freezes the API. The builder continues to ship prebuilt assets consumed by the framework (ADR-036's supersession of the ADR-011 build-step exception stands).

Web UI positioning (recorded here because it shapes P3 scope): the built-in HTMX /ui/ stays in the framework and in the wheel (88 KB, server-rendered Python, capability gated by the [server] extra). Its role is the zero-setup operations console — memory inspection/deletion, HITL approvals, alert rule management, cost budgets, first-run demos, air-gapped deployments — not deep observability. Trace exploration, span waterfalls, and eval experiment comparison are delegated to OTel backends (Phoenix, Grafana, Datadog — first-class via ADR-033 gen_ai semconv dual emission and the existing Phoenix integration). P3 is scoped accordingly: the alerts page is full-featured (control plane); the traces page is a thin consolidation of the existing standalone viewer for quick session debugging, with docs pointing power users at OTel backends. The trace page should not grow further.

Consequences: - P1, P2, P5 return to this repo's dev queue; the ADR-036 destination annotations on those phases are void; grafana/ gets tracked - Release cadence coupling for in-repo infra/bench content is accepted — a chart tweak no longer implies a PyPI release because tags drive releases, not merges - Benchmark datasets use fetch-on-demand or Git LFS if they grow beyond a few MB - ADR-036's boundary rules otherwise stand (framework never depends on satellites; satellites pin a minimum grampus-ai version)