Skip to content

Latest commit

Β 

History

History
654 lines (525 loc) Β· 13.4 KB

File metadata and controls

654 lines (525 loc) Β· 13.4 KB

MCG Context & State Management - AI Agent Reference

Focus: Deep dive into MCG's context system, patch-based updates, and state management


🧠 Context Architecture

The Ctx Type

type Ctx<G = unknown, I = unknown, S = unknown> = Readonly<{
  // === Configuration & Environment ===
  global: G;              // Graph-level config (immutable across runs)
  injected: I;            // Per-run parameters (request-specific)
  services: S;            // Infrastructure adapters (secrets, LLM, vectors)
  
  // === State Storage ===
  data: Record<string, unknown>;              // Shared key/value store
  node: Record<string, Record<string, unknown>>;  // Node-local namespaces
  
  // === Execution Context ===
  now: Date;              // Deterministic timestamp
  version: number;        // Increments on each commit
  
  // === Audit Trail ===
  journal: Patch[];       // Append-only patch log (immutable updates)
  events: Event[];        // Append-only event log (domain events)
  
  // === Side Effects ===
  idempotency: Record<string, string>;  // Idempotency keys for external calls
  
  // === Observability ===
  metrics?: {
    traversals: number;
    errors: number;
    byNode: Record<string, number>;
  };
}>;

Context Layers Explained

1. Global State (ctx.global)

  • Purpose: Graph-level configuration that never changes
  • Examples: API endpoints, feature flags, tenant ID, environment
  • Lifecycle: Set once at graph creation, immutable
  • Access: Read-only throughout execution
type MyGlobal = {
  apiUrl: string;
  tenantId: string;
  featureFlags: Record<string, boolean>;
};

const graph = new ManagedCyclicGraph<Ctx<MyGlobal>>('my-graph');
const manager = new Manager(graph);

await manager.start({
  initialContext: {
    global: {
      apiUrl: 'https://api.example.com',
      tenantId: 'tenant-123',
      featureFlags: { newFeature: true }
    }
  }
});

2. Injected Parameters (ctx.injected)

  • Purpose: Per-run request-specific data
  • Examples: User ID, request ID, session info, overrides
  • Lifecycle: Set at run start, immutable during run
  • Access: Read-only throughout execution
type MyInjected = {
  userId: string;
  requestId: string;
  sessionToken: string;
};

await manager.start({
  initialContext: {
    injected: {
      userId: 'user-456',
      requestId: 'req-789',
      sessionToken: 'token-abc'
    }
  }
});

3. Services (ctx.services)

  • Purpose: Infrastructure adapters (secrets, LLM, vectors, etc.)
  • Examples: OpenAI client, vector DB, secrets manager
  • Lifecycle: Injected via plugins or initialContext
  • Access: Available throughout execution
type MyServices = {
  secrets: SecretsAdapter;
  vectors: VectorAdapter;
  embeddings: EmbeddingAdapter;
  llm: LLMAdapter;
};

// Via plugin
const servicesPlugin = {
  name: 'my-services',
  services: {
    secrets: mySecretsAdapter,
    vectors: myVectorAdapter,
    embeddings: myEmbeddingAdapter
  }
};

const graph = new ManagedCyclicGraph('my-graph')
  .use(servicesPlugin as any);

// Via initialContext (alternative)
await manager.start({
  initialContext: {
    injected: {
      services: {
        secrets: mySecretsAdapter,
        vectors: myVectorAdapter
      }
    }
  }
});

4. Data Store (ctx.data)

  • Purpose: Shared mutable state across all nodes
  • Examples: User input, computed results, workflow state
  • Lifecycle: Starts empty or with initial values, modified via patches
  • Access: Read anywhere, write via propose()
// Reading
const userName = ctx.data.user?.name as string;
const results = ctx.data.searchResults as any[];

// Writing (via propose)
return propose(ctx, {
  op: 'set',
  path: 'data.user.name',
  value: 'John Doe',
  ts: Date.now(),
  by: 'myAction'
});

5. Node-Local State (ctx.node)

  • Purpose: Namespaced state per node (avoids collisions)
  • Examples: Node-specific counters, temporary state
  • Lifecycle: Persists across node visits
  • Access: ctx.node[nodeId].*
// Reading
const visitCount = ctx.node.myNode?.visitCount as number || 0;

// Writing
return propose(ctx, {
  op: 'set',
  path: 'node.myNode.visitCount',
  value: visitCount + 1,
  ts: Date.now(),
  by: 'myNode'
});

πŸ”„ Patch System (Immutable Updates)

Why Patches?

