orca-runtime-python 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,69 @@
1
+ """
2
+ Orca Runtime Python
3
+
4
+ A first-class Python async runtime for Orca state machines.
5
+ """
6
+
7
+ from .types import (
8
+ StateDef,
9
+ Transition,
10
+ GuardDef,
11
+ ActionSignature,
12
+ EffectDef,
13
+ MachineDef,
14
+ StateValue,
15
+ Context,
16
+ Effect,
17
+ EffectResult,
18
+ EffectStatus,
19
+ )
20
+
21
+ from .bus import (
22
+ EventBus,
23
+ Event,
24
+ EventType,
25
+ get_event_bus,
26
+ )
27
+
28
+ from .machine import OrcaMachine
29
+
30
+ from .parser import parse_orca_md, parse_orca_auto
31
+
32
+ from .persistence import PersistenceAdapter, FilePersistence
33
+
34
+ from .logging import LogSink, FileSink, ConsoleSink, MultiSink
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ # Types
40
+ "StateDef",
41
+ "Transition",
42
+ "GuardDef",
43
+ "ActionSignature",
44
+ "EffectDef",
45
+ "MachineDef",
46
+ "StateValue",
47
+ "Context",
48
+ "Effect",
49
+ "EffectResult",
50
+ "EffectStatus",
51
+ # Bus
52
+ "EventBus",
53
+ "Event",
54
+ "EventType",
55
+ "get_event_bus",
56
+ # Machine
57
+ "OrcaMachine",
58
+ # Parser
59
+ "parse_orca_md",
60
+ "parse_orca_auto",
61
+ # Persistence
62
+ "PersistenceAdapter",
63
+ "FilePersistence",
64
+ # Logging
65
+ "LogSink",
66
+ "FileSink",
67
+ "ConsoleSink",
68
+ "MultiSink",
69
+ ]
@@ -0,0 +1,227 @@
1
+ """
2
+ Async event bus with pub/sub and request/response patterns.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Any, Awaitable, Callable
12
+ from uuid import uuid4
13
+
14
+ from .types import Effect, EffectResult, EffectStatus
15
+
16
+
17
+ class EventType(Enum):
18
+ """Standard Orca event types."""
19
+
20
+ # State machine events
21
+ STATE_CHANGED = "orca.state.changed"
22
+ TRANSITION_STARTED = "orca.transition.started"
23
+ TRANSITION_COMPLETED = "orca.transition.completed"
24
+ EFFECT_EXECUTING = "orca.effect.executing"
25
+ EFFECT_COMPLETED = "orca.effect.completed"
26
+ EFFECT_FAILED = "orca.effect.failed"
27
+ MACHINE_STARTED = "orca.machine.started"
28
+ MACHINE_STOPPED = "orca.machine.stopped"
29
+
30
+ # Workflow events
31
+ WORKFLOW_STATE_CHANGED = "workflow.state.changed"
32
+
33
+ # Agent events
34
+ AGENT_TASK_ASSIGNED = "agent.task.assigned"
35
+ AGENT_TASK_COMPLETED = "agent.task.completed"
36
+
37
+ # Scheduling events
38
+ SCHEDULING_QUERY = "scheduling.query"
39
+ SCHEDULING_QUERY_RESPONSE = "scheduling.query_response"
40
+
41
+
42
+ @dataclass
43
+ class Event:
44
+ """
45
+ Represents a typed event with correlation IDs and source tracking.
46
+ """
47
+ type: EventType
48
+ source: str
49
+ event_name: str | None = None # Original event name for custom events
50
+ correlation_id: str | None = None
51
+ timestamp: datetime = field(default_factory=datetime.utcnow)
52
+ payload: dict[str, Any] = field(default_factory=dict)
53
+
54
+ def __str__(self) -> str:
55
+ return f"Event({self.type.value}, source={self.source})"
56
+
57
+
58
+ # Type alias for effect handlers
59
+ EffectHandler = Callable[[Effect], Awaitable[EffectResult]]
60
+
61
+ # Type alias for event handlers
62
+ EventHandler = Callable[[Event], Awaitable[None]]
63
+
64
+
65
+ class EventBus:
66
+ """
67
+ Async event bus with pub/sub and request/response patterns.
68
+
69
+ Supports:
70
+ - Subscribe/unsubscribe to event types
71
+ - Publish events to all subscribers
72
+ - Request/response pattern with correlation IDs
73
+ - Effect handler registration and execution
74
+ """
75
+
76
+ def __init__(self):
77
+ self._subscribers: dict[EventType, list[EventHandler]] = {}
78
+ self._effect_handlers: dict[str, EffectHandler] = {}
79
+ self._response_queues: dict[str, asyncio.Queue[Event]] = {}
80
+
81
+ def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
82
+ """Subscribe a handler to an event type."""
83
+ if event_type not in self._subscribers:
84
+ self._subscribers[event_type] = []
85
+ if handler not in self._subscribers[event_type]:
86
+ self._subscribers[event_type].append(handler)
87
+
88
+ def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
89
+ """Unsubscribe a handler from an event type."""
90
+ if event_type in self._subscribers:
91
+ if handler in self._subscribers[event_type]:
92
+ self._subscribers[event_type].remove(handler)
93
+
94
+ async def publish(self, event: Event) -> None:
95
+ """
96
+ Publish an event to all subscribers.
97
+
98
+ All handlers are executed concurrently with return_exceptions=True
99
+ so one handler's exception doesn't affect others.
100
+ """
101
+ if event.type in self._subscribers:
102
+ handlers = list(self._subscribers[event.type])
103
+ await asyncio.gather(
104
+ *[handler(event) for handler in handlers],
105
+ return_exceptions=True
106
+ )
107
+
108
+ def register_effect_handler(self, effect_type: str, handler: EffectHandler) -> None:
109
+ """
110
+ Register an effect handler for a specific effect type.
111
+
112
+ Effect handlers are async functions that receive an Effect
113
+ and return an EffectResult.
114
+ """
115
+ self._effect_handlers[effect_type] = handler
116
+
117
+ async def execute_effect(self, effect: Effect) -> EffectResult:
118
+ """
119
+ Execute an effect via registered handler.
120
+
121
+ Returns EffectResult with status SUCCESS or FAILURE.
122
+ """
123
+ if effect.type not in self._effect_handlers:
124
+ return EffectResult(
125
+ status=EffectStatus.FAILURE,
126
+ error=f"No handler registered for effect type: {effect.type}"
127
+ )
128
+
129
+ handler = self._effect_handlers[effect.type]
130
+ try:
131
+ return await handler(effect)
132
+ except Exception as e:
133
+ return EffectResult(
134
+ status=EffectStatus.FAILURE,
135
+ error=str(e)
136
+ )
137
+
138
+ async def request_response(
139
+ self,
140
+ request_type: EventType,
141
+ request_payload: dict[str, Any],
142
+ response_type: EventType,
143
+ correlation_id: str | None = None,
144
+ timeout: float = 5.0,
145
+ source: str = "orca",
146
+ ) -> Any:
147
+ """
148
+ Request/response pattern via event bus.
149
+
150
+ Publishes a request event and waits for a matching response
151
+ with the same correlation ID.
152
+
153
+ Args:
154
+ request_type: Event type for the request
155
+ request_payload: Data to send with request
156
+ response_type: Event type expected for response
157
+ correlation_id: Optional correlation ID (generated if not provided)
158
+ timeout: Seconds to wait for response
159
+ source: Source identifier for the request event
160
+
161
+ Returns:
162
+ The payload from the response event
163
+
164
+ Raises:
165
+ TimeoutError: If response not received within timeout
166
+ """
167
+ corr_id = correlation_id or str(uuid4())
168
+
169
+ response_queue: asyncio.Queue[Event] = asyncio.Queue()
170
+ self._response_queues[corr_id] = response_queue
171
+
172
+ async def response_handler(event: Event) -> None:
173
+ if event.correlation_id == corr_id:
174
+ await response_queue.put(event)
175
+
176
+ self.subscribe(response_type, response_handler)
177
+
178
+ try:
179
+ # Publish request
180
+ await self.publish(Event(
181
+ type=request_type,
182
+ source=source,
183
+ correlation_id=corr_id,
184
+ payload=request_payload
185
+ ))
186
+
187
+ # Wait for response
188
+ try:
189
+ response_event = await asyncio.wait_for(
190
+ response_queue.get(),
191
+ timeout=timeout
192
+ )
193
+ return response_event.payload
194
+ except asyncio.TimeoutError:
195
+ raise TimeoutError(
196
+ f"Request {corr_id} timed out after {timeout}s"
197
+ )
198
+ finally:
199
+ self.unsubscribe(response_type, response_handler)
200
+ del self._response_queues[corr_id]
201
+
202
+ @property
203
+ def effect_handler_types(self) -> list[str]:
204
+ """List of registered effect handler types."""
205
+ return list(self._effect_handlers.keys())
206
+
207
+
208
+ # Global event bus instance
209
+ _bus: EventBus | None = None
210
+
211
+
212
+ def get_event_bus() -> EventBus:
213
+ """
214
+ Get the global event bus instance.
215
+
216
+ Creates a new EventBus if one doesn't exist.
217
+ """
218
+ global _bus
219
+ if _bus is None:
220
+ _bus = EventBus()
221
+ return _bus
222
+
223
+
224
+ def reset_event_bus() -> None:
225
+ """Reset the global event bus (useful for testing)."""
226
+ global _bus
227
+ _bus = None
@@ -0,0 +1,216 @@
1
+ """
2
+ Effect system for Orca runtime.
3
+
4
+ Provides effect types and utilities for async operations
5
+ that can be executed by the runtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Awaitable, Callable
12
+
13
+ from .types import Effect, EffectResult, EffectStatus
14
+
15
+
16
+ # Type alias for effect handlers
17
+ EffectHandler = Callable[[Effect], Awaitable[EffectResult]]
18
+
19
+
20
+ @dataclass
21
+ class EffectType:
22
+ """
23
+ Defines an effect type with its handler.
24
+ """
25
+ name: str
26
+ handler: EffectHandler
27
+
28
+ async def execute(self, payload: dict[str, Any]) -> EffectResult:
29
+ """Execute this effect with the given payload."""
30
+ effect = Effect(type=self.name, payload=payload)
31
+ return await self.handler(effect)
32
+
33
+
34
+ class EffectRegistry:
35
+ """
36
+ Registry for effect types and handlers.
37
+
38
+ Allows registering effect handlers and creating effect instances.
39
+ """
40
+
41
+ def __init__(self):
42
+ self._handlers: dict[str, EffectHandler] = {}
43
+
44
+ def register(self, effect_type: str, handler: EffectHandler) -> None:
45
+ """Register a handler for an effect type."""
46
+ self._handlers[effect_type] = handler
47
+
48
+ def get_handler(self, effect_type: str) -> EffectHandler | None:
49
+ """Get the handler for an effect type."""
50
+ return self._handlers.get(effect_type)
51
+
52
+ def has_handler(self, effect_type: str) -> bool:
53
+ """Check if a handler is registered for an effect type."""
54
+ return effect_type in self._handlers
55
+
56
+ @property
57
+ def effect_types(self) -> list[str]:
58
+ """List all registered effect types."""
59
+ return list(self._handlers.keys())
60
+
61
+ async def execute(self, effect: Effect) -> EffectResult:
62
+ """Execute an effect using the registered handler."""
63
+ if effect.type not in self._handlers:
64
+ return EffectResult(
65
+ status=EffectStatus.FAILURE,
66
+ error=f"No handler registered for effect type: {effect.type}"
67
+ )
68
+
69
+ handler = self._handlers[effect.type]
70
+ try:
71
+ return await handler(effect)
72
+ except Exception as e:
73
+ return EffectResult(
74
+ status=EffectStatus.FAILURE,
75
+ error=str(e)
76
+ )
77
+
78
+
79
+ # Common effect payload types
80
+
81
+ @dataclass
82
+ class NarrativeRequest:
83
+ """Request for narrative generation (LLM)."""
84
+ action: str # look, move, take, etc.
85
+ context: dict[str, Any]
86
+ event: dict[str, Any] | None = None
87
+
88
+
89
+ @dataclass
90
+ class NarrativeResponse:
91
+ """Response from narrative generation."""
92
+ narrative: str
93
+ new_location: str | None = None
94
+
95
+
96
+ @dataclass
97
+ class MoveRequest:
98
+ """Request to move to a new location."""
99
+ direction: str
100
+ context: dict[str, Any]
101
+
102
+
103
+ @dataclass
104
+ class MoveResponse:
105
+ """Response from move operation."""
106
+ new_location: str
107
+ description: str
108
+ visited: bool = False
109
+
110
+
111
+ @dataclass
112
+ class SaveRequest:
113
+ """Request to save state."""
114
+ session_id: str
115
+
116
+
117
+ @dataclass
118
+ class SaveResponse:
119
+ """Response from save operation."""
120
+ saved: bool
121
+ timestamp: int
122
+
123
+
124
+ @dataclass
125
+ class LoadRequest:
126
+ """Request to load state."""
127
+ session_id: str
128
+
129
+
130
+ @dataclass
131
+ class LoadResponse:
132
+ """Response from load operation."""
133
+ loaded: bool
134
+ context: dict[str, Any]
135
+
136
+
137
+ # Default effect handlers
138
+
139
+ async def default_narrative_handler(effect: Effect) -> EffectResult:
140
+ """Default narrative handler for development/testing."""
141
+ payload = effect.payload
142
+
143
+ narrative = f"The world shifts around you... (action: {payload.get('action', 'unknown')})"
144
+
145
+ return EffectResult(
146
+ status=EffectStatus.SUCCESS,
147
+ data={
148
+ "narrative": narrative,
149
+ "new_location": payload.get("context", {}).get("current_location"),
150
+ }
151
+ )
152
+
153
+
154
+ async def default_effect_handler(effect: Effect) -> EffectResult:
155
+ """Default handler that returns success with no data."""
156
+ return EffectResult(
157
+ status=EffectStatus.SUCCESS,
158
+ data=None
159
+ )
160
+
161
+
162
+ # Decorator for creating effect handlers
163
+
164
+ def effect_handler(effect_type: str):
165
+ """
166
+ Decorator to register an effect handler.
167
+
168
+ Usage:
169
+ @effect_handler("NarrativeRequest")
170
+ async def handle_narrative(effect: Effect) -> EffectResult:
171
+ # Process the effect
172
+ return EffectResult(status=EffectStatus.SUCCESS, data={...})
173
+ """
174
+ def decorator(handler: EffectHandler) -> EffectHandler:
175
+ # Store the effect type on the handler for later registration
176
+ handler._effect_type = effect_type # type: ignore
177
+ return handler
178
+
179
+ return decorator
180
+
181
+
182
+ def register_effect_handlers(
183
+ registry: EffectRegistry,
184
+ handlers: dict[str, EffectHandler] | None = None
185
+ ) -> None:
186
+ """
187
+ Register multiple effect handlers with a registry.
188
+
189
+ Args:
190
+ registry: EffectRegistry to register with
191
+ handlers: Dict mapping effect type names to handlers
192
+ """
193
+ if handlers:
194
+ for effect_type, handler in handlers.items():
195
+ registry.register(effect_type, handler)
196
+
197
+
198
+ # Global registry
199
+ _global_registry: EffectRegistry | None = None
200
+
201
+
202
+ def get_effect_registry() -> EffectRegistry:
203
+ """Get the global effect registry."""
204
+ global _global_registry
205
+ if _global_registry is None:
206
+ _global_registry = EffectRegistry()
207
+ # Register default handlers
208
+ _global_registry.register("NarrativeRequest", default_narrative_handler)
209
+ _global_registry.register("Effect", default_effect_handler)
210
+ return _global_registry
211
+
212
+
213
+ def reset_effect_registry() -> None:
214
+ """Reset the global effect registry."""
215
+ global _global_registry
216
+ _global_registry = None
@@ -0,0 +1,161 @@
1
+ """
2
+ Pluggable log sinks for Orca machine audit trails.
3
+
4
+ LogSink is a Protocol — any object implementing write/close can be used.
5
+ Three sinks are bundled:
6
+
7
+ FileSink — JSONL file, one entry per line, append-safe for resume
8
+ ConsoleSink — human-readable transitions printed to stdout
9
+ MultiSink — fan-out to multiple sinks simultaneously
10
+
11
+ Usage:
12
+ from orca_runtime_python import FileSink, ConsoleSink, MultiSink
13
+
14
+ sink = MultiSink(
15
+ FileSink("./runs/exp-001/audit.jsonl"),
16
+ ConsoleSink(),
17
+ )
18
+ await run_pipeline(machines, ctx, run_id="exp-001", log_sink=sink)
19
+ sink.close()
20
+
21
+ Log entry format (dict written to each sink):
22
+ {
23
+ "ts": "2026-03-27T10:15:32.123456Z",
24
+ "run_id": "exp-001",
25
+ "machine": "TrainingLab",
26
+ "event": "DATA_READY",
27
+ "from": "data_prep",
28
+ "to": "hyper_search",
29
+ "context_delta": {"vocab_size": 65, "train_tokens": 900000}
30
+ }
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import sys
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Any, Protocol, runtime_checkable
40
+
41
+
42
+ @runtime_checkable
43
+ class LogSink(Protocol):
44
+ """Protocol for Orca audit log destinations."""
45
+
46
+ def write(self, entry: dict[str, Any]) -> None:
47
+ """Record a single log entry."""
48
+ ...
49
+
50
+ def close(self) -> None:
51
+ """Flush and release any held resources."""
52
+ ...
53
+
54
+
55
+ class FileSink:
56
+ """
57
+ Appends log entries as newline-delimited JSON (JSONL) to a file.
58
+
59
+ Opens in append mode so resumed runs extend the same audit log
60
+ rather than overwriting it.
61
+
62
+ Example:
63
+ sink = FileSink("./runs/exp-001/audit.jsonl")
64
+ sink.write({"event": "DATA_READY", ...})
65
+ sink.close()
66
+ """
67
+
68
+ def __init__(self, path: str | Path):
69
+ self._path = Path(path)
70
+ self._f = None
71
+
72
+ def _ensure_open(self) -> None:
73
+ if self._f is None:
74
+ self._path.parent.mkdir(parents=True, exist_ok=True)
75
+ self._f = open(self._path, "a", encoding="utf-8")
76
+
77
+ def write(self, entry: dict[str, Any]) -> None:
78
+ self._ensure_open()
79
+ self._f.write(json.dumps(entry, default=str) + "\n") # type: ignore[union-attr]
80
+ self._f.flush() # type: ignore[union-attr]
81
+
82
+ def close(self) -> None:
83
+ if self._f is not None:
84
+ self._f.close()
85
+ self._f = None
86
+
87
+
88
+ class ConsoleSink:
89
+ """
90
+ Prints a compact, human-readable line for each transition.
91
+
92
+ Format:
93
+ [HH:MM:SS] Machine from → to (EVENT) key=val key=val
94
+ """
95
+
96
+ def __init__(self, file=None):
97
+ self._file = file or sys.stdout
98
+
99
+ def write(self, entry: dict[str, Any]) -> None:
100
+ ts = entry.get("ts", "")
101
+ time_part = ts[11:19] if len(ts) >= 19 else ts # HH:MM:SS
102
+ machine = entry.get("machine", "")
103
+ from_s = entry.get("from", "?")
104
+ to_s = entry.get("to", "?")
105
+ event = entry.get("event", "")
106
+ delta = entry.get("context_delta", {})
107
+
108
+ delta_str = " " + " ".join(
109
+ f"{k}={v}" for k, v in delta.items()
110
+ if k != "error_message" or v
111
+ ) if delta else ""
112
+
113
+ event_str = f" ({event})" if event else ""
114
+ print(
115
+ f"[{time_part}] {machine:<14} {from_s} → {to_s}{event_str}{delta_str}",
116
+ file=self._file,
117
+ )
118
+
119
+ def close(self) -> None:
120
+ pass
121
+
122
+
123
+ class MultiSink:
124
+ """
125
+ Fan-out sink that writes each entry to multiple sinks.
126
+
127
+ Example:
128
+ sink = MultiSink(FileSink("audit.jsonl"), ConsoleSink())
129
+ """
130
+
131
+ def __init__(self, *sinks: LogSink):
132
+ self._sinks = list(sinks)
133
+
134
+ def write(self, entry: dict[str, Any]) -> None:
135
+ for sink in self._sinks:
136
+ sink.write(entry)
137
+
138
+ def close(self) -> None:
139
+ for sink in self._sinks:
140
+ sink.close()
141
+
142
+
143
+ def _make_entry(
144
+ *,
145
+ run_id: str,
146
+ machine: str,
147
+ event: str,
148
+ from_state: str,
149
+ to_state: str,
150
+ context_delta: dict[str, Any],
151
+ ) -> dict[str, Any]:
152
+ """Build a standard log entry dict."""
153
+ return {
154
+ "ts": datetime.now(timezone.utc).isoformat(),
155
+ "run_id": run_id,
156
+ "machine": machine,
157
+ "event": event,
158
+ "from": from_state,
159
+ "to": to_state,
160
+ "context_delta": context_delta,
161
+ }