Focus: Deep dive into MCG's context system, patch-based updates, and state management
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>;
};
}>;- 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 }
}
}
});- 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'
}
}
});- 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
}
}
}
});- 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'
});- 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'
});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
type Patch = {
op: 'set' | 'del' | 'inc' | 'push-uniq';
path: string; // Dot-notation path
value?: unknown;
ts: number; // Timestamp
by: string; // Node/action ID
};// 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'
});propose(ctx, {
op: 'del',
path: 'data.temporaryData',
ts: Date.now(),
by: 'cleanupAction'
});// 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'
});// Add to array (only if not already present)
propose(ctx, {
op: 'push-uniq',
path: 'data.visitedNodes',
value: 'nodeA',
ts: Date.now(),
by: 'trackVisitAction'
});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'
});
}
};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;
}You don't call commit() manually - the Traverser handles it:
- Action returns context with patches in journal
- Traverser calls
commit(ctx)automatically - Patches are applied to context data
- Version is incremented
- 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 thistype 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
};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'
});
}// 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 keys ensure external side effects (API calls, payments, etc.) are not duplicated on retries.
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?: {
traversals: number; // Total edge traversals
errors: number; // Total errors
byNode: Record<string, number>; // Visit count per node
}The Manager automatically tracks:
- Number of edge traversals
- Number of errors
- Visit count per node
// 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);// CORRECT
return propose(ctx, {
op: 'set',
path: 'data.value',
value: newValue,
ts: Date.now(),
by: 'myAction'
});// WRONG - Context is readonly!
ctx.data.value = newValue;
return ctx;// WRONG - Bypasses patch system
return {
...ctx,
data: { ...ctx.data, value: newValue }
};// CORRECT - Clear origin
return propose(ctx, {
op: 'set',
path: 'data.result',
value: result,
ts: Date.now(),
by: 'calculateTaxAction' // Descriptive
});// CORRECT - Namespaced to avoid collisions
return propose(ctx, {
op: 'set',
path: 'node.retryNode.attemptCount',
value: attemptCount,
ts: Date.now(),
by: 'retryNode'
});// CORRECT - Read-only configuration
const apiUrl = ctx.global.apiUrl;
const tenantId = ctx.global.tenantId;// CORRECT - Per-run parameters
const userId = ctx.injected.userId;
const requestId = ctx.injected.requestId;const snapshot = await manager.getSnapshot(runId);
console.log('Full context:', JSON.stringify(snapshot.ctx, null, 2));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}`);
});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')
);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]
);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');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);
});- 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