MCG uses an append-only patch journal instead of direct mutations for:

  • βœ… Immutability: Context is never mutated directly
  • βœ… Audit trail: Full history of all changes
  • βœ… Time travel: Can replay to any point
  • βœ… Debugging: See exactly what changed and when
  • βœ… Concurrency: Safe for parallel execution

Patch Operations

type Patch = {
  op: 'set' | 'del' | 'inc' | 'push-uniq';
  path: string;           // Dot-notation path
  value?: unknown;
  ts: number;             // Timestamp
  by: string;             // Node/action ID
};

1. SET - Set a value

// Set a simple value
propose(ctx, {
  op: 'set',
  path: 'data.userName',
  value: 'Alice',
  ts: Date.now(),
  by: 'setUserAction'
});

// Set a nested object
propose(ctx, {
  op: 'set',
  path: 'data.user',
  value: { name: 'Alice', age: 30 },
  ts: Date.now(),
  by: 'setUserAction'
});

// Set a nested property
propose(ctx, {
  op: 'set',
  path: 'data.user.email',
  value: 'alice@example.com',
  ts: Date.now(),
  by: 'setEmailAction'
});

2. DEL - Delete a value

propose(ctx, {
  op: 'del',
  path: 'data.temporaryData',
  ts: Date.now(),
  by: 'cleanupAction'
});

3. INC - Increment a number

// Increment counter
propose(ctx, {
  op: 'inc',
  path: 'data.retryCount',
  value: 1,
  ts: Date.now(),
  by: 'retryAction'
});

// Decrement (negative value)
propose(ctx, {
  op: 'inc',
  path: 'data.remainingAttempts',
  value: -1,
  ts: Date.now(),
  by: 'attemptAction'
});

4. PUSH-UNIQ - Add unique item to array

// Add to array (only if not already present)
propose(ctx, {
  op: 'push-uniq',
  path: 'data.visitedNodes',
  value: 'nodeA',
  ts: Date.now(),
  by: 'trackVisitAction'
});

Using propose()

import { propose } from '@quarry-systems/managed-cyclic-graph';

// In an action
const myAction: Action = {
  id: 'my-action',
  run: async (ctx) => {
    // Compute new value
    const result = await computeSomething(ctx.data.input);
    
    // Propose the change
    return propose(ctx, {
      op: 'set',
      path: 'data.result',
      value: result,
      ts: Date.now(),
      by: 'my-action'
    });
  }
};

Multiple Patches

You can chain multiple propose() calls:

run: async (ctx) => {
  // First patch
  let updatedCtx = propose(ctx, {
    op: 'set',
    path: 'data.step1',
    value: 'complete',
    ts: Date.now(),
    by: 'myAction'
  });
  
  // Second patch
  updatedCtx = propose(updatedCtx, {
    op: 'inc',
    path: 'data.stepCount',
    value: 1,
    ts: Date.now(),
    by: 'myAction'
  });
  
  return updatedCtx;
}

Commit Process

You don't call commit() manually - the Traverser handles it:

  1. Action returns context with patches in journal
  2. Traverser calls commit(ctx) automatically
  3. Patches are applied to context data
  4. Version is incremented
  5. Updated context flows to next node
// Internal Traverser logic (you don't write this)
let ctx = await action.run(ctx);
ctx = commit(ctx);  // Traverser does this

πŸ“ Event System

Event Structure

type Event = {
  id?: string;
  type: string;           // Event type (e.g., 'UserRegistered', 'PaymentProcessed')
  payload?: unknown;      // Event-specific data
  ts: number;             // Timestamp
  by: string;             // Node/action that emitted
};

Emitting Events

Events are added to ctx.events array (append-only):

run: async (ctx) => {
  // Emit domain event
  const event: Event = {
    type: 'UserRegistered',
    payload: { userId: 'user-123', email: 'user@example.com' },
    ts: Date.now(),
    by: 'registerAction'
  };
  
  // Add to events array
  return propose(ctx, {
    op: 'push-uniq',
    path: 'events',
    value: event,
    ts: Date.now(),
    by: 'registerAction'
  });
}

Reading Events

// Get all events
const allEvents = ctx.events;

// Filter by type
const registrationEvents = ctx.events.filter(e => e.type === 'UserRegistered');

// Get latest event
const latestEvent = ctx.events[ctx.events.length - 1];

πŸ” Idempotency

Purpose

Idempotency keys ensure external side effects (API calls, payments, etc.) are not duplicated on retries.

Usage

run: async (ctx) => {
  // Generate idempotency key
  const idempotencyKey = `${ctx.injected.requestId}-payment`;
  
  // Check if already executed
  if (ctx.idempotency[idempotencyKey]) {
    console.log('Payment already processed, skipping');
    return ctx;
  }
  
  // Execute side effect
  await externalPaymentAPI.charge({
    amount: 100,
    idempotencyKey
  });
  
  // Record execution
  return propose(ctx, {
    op: 'set',
    path: `idempotency.${idempotencyKey}`,
    value: Date.now().toString(),
    ts: Date.now(),
    by: 'paymentAction'
  });
}

πŸ“Š Metrics

Structure

metrics?: {
  traversals: number;              // Total edge traversals
  errors: number;                  // Total errors
  byNode: Record<string, number>;  // Visit count per node
}

Automatic Tracking

The Manager automatically tracks:

  • Number of edge traversals
  • Number of errors
  • Visit count per node

Reading Metrics

// After execution
const snapshot = await manager.getSnapshot(runId);
console.log('Total traversals:', snapshot.ctx.metrics?.traversals);
console.log('Errors:', snapshot.ctx.metrics?.errors);
console.log('Node visits:', snapshot.ctx.metrics?.byNode);

🎯 Best Practices

βœ… DO: Use propose() for all updates

// CORRECT
return propose(ctx, {
  op: 'set',
  path: 'data.value',
  value: newValue,
  ts: Date.now(),
  by: 'myAction'
});

❌ DON'T: Mutate context directly

// WRONG - Context is readonly!
ctx.data.value = newValue;
return ctx;

❌ DON'T: Return new context object

// WRONG - Bypasses patch system
return {
  ...ctx,
  data: { ...ctx.data, value: newValue }
};

βœ… DO: Use descriptive patch origins

// CORRECT - Clear origin
return propose(ctx, {
  op: 'set',
  path: 'data.result',
  value: result,
  ts: Date.now(),
  by: 'calculateTaxAction'  // Descriptive
});

βœ… DO: Use node-local state for node-specific data

// CORRECT - Namespaced to avoid collisions
return propose(ctx, {
  op: 'set',
  path: 'node.retryNode.attemptCount',
  value: attemptCount,
  ts: Date.now(),
  by: 'retryNode'
});

βœ… DO: Use global for immutable config

// CORRECT - Read-only configuration
const apiUrl = ctx.global.apiUrl;
const tenantId = ctx.global.tenantId;

βœ… DO: Use injected for request-specific data

// CORRECT - Per-run parameters
const userId = ctx.injected.userId;
const requestId = ctx.injected.requestId;

πŸ” Debugging Context

View Full Context

const snapshot = await manager.getSnapshot(runId);
console.log('Full context:', JSON.stringify(snapshot.ctx, null, 2));

View Patch History

const snapshot = await manager.getSnapshot(runId);
console.log('Patch journal:', snapshot.ctx.journal);

// See what changed
snapshot.ctx.journal.forEach(patch => {
  console.log(`${patch.by} ${patch.op} ${patch.path} = ${patch.value}`);
});

View Events

const snapshot = await manager.getSnapshot(runId);
console.log('Events:', snapshot.ctx.events);

// Filter by type
const userEvents = snapshot.ctx.events.filter(e => 
  e.type.startsWith('User')
);

View Metrics

const snapshot = await manager.getSnapshot(runId);
console.log('Metrics:', snapshot.ctx.metrics);
console.log('Most visited node:', 
  Object.entries(snapshot.ctx.metrics?.byNode || {})
    .sort((a, b) => b[1] - a[1])[0]
);

πŸ§ͺ Testing Context

Mock Context

import { Ctx } from '@quarry-systems/mcg-contracts';

const mockCtx: Ctx = {
  global: { apiUrl: 'https://test.api.com' },
  injected: { userId: 'test-user' },
  services: {},
  data: { testData: 'value' },
  node: {},
  now: new Date('2024-01-01'),
  version: 1,
  journal: [],
  events: [],
  idempotency: {},
  metrics: { traversals: 0, errors: 0, byNode: {} }
};

// Test action
const result = await myAction.run(mockCtx);
expect(result.journal).toHaveLength(1);
expect(result.journal[0].path).toBe('data.result');

Test Patches

import { propose, commit } from '@quarry-systems/managed-cyclic-graph';

test('action updates context correctly', async () => {
  let ctx = mockCtx;
  
  // Apply patch
  ctx = propose(ctx, {
    op: 'set',
    path: 'data.value',
    value: 42,
    ts: Date.now(),
    by: 'test'
  });
  
  // Commit
  ctx = commit(ctx);
  
  expect(ctx.data.value).toBe(42);
  expect(ctx.version).toBe(2);
});

πŸ“š Related Documentation

  • Main Guide: ./ai_agent.md
  • Plugin System: ./ai_agent_plugins.md (coming)
  • Execution: ./ai_agent_execution.md (coming)
  • Patterns: ./ai_agent_patterns.md (coming)

Last Updated: 2025-12-22
For: AI Agents implementing MCG context management