crewcontext 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ """CrewContext — Context coordination layer for multi-agent workflows.
2
+
3
+ Usage:
4
+ from crewcontext import ProcessContext, Entity, Event, Relation
5
+
6
+ with ProcessContext(process_id="p1", agent_id="agent-1") as ctx:
7
+ event = ctx.emit("invoice.received", {"amount": 5000}, entity_id="inv-1")
8
+ """
9
+ from .context import ProcessContext
10
+ from .models import Entity, Event, Relation, RoutingDecision, generate_id
11
+ from .router import (
12
+ PolicyRouter,
13
+ all_of,
14
+ any_of,
15
+ none_of,
16
+ data_field_eq,
17
+ data_field_gt,
18
+ data_field_ne,
19
+ data_fields_differ,
20
+ event_type_is,
21
+ )
22
+
23
+ __version__ = "0.1.0"
24
+ __all__ = [
25
+ "ProcessContext",
26
+ "Entity",
27
+ "Event",
28
+ "Relation",
29
+ "RoutingDecision",
30
+ "generate_id",
31
+ "PolicyRouter",
32
+ "all_of",
33
+ "any_of",
34
+ "none_of",
35
+ "data_field_eq",
36
+ "data_field_gt",
37
+ "data_field_ne",
38
+ "data_fields_differ",
39
+ "event_type_is",
40
+ ]
crewcontext/cli.py ADDED
@@ -0,0 +1,39 @@
1
+ """CLI for CrewContext."""
2
+ import click
3
+ from .utils import load_env
4
+
5
+
6
+ @click.group()
7
+ @click.version_option(version="0.1.0")
8
+ def main():
9
+ """CrewContext - Context coordination for multi-agent workflows."""
10
+ load_env()
11
+
12
+
13
+ @main.group()
14
+ def demo():
15
+ """Run demo scenarios."""
16
+ pass
17
+
18
+
19
+ @demo.command("vendor-discrepancy")
20
+ def vendor_discrepancy():
21
+ """Run the vendor discrepancy demo."""
22
+ from .demos.vendor_discrepancy import run_demo
23
+ run_demo()
24
+
25
+
26
+ @main.command("init-db")
27
+ @click.option("--db-url", envvar="CREWCONTEXT_DB_URL", default=None)
28
+ def init_db(db_url):
29
+ """Initialise the database schema."""
30
+ from .store.postgres import PostgresStore
31
+ store = PostgresStore(db_url)
32
+ store.connect()
33
+ store.init_schema()
34
+ store.close()
35
+ click.echo("Database schema initialised.")
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
crewcontext/context.py ADDED
@@ -0,0 +1,281 @@
1
+ """ProcessContext — the main API agents interact with.
2
+
3
+ This is the public interface. Agents emit events, query history,
4
+ build entity snapshots, and trace causal chains through this class.
5
+
6
+ Usage:
7
+ with ProcessContext(process_id="proc-1", agent_id="agent-a") as ctx:
8
+ e1 = ctx.emit("invoice.received", {"amount": 5000}, entity_id="inv-1")
9
+ e2 = ctx.emit("invoice.validated", {"ok": True}, entity_id="inv-1", caused_by=[e1])
10
+ history = ctx.timeline("inv-1")
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from datetime import datetime
16
+ from typing import Any, Callable, Dict, List, Optional, Sequence
17
+
18
+ from .models import Entity, Event, Relation, RoutingDecision, generate_id
19
+ from .projection.projector import Neo4jProjector
20
+ from .router import PolicyRouter
21
+ from .store.postgres import PostgresStore
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class ProcessContext:
27
+ """Scoped, temporal, causal context for a business process.
28
+
29
+ Features:
30
+ - **emit()**: Record events with optional causal parents.
31
+ - **query()**: Retrieve events with temporal/scope/type filtering.
32
+ - **timeline()**: Ordered event history for an entity.
33
+ - **snapshot()**: Save/retrieve versioned entity state.
34
+ - **causal_chain()**: Walk the causal DAG.
35
+ - **subscribe()**: React to event types in real-time.
36
+ - **batch_emit()**: Atomic multi-event writes.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ process_id: str,
42
+ agent_id: str,
43
+ scope: str = "default",
44
+ db_url: Optional[str] = None,
45
+ enable_neo4j: bool = True,
46
+ ):
47
+ self.process_id = process_id
48
+ self.agent_id = agent_id
49
+ self.scope = scope
50
+
51
+ self._store = PostgresStore(db_url)
52
+ self._projector = Neo4jProjector() if enable_neo4j else None
53
+ self._router = PolicyRouter()
54
+ self._connected = False
55
+
56
+ # -- context manager ----------------------------------------------------
57
+
58
+ def __enter__(self) -> ProcessContext:
59
+ self.connect()
60
+ return self
61
+
62
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
63
+ self.close()
64
+
65
+ def connect(self) -> None:
66
+ self._store.connect()
67
+ self._store.init_schema()
68
+ self._connected = True
69
+ if self._projector:
70
+ self._projector.connect() # best-effort, logs on failure
71
+ log.info(
72
+ "ProcessContext ready: process=%s agent=%s scope=%s",
73
+ self.process_id, self.agent_id, self.scope,
74
+ )
75
+
76
+ def close(self) -> None:
77
+ self._store.close()
78
+ if self._projector:
79
+ self._projector.close()
80
+ self._connected = False
81
+
82
+ # -- router access ------------------------------------------------------
83
+
84
+ @property
85
+ def router(self) -> PolicyRouter:
86
+ return self._router
87
+
88
+ # -- emit events --------------------------------------------------------
89
+
90
+ def emit(
91
+ self,
92
+ event_type: str,
93
+ data: Dict[str, Any],
94
+ entity_id: Optional[str] = None,
95
+ relation_id: Optional[str] = None,
96
+ metadata: Optional[Dict[str, Any]] = None,
97
+ caused_by: Optional[Sequence[Event]] = None,
98
+ ) -> Event:
99
+ """Emit an event into the process context.
100
+
101
+ Args:
102
+ event_type: Dotted event name (e.g. "invoice.received").
103
+ data: Event payload.
104
+ entity_id: Optional entity this event affects.
105
+ relation_id: Optional relation this event affects.
106
+ metadata: Arbitrary metadata.
107
+ caused_by: Parent events that caused this one (builds the DAG).
108
+
109
+ Returns:
110
+ The persisted Event object.
111
+ """
112
+ parent_ids = tuple(e.id for e in caused_by) if caused_by else ()
113
+
114
+ event = Event(
115
+ id=generate_id(),
116
+ type=event_type,
117
+ process_id=self.process_id,
118
+ data=data,
119
+ agent_id=self.agent_id,
120
+ entity_id=entity_id,
121
+ relation_id=relation_id,
122
+ scope=self.scope,
123
+ metadata=metadata or {},
124
+ parent_ids=parent_ids,
125
+ )
126
+
127
+ # 1. Persist to source of truth
128
+ self._store.save_event(event)
129
+
130
+ # 2. Project to graph (best-effort)
131
+ if self._projector:
132
+ self._projector.project_event(event)
133
+
134
+ # 3. Notify subscribers
135
+ self._router.notify_subscribers(event)
136
+
137
+ # 4. Evaluate routing rules
138
+ decision = self._router.evaluate(event)
139
+ if decision:
140
+ self._persist_routing_decision(decision, parent_event=event)
141
+
142
+ log.debug("Emitted: %s (type=%s, entity=%s)", event.id[:8], event_type, entity_id)
143
+ return event
144
+
145
+ def batch_emit(
146
+ self, events_spec: List[Dict[str, Any]]
147
+ ) -> List[Event]:
148
+ """Atomically emit multiple events in a single transaction.
149
+
150
+ Each spec is a dict with keys: event_type, data, and optionally
151
+ entity_id, relation_id, metadata.
152
+ """
153
+ events = []
154
+ for spec in events_spec:
155
+ event = Event(
156
+ id=generate_id(),
157
+ type=spec["event_type"],
158
+ process_id=self.process_id,
159
+ data=spec["data"],
160
+ agent_id=self.agent_id,
161
+ entity_id=spec.get("entity_id"),
162
+ relation_id=spec.get("relation_id"),
163
+ scope=self.scope,
164
+ metadata=spec.get("metadata", {}),
165
+ )
166
+ events.append(event)
167
+
168
+ self._store.save_events(events)
169
+
170
+ # Best-effort graph projection
171
+ if self._projector:
172
+ for ev in events:
173
+ self._projector.project_event(ev)
174
+
175
+ return events
176
+
177
+ def _persist_routing_decision(
178
+ self, decision: RoutingDecision, parent_event: Event
179
+ ) -> None:
180
+ """Save a routing decision as a child event (no re-evaluation)."""
181
+ decision_event = Event(
182
+ id=generate_id(),
183
+ type="routing.decision",
184
+ process_id=self.process_id,
185
+ data=decision.to_dict(),
186
+ agent_id="system.router",
187
+ entity_id=parent_event.entity_id,
188
+ scope=self.scope,
189
+ parent_ids=(parent_event.id,),
190
+ )
191
+ self._store.save_event(decision_event)
192
+ if self._projector:
193
+ self._projector.project_event(decision_event)
194
+
195
+ # -- query events -------------------------------------------------------
196
+
197
+ def query(
198
+ self,
199
+ entity_id: Optional[str] = None,
200
+ event_type: Optional[str] = None,
201
+ scope: Optional[str] = None,
202
+ as_of: Optional[datetime] = None,
203
+ limit: int = 1000,
204
+ offset: int = 0,
205
+ ) -> List[Dict[str, Any]]:
206
+ """Query events in this process with optional filters."""
207
+ return self._store.query_events(
208
+ self.process_id,
209
+ entity_id=entity_id,
210
+ event_type=event_type,
211
+ scope=scope or self.scope,
212
+ as_of=as_of,
213
+ limit=limit,
214
+ offset=offset,
215
+ )
216
+
217
+ def timeline(self, entity_id: str, as_of: Optional[datetime] = None) -> List[Dict[str, Any]]:
218
+ """Get the full ordered event history for an entity."""
219
+ return self._store.query_events(
220
+ self.process_id,
221
+ entity_id=entity_id,
222
+ as_of=as_of,
223
+ )
224
+
225
+ # -- entity snapshots ---------------------------------------------------
226
+
227
+ def save_entity(self, entity: Entity) -> None:
228
+ """Save a versioned entity snapshot."""
229
+ self._store.save_entity(entity)
230
+ if self._projector:
231
+ self._projector.project_entity(entity)
232
+
233
+ def get_entity(
234
+ self, entity_id: str, as_of: Optional[datetime] = None
235
+ ) -> Optional[Dict[str, Any]]:
236
+ """Retrieve the latest entity state (or state at a point in time)."""
237
+ return self._store.get_entity(entity_id, as_of=as_of)
238
+
239
+ # -- relations ----------------------------------------------------------
240
+
241
+ def save_relation(self, relation: Relation) -> None:
242
+ """Persist a typed relation between entities."""
243
+ self._store.save_relation(relation)
244
+ if self._projector:
245
+ self._projector.project_relation(relation)
246
+
247
+ # -- causal DAG ---------------------------------------------------------
248
+
249
+ def causal_parents(self, event_id: str) -> List[str]:
250
+ """Get the events that caused this event."""
251
+ return self._store.get_causal_parents(event_id)
252
+
253
+ def causal_children(self, event_id: str) -> List[str]:
254
+ """Get the events caused by this event."""
255
+ return self._store.get_causal_children(event_id)
256
+
257
+ def causal_chain(self, event_id: str, max_depth: int = 10) -> List[Dict[str, Any]]:
258
+ """Walk the full causal DAG via Neo4j (if available)."""
259
+ if self._projector:
260
+ return self._projector.get_causal_chain(event_id, max_depth=max_depth)
261
+ return []
262
+
263
+ # -- graph queries ------------------------------------------------------
264
+
265
+ def lineage(self, entity_id: str, max_depth: int = 20) -> List[Dict[str, Any]]:
266
+ """Get Neo4j lineage for an entity (if available)."""
267
+ if self._projector:
268
+ return self._projector.get_lineage(entity_id, max_depth=max_depth)
269
+ return []
270
+
271
+ def cypher(self, query: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
272
+ """Execute a raw Cypher query against Neo4j."""
273
+ if self._projector:
274
+ return self._projector.run_cypher(query, params)
275
+ return []
276
+
277
+ # -- pub/sub convenience ------------------------------------------------
278
+
279
+ def subscribe(self, event_type: str, callback: Callable[[Event], None]) -> None:
280
+ """Subscribe to events of a given type."""
281
+ self._router.subscribe(event_type, callback)
@@ -0,0 +1 @@
1
+ """Demo scenarios."""
@@ -0,0 +1,205 @@
1
+ """Vendor discrepancy demo — showcases the full CrewContext pipeline.
2
+
3
+ Scenario: An invoice arrives with a vendor mismatch. Three agents handle
4
+ it in sequence — receiver, validator, reconciler. CrewContext preserves
5
+ full context, causal chains, and routing decisions across handoffs.
6
+ """
7
+ from crewcontext.context import ProcessContext
8
+ from crewcontext.models import Entity, Relation, generate_id
9
+ from crewcontext.router import all_of, data_field_gt, data_fields_differ
10
+
11
+
12
+ def run_demo():
13
+ process_id = f"demo-{generate_id()[:8]}"
14
+ invoice_id = f"inv-{generate_id()[:8]}"
15
+
16
+ print(f"{'=' * 60}")
17
+ print(f" CrewContext Demo: Vendor Discrepancy Resolution")
18
+ print(f" Process: {process_id}")
19
+ print(f" Invoice: {invoice_id}")
20
+ print(f"{'=' * 60}\n")
21
+
22
+ # ── Agent 1: Invoice Receiver ─────────────────────────────
23
+
24
+ print("[Agent: invoice-receiver]")
25
+ with ProcessContext(
26
+ process_id=process_id,
27
+ agent_id="agent-invoice-receiver",
28
+ enable_neo4j=True,
29
+ ) as ctx:
30
+
31
+ # Set up routing rules
32
+ ctx.router.add_rule(
33
+ name="high-value-review",
34
+ condition=data_field_gt("amount", 1000),
35
+ action="route-to-senior-auditor",
36
+ priority=10,
37
+ metadata={"sla_hours": 4},
38
+ )
39
+ ctx.router.add_rule(
40
+ name="vendor-mismatch",
41
+ condition=data_fields_differ("vendor_id", "expected_vendor_id"),
42
+ action="flag-for-reconciliation",
43
+ priority=5,
44
+ metadata={"requires_manual_review": True},
45
+ )
46
+
47
+ # Emit: invoice received
48
+ e1 = ctx.emit(
49
+ "invoice.received",
50
+ {
51
+ "invoice_id": invoice_id,
52
+ "vendor_id": "V-1001",
53
+ "expected_vendor_id": "V-1002",
54
+ "amount": 15000,
55
+ "currency": "USD",
56
+ },
57
+ entity_id=invoice_id,
58
+ )
59
+ print(f" Emitted: invoice.received (amount=15000 USD)")
60
+ print(f" Event ID: {e1.id[:12]}...")
61
+
62
+ # Save entity snapshot
63
+ ctx.save_entity(Entity(
64
+ id=invoice_id, type="Invoice",
65
+ attributes={
66
+ "amount": 15000, "currency": "USD",
67
+ "vendor_id": "V-1001", "status": "received",
68
+ },
69
+ provenance={"agent": "agent-invoice-receiver"},
70
+ ))
71
+ print(f" Entity snapshot saved: {invoice_id} v1")
72
+
73
+ # Check routing decisions
74
+ decisions = ctx.query(event_type="routing.decision")
75
+ for d in decisions:
76
+ data = d.get("data", {})
77
+ print(f" Routing: {data.get('rule_name')} -> {data.get('action')}")
78
+
79
+ # ── Agent 2: Validator ────────────────────────────────────
80
+
81
+ print(f"\n[Agent: invoice-validator]")
82
+ with ProcessContext(
83
+ process_id=process_id,
84
+ agent_id="agent-invoice-validator",
85
+ enable_neo4j=True,
86
+ ) as ctx:
87
+
88
+ # Query what happened before (context handoff!)
89
+ history = ctx.timeline(invoice_id)
90
+ print(f" Context received: {len(history)} prior events")
91
+
92
+ # Emit: validation result (caused by e1)
93
+ e2 = ctx.emit(
94
+ "invoice.validated",
95
+ {
96
+ "invoice_id": invoice_id,
97
+ "validation_status": "discrepancy_found",
98
+ "discrepancy_type": "vendor_mismatch",
99
+ "vendor_on_invoice": "V-1001",
100
+ "vendor_expected": "V-1002",
101
+ },
102
+ entity_id=invoice_id,
103
+ caused_by=[e1],
104
+ )
105
+ print(f" Emitted: invoice.validated (discrepancy_found)")
106
+ print(f" Causal parent: {e1.id[:12]}...")
107
+
108
+ # Update entity snapshot
109
+ ctx.save_entity(Entity(
110
+ id=invoice_id, type="Invoice", version=2,
111
+ attributes={
112
+ "amount": 15000, "currency": "USD",
113
+ "vendor_id": "V-1001", "status": "discrepancy_found",
114
+ },
115
+ provenance={"agent": "agent-invoice-validator"},
116
+ ))
117
+ print(f" Entity snapshot saved: {invoice_id} v2")
118
+
119
+ # ── Agent 3: Reconciler ───────────────────────────────────
120
+
121
+ print(f"\n[Agent: reconciler]")
122
+ with ProcessContext(
123
+ process_id=process_id,
124
+ agent_id="agent-reconciler",
125
+ enable_neo4j=True,
126
+ ) as ctx:
127
+
128
+ # Full context available
129
+ history = ctx.timeline(invoice_id)
130
+ print(f" Context received: {len(history)} prior events")
131
+
132
+ # Emit: reconciliation
133
+ e3 = ctx.emit(
134
+ "reconciliation.completed",
135
+ {
136
+ "invoice_id": invoice_id,
137
+ "resolution": "vendor_corrected",
138
+ "original_vendor": "V-1001",
139
+ "corrected_vendor": "V-1002",
140
+ },
141
+ entity_id=invoice_id,
142
+ caused_by=[e2],
143
+ )
144
+ print(f" Emitted: reconciliation.completed")
145
+
146
+ # Save relation
147
+ ctx.save_relation(Relation(
148
+ id=generate_id(), type="RECONCILED_BY",
149
+ from_entity_id=invoice_id, to_entity_id="agent-reconciler",
150
+ ))
151
+
152
+ # Final entity snapshot
153
+ ctx.save_entity(Entity(
154
+ id=invoice_id, type="Invoice", version=3,
155
+ attributes={
156
+ "amount": 15000, "currency": "USD",
157
+ "vendor_id": "V-1002", "status": "reconciled",
158
+ },
159
+ provenance={"agent": "agent-reconciler"},
160
+ ))
161
+ print(f" Entity snapshot saved: {invoice_id} v3 (reconciled)")
162
+
163
+ # ── Summary ──────────────────────────────────────────
164
+
165
+ print(f"\n{'=' * 60}")
166
+ print(f" SUMMARY")
167
+ print(f"{'=' * 60}")
168
+
169
+ full_timeline = ctx.timeline(invoice_id)
170
+ print(f"\n Timeline ({len(full_timeline)} events):")
171
+ for evt in full_timeline:
172
+ agent = evt.get("agent_id", "?")
173
+ etype = evt.get("type", "?")
174
+ ts = evt.get("timestamp", "?")
175
+ print(f" {ts} [{agent}] {etype}")
176
+
177
+ # Causal chain
178
+ parents = ctx.causal_parents(e3.id)
179
+ print(f"\n Causal parents of reconciliation: {len(parents)}")
180
+ children = ctx.causal_children(e1.id)
181
+ print(f" Causal children of initial receipt: {len(children)}")
182
+
183
+ # Entity state
184
+ entity = ctx.get_entity(invoice_id)
185
+ if entity:
186
+ print(f"\n Final entity state:")
187
+ print(f" Version: {entity.get('version')}")
188
+ print(f" Status: {entity.get('attributes', {}).get('status', 'unknown') if isinstance(entity.get('attributes'), dict) else 'see attributes'}")
189
+
190
+ # Neo4j lineage
191
+ lineage = ctx.lineage(invoice_id)
192
+ if lineage:
193
+ print(f"\n Neo4j Lineage ({len(lineage)} events):")
194
+ for rec in lineage:
195
+ print(f" {rec.get('type')} by {rec.get('agent_id')}")
196
+ else:
197
+ print(f"\n (Neo4j lineage not available)")
198
+
199
+ print(f"\n{'=' * 60}")
200
+ print(f" Demo completed successfully!")
201
+ print(f"{'=' * 60}")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ run_demo()