operator-architecture 0.2.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
+ """Operator Architecture — framework-agnostic multi-agent orchestration SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from operator_architecture.agent import (
6
+ AgentHandle,
7
+ AgentRequest,
8
+ AgentResult,
9
+ AgentRunner,
10
+ AgentSpec,
11
+ ObjectiveSlot,
12
+ callable_agent,
13
+ )
14
+ from operator_architecture.coordinator import DEFAULT_COORDINATOR_SKILL, Coordinator
15
+ from operator_architecture.machine import StateMachine
16
+ from operator_architecture.messages import Message, Messages
17
+ from operator_architecture.orchestration import openai_tool_schema, tool_schemas
18
+ from operator_architecture.streaming import StreamEvent, StreamingCallback, emit_stream
19
+
20
+ __all__ = [
21
+ "AgentHandle",
22
+ "AgentRequest",
23
+ "AgentResult",
24
+ "AgentRunner",
25
+ "AgentSpec",
26
+ "Coordinator",
27
+ "DEFAULT_COORDINATOR_SKILL",
28
+ "Message",
29
+ "Messages",
30
+ "ObjectiveSlot",
31
+ "StateMachine",
32
+ "StreamEvent",
33
+ "StreamingCallback",
34
+ "callable_agent",
35
+ "emit_stream",
36
+ "openai_tool_schema",
37
+ "tool_schemas",
38
+ ]
39
+
40
+ __version__ = "0.2.0"
@@ -0,0 +1,157 @@
1
+ """Agent registration and runner protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Protocol, runtime_checkable
8
+
9
+ from operator_architecture.streaming import StreamingCallback
10
+
11
+
12
+ @dataclass
13
+ class AgentRequest:
14
+ """Input handed to a host ``AgentRunner`` when an objective is run."""
15
+
16
+ agent: str
17
+ objective: str
18
+ skill: str
19
+ messages: list[dict[str, Any]]
20
+ checklist: list[str] | None = None
21
+ agent_props: dict[str, Any] | None = None
22
+ model: str | None = None
23
+ metadata: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ @dataclass
27
+ class AgentResult:
28
+ """Output from a host agent — staged onto the objective slot."""
29
+
30
+ content: str
31
+ messages: list[dict[str, Any]] | None = None
32
+ usage: dict[str, Any] | None = None
33
+ raw: Any = None
34
+
35
+
36
+ @runtime_checkable
37
+ class AgentRunner(Protocol):
38
+ """Host-provided agent runtime (Relay, LangChain, HTTP, callable, …)."""
39
+
40
+ async def run(
41
+ self,
42
+ request: AgentRequest,
43
+ *,
44
+ streaming_callback: StreamingCallback = None,
45
+ ) -> AgentResult: ...
46
+
47
+
48
+ @dataclass
49
+ class AgentSpec:
50
+ """Register a named sub-agent with the state machine."""
51
+
52
+ name: str
53
+ description: str
54
+ skill: str
55
+ runner: AgentRunner
56
+ model: str | None = None
57
+ metadata: dict[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ @dataclass
61
+ class ObjectiveSlot:
62
+ """One commissioned objective for a sub-agent."""
63
+
64
+ index: int
65
+ agent: str
66
+ objective: str
67
+ checklist: list[str] = field(default_factory=list)
68
+ agent_props: dict[str, Any] = field(default_factory=dict)
69
+ messages: list[dict[str, Any]] = field(default_factory=list)
70
+ agent_message: str | None = None
71
+ result: dict[str, Any] | None = None
72
+ status: str = "pending" # pending | running | staged | accepted | failed
73
+ model: str | None = None
74
+ duration_ms: float | None = None
75
+ commission_id: str = ""
76
+
77
+ def __post_init__(self) -> None:
78
+ if not self.commission_id:
79
+ self.commission_id = f"{self.agent}-{self.index}"
80
+
81
+
82
+ @dataclass
83
+ class AgentHandle:
84
+ """Runtime handle for one registered agent + its objective slots."""
85
+
86
+ spec: AgentSpec
87
+ slots: list[ObjectiveSlot] = field(default_factory=list)
88
+
89
+ @property
90
+ def name(self) -> str:
91
+ return self.spec.name
92
+
93
+ @property
94
+ def runner(self) -> AgentRunner:
95
+ return self.spec.runner
96
+
97
+ def __len__(self) -> int:
98
+ return len(self.slots)
99
+
100
+ def __getitem__(self, index: int) -> ObjectiveSlot:
101
+ if index < 1 or index > len(self.slots):
102
+ raise IndexError(
103
+ f"{self.name} has {len(self.slots)} objectives; "
104
+ f"requested index {index} (1-based)"
105
+ )
106
+ return self.slots[index - 1]
107
+
108
+ def next_index(self) -> int:
109
+ return len(self.slots) + 1
110
+
111
+ def create_objective(
112
+ self,
113
+ objective: str,
114
+ *,
115
+ checklist: list[str] | None = None,
116
+ agent_props: dict[str, Any] | None = None,
117
+ ) -> ObjectiveSlot:
118
+ idx = self.next_index()
119
+ slot = ObjectiveSlot(
120
+ index=idx,
121
+ agent=self.name,
122
+ objective=objective,
123
+ checklist=list(checklist or []),
124
+ agent_props=dict(agent_props or {}),
125
+ model=self.spec.model,
126
+ messages=[
127
+ {"role": "system", "content": self.spec.skill},
128
+ ],
129
+ )
130
+ self.slots.append(slot)
131
+ return slot
132
+
133
+ def objectives(self) -> list[ObjectiveSlot]:
134
+ return list(self.slots)
135
+
136
+
137
+ def callable_agent(
138
+ fn: Callable[[AgentRequest], Awaitable[AgentResult] | AgentResult],
139
+ ) -> AgentRunner:
140
+ """Wrap an async/sync callable as an ``AgentRunner``."""
141
+
142
+ class _CallableRunner:
143
+ async def run(
144
+ self,
145
+ request: AgentRequest,
146
+ *,
147
+ streaming_callback: StreamingCallback = None,
148
+ ) -> AgentResult:
149
+ _ = streaming_callback
150
+ result = fn(request)
151
+ if hasattr(result, "__await__"):
152
+ result = await result # type: ignore[misc]
153
+ if isinstance(result, AgentResult):
154
+ return result
155
+ return AgentResult(content=str(result))
156
+
157
+ return _CallableRunner()
@@ -0,0 +1,42 @@
1
+ """Coordinator configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from operator_architecture.agent import AgentRunner
9
+
10
+ DEFAULT_COORDINATOR_SKILL = """\
11
+ You are the **Operator coordinator**. You are the sole interface to the user.
12
+
13
+ You do NOT perform domain work yourself. Commission specialized agents, then \
14
+ review their staged messages before accepting results into the core conversation.
15
+
16
+ ## Orchestration tools
17
+ - `list_agents()` — discover registered agents
18
+ - `commission(agent, objective, checklist=None, agent_props=None)` — run a junior
19
+ - `get_agent_message(agent, index)` — peek staged junior prose
20
+ - `accept_agent_result(agent, index)` — attach compact result to the core thread
21
+ - `instruct_agent(agent, index, message)` — continue a junior objective
22
+ - `list_objectives(agent=None)` — status board
23
+
24
+ ## Rules
25
+ - Prefer listing agents before commissioning when unsure what exists.
26
+ - After each commission, review the staged message: accept or instruct.
27
+ - Summarize accepted results clearly for the user.
28
+ """
29
+
30
+
31
+ @dataclass
32
+ class Coordinator:
33
+ """Coordinator persona — context owned by the StateMachine.
34
+
35
+ If ``runner`` is set, ``StateMachine.run`` will invoke it with orchestration
36
+ tools. If unset, the host drives orchestration via SM methods only.
37
+ """
38
+
39
+ skill: str = DEFAULT_COORDINATOR_SKILL
40
+ runner: AgentRunner | None = None
41
+ model: str | None = None
42
+ metadata: dict[str, Any] = field(default_factory=dict)
@@ -0,0 +1,507 @@
1
+ """StateMachine — orchestration engine for the Operator Architecture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ from operator_architecture.agent import (
10
+ AgentHandle,
11
+ AgentRequest,
12
+ AgentResult,
13
+ AgentSpec,
14
+ ObjectiveSlot,
15
+ )
16
+ from operator_architecture.coordinator import Coordinator
17
+ from operator_architecture.messages import Messages
18
+ from operator_architecture.orchestration import attach_schema, openai_tool_schema, tool_schemas
19
+ from operator_architecture.streaming import StreamingCallback, emit_stream
20
+
21
+
22
+ class StateMachine:
23
+ """Framework-agnostic multi-agent orchestration engine.
24
+
25
+ Owns coordinator context, sub-agent registry, and the commission → stage →
26
+ accept/instruct lifecycle. Does **not** call LLMs — hosts supply
27
+ ``AgentRunner`` implementations.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ coordinator: Coordinator | None = None,
33
+ agents: Sequence[AgentSpec] | None = None,
34
+ ) -> None:
35
+ self.coordinator = coordinator or Coordinator()
36
+ self.coordinator_messages = Messages().system(self.coordinator.skill)
37
+ self.active_agent: str | None = None
38
+ self._streaming_callback: StreamingCallback = None
39
+ self._agents: dict[str, AgentHandle] = {}
40
+ for spec in agents or []:
41
+ self.add_agent(spec)
42
+
43
+ # ── registry ──────────────────────────────────────────────────────
44
+
45
+ def add_agent(self, spec: AgentSpec) -> AgentHandle:
46
+ if not spec.name or spec.name == "coordinator":
47
+ raise ValueError("agent name required and must not be 'coordinator'")
48
+ if spec.name in self._agents:
49
+ raise ValueError(f"agent already registered: {spec.name}")
50
+ handle = AgentHandle(spec=spec)
51
+ self._agents[spec.name] = handle
52
+ return handle
53
+
54
+ def agent(self, name: str) -> AgentHandle:
55
+ if name not in self._agents:
56
+ raise KeyError(f"Unknown agent: {name}")
57
+ return self._agents[name]
58
+
59
+ def agents(self) -> list[AgentHandle]:
60
+ return list(self._agents.values())
61
+
62
+ def set_streaming_callback(self, callback: StreamingCallback) -> None:
63
+ self._streaming_callback = callback
64
+
65
+ # ── discovery / status ────────────────────────────────────────────
66
+
67
+ def list_agents(self) -> list[dict[str, Any]]:
68
+ """Return registered agents (name, description, model, metadata keys)."""
69
+ out: list[dict[str, Any]] = []
70
+ for handle in self._agents.values():
71
+ spec = handle.spec
72
+ entry: dict[str, Any] = {
73
+ "name": spec.name,
74
+ "description": spec.description,
75
+ "model": spec.model,
76
+ }
77
+ meta_tools = spec.metadata.get("tools")
78
+ if meta_tools is not None:
79
+ entry["tools"] = meta_tools
80
+ out.append(entry)
81
+ return out
82
+
83
+ def list_objectives(self, agent: str | None = None) -> list[dict[str, Any]]:
84
+ """Return objective status board for one agent or all."""
85
+ handles = [self.agent(agent)] if agent else self.agents()
86
+ rows: list[dict[str, Any]] = []
87
+ for handle in handles:
88
+ for slot in handle.objectives():
89
+ rows.append(
90
+ {
91
+ "agent": slot.agent,
92
+ "index": slot.index,
93
+ "commission_id": slot.commission_id,
94
+ "objective": slot.objective,
95
+ "status": slot.status,
96
+ "model": slot.model,
97
+ "duration_ms": slot.duration_ms,
98
+ }
99
+ )
100
+ return rows
101
+
102
+ def get_agent_message(self, agent: str, index: int) -> dict[str, Any]:
103
+ """Peek staged junior prose without accepting it."""
104
+ slot = self.agent(agent)[index]
105
+ return {
106
+ "agent": agent,
107
+ "index": index,
108
+ "status": slot.status,
109
+ "agent_message": slot.agent_message,
110
+ "objective": slot.objective,
111
+ }
112
+
113
+ # ── commission / instruct / accept ────────────────────────────────
114
+
115
+ async def commission(
116
+ self,
117
+ agent: str,
118
+ objective: str,
119
+ checklist: list[str] | None = None,
120
+ agent_props: dict[str, Any] | None = None,
121
+ *,
122
+ streaming_callback: StreamingCallback = None,
123
+ ) -> dict[str, Any]:
124
+ """Create an objective slot, run the host agent, stage the result."""
125
+ goal = (objective or "").strip()
126
+ if not goal:
127
+ return {"error": "missing_objective", "detail": "objective is required."}
128
+
129
+ handle = self.agent(agent)
130
+ slot = handle.create_objective(
131
+ goal, checklist=checklist, agent_props=agent_props
132
+ )
133
+ slot.status = "running"
134
+ self.active_agent = agent
135
+ cb = streaming_callback or self._streaming_callback
136
+
137
+ await emit_stream(
138
+ cb,
139
+ {
140
+ "agent": agent,
141
+ "phase": "commissioned",
142
+ "detail": goal,
143
+ "model": slot.model or "",
144
+ "objective": goal,
145
+ "commission_id": slot.commission_id,
146
+ "index": slot.index,
147
+ },
148
+ )
149
+
150
+ brief = self._build_brief(goal, checklist, agent_props)
151
+ slot.messages.append({"role": "user", "content": brief})
152
+
153
+ return await self._run_slot(handle, slot, streaming_callback=cb)
154
+
155
+ async def instruct(
156
+ self,
157
+ agent: str,
158
+ index: int,
159
+ message: str,
160
+ *,
161
+ streaming_callback: StreamingCallback = None,
162
+ ) -> dict[str, Any]:
163
+ """Append instruction to a slot and re-run the host agent."""
164
+ text = (message or "").strip()
165
+ if not text:
166
+ return {"error": "empty_message"}
167
+ handle = self.agent(agent)
168
+ try:
169
+ slot = handle[index]
170
+ except IndexError as exc:
171
+ return {"error": "bad_index", "detail": str(exc)}
172
+
173
+ slot.messages.append({"role": "user", "content": text})
174
+ slot.status = "running"
175
+ self.active_agent = agent
176
+ cb = streaming_callback or self._streaming_callback
177
+ return await self._run_slot(handle, slot, streaming_callback=cb)
178
+
179
+ def accept(self, agent: str, index: int) -> dict[str, Any]:
180
+ """Accept staged result into the coordinator core thread (compact)."""
181
+ return self.accept_agent_result(agent, index)
182
+
183
+ def accept_agent_result(self, agent: str, index: int) -> dict[str, Any]:
184
+ """Attach compact slot.result onto coordinator messages context."""
185
+ if agent not in self._agents:
186
+ return {"error": "unknown_agent", "agent": agent}
187
+ try:
188
+ slot = self.agent(agent)[index]
189
+ except IndexError as exc:
190
+ return {"error": "bad_index", "detail": str(exc)}
191
+
192
+ if slot.agent_message is None and slot.result is None:
193
+ return {"error": "nothing_staged", "agent": agent, "index": index}
194
+
195
+ compact = slot.result or {
196
+ "status": "accepted",
197
+ "agent": agent,
198
+ "index": index,
199
+ "report": (slot.agent_message or "")[:2000],
200
+ "objective": slot.objective,
201
+ "model": slot.model,
202
+ "duration_ms": slot.duration_ms,
203
+ }
204
+ compact = {**compact, "status": "accepted"}
205
+ slot.result = compact
206
+ slot.status = "accepted"
207
+
208
+ # Hosts attach this return value to the coordinator thread (tool result
209
+ # or manual append). OA does not invent a second copy here.
210
+ return {
211
+ "status": "accepted",
212
+ "agent": agent,
213
+ "index": index,
214
+ "result": compact,
215
+ }
216
+
217
+ # aliases matching tool names
218
+ async def instruct_agent(
219
+ self,
220
+ agent: str,
221
+ index: int,
222
+ message: str,
223
+ *,
224
+ streaming_callback: StreamingCallback = None,
225
+ ) -> dict[str, Any]:
226
+ return await self.instruct(
227
+ agent, index, message, streaming_callback=streaming_callback
228
+ )
229
+
230
+ # ── coordinator turn ──────────────────────────────────────────────
231
+
232
+ async def run(
233
+ self,
234
+ user_text: str,
235
+ *,
236
+ streaming_callback: StreamingCallback = None,
237
+ orchestration_tools: bool = True,
238
+ ) -> str:
239
+ """Append user text and invoke the coordinator runner (if configured).
240
+
241
+ OA does not execute a tool loop. The host ``Coordinator.runner`` owns
242
+ inference/tool-calling and may call back into orchestration tools.
243
+ """
244
+ text = (user_text or "").strip()
245
+ if not text:
246
+ return ""
247
+ if self.coordinator.runner is None:
248
+ raise RuntimeError(
249
+ "Coordinator.runner is not set. "
250
+ "Provide a runner for sm.run(), or call sm.commission/accept directly."
251
+ )
252
+
253
+ self.coordinator_messages.user(text)
254
+ self.active_agent = "coordinator"
255
+ cb = streaming_callback or self._streaming_callback
256
+ self._streaming_callback = cb
257
+
258
+ await emit_stream(
259
+ cb,
260
+ {
261
+ "agent": "coordinator",
262
+ "phase": "start",
263
+ "detail": "starting",
264
+ "model": self.coordinator.model or "",
265
+ },
266
+ )
267
+
268
+ tools = self.orchestration_tools() if orchestration_tools else []
269
+ request = AgentRequest(
270
+ agent="coordinator",
271
+ objective=text,
272
+ skill=self.coordinator.skill,
273
+ messages=self.coordinator_messages.to_list(),
274
+ model=self.coordinator.model,
275
+ metadata={
276
+ "tools": tools,
277
+ "tool_schemas": tool_schemas(tools),
278
+ **self.coordinator.metadata,
279
+ },
280
+ )
281
+
282
+ try:
283
+ result = await self.coordinator.runner.run(
284
+ request, streaming_callback=cb
285
+ )
286
+ if result.messages is not None:
287
+ self.coordinator_messages.replace(result.messages)
288
+ elif result.content:
289
+ self.coordinator_messages.assistant(result.content)
290
+ reply = (result.content or "").strip() or "(no response)"
291
+ await emit_stream(
292
+ cb,
293
+ {
294
+ "agent": "coordinator",
295
+ "phase": "done",
296
+ "detail": reply[:500],
297
+ "model": self.coordinator.model or "",
298
+ "status": "done",
299
+ },
300
+ )
301
+ return reply
302
+ except Exception as exc: # noqa: BLE001
303
+ err = f"Coordinator error: {type(exc).__name__}: {exc}"
304
+ await emit_stream(
305
+ cb,
306
+ {
307
+ "agent": "coordinator",
308
+ "phase": "fail",
309
+ "detail": err,
310
+ "status": "failed",
311
+ },
312
+ )
313
+ raise
314
+ finally:
315
+ self.active_agent = None
316
+ if streaming_callback is not None:
317
+ # only clear if we temporarily owned the callback for this call
318
+ pass
319
+
320
+ def orchestration_tools(self) -> list:
321
+ """Return plain callables + OpenAI schemas for host coordinator runners."""
322
+ sm = self
323
+
324
+ async def list_agents() -> list[dict[str, Any]]:
325
+ """List registered sub-agents with descriptions."""
326
+ return sm.list_agents()
327
+
328
+ async def commission(
329
+ agent: str,
330
+ objective: str,
331
+ checklist: list[str] | None = None,
332
+ agent_props: dict[str, Any] | None = None,
333
+ ) -> dict[str, Any]:
334
+ """Commission a sub-agent. Result is staged until accept_agent_result."""
335
+ return await sm.commission(
336
+ agent, objective, checklist=checklist, agent_props=agent_props
337
+ )
338
+
339
+ async def get_agent_message(agent: str, index: int) -> dict[str, Any]:
340
+ """Peek a staged sub-agent message without accepting it."""
341
+ return sm.get_agent_message(agent, index)
342
+
343
+ async def accept_agent_result(agent: str, index: int) -> dict[str, Any]:
344
+ """Accept a staged result into the coordinator core thread."""
345
+ return sm.accept_agent_result(agent, index)
346
+
347
+ async def instruct_agent(
348
+ agent: str, index: int, message: str
349
+ ) -> dict[str, Any]:
350
+ """Send further instruction to a sub-agent objective and re-run it."""
351
+ return await sm.instruct(agent, index, message)
352
+
353
+ async def list_objectives(agent: str | None = None) -> list[dict[str, Any]]:
354
+ """List objective slots and their statuses."""
355
+ return sm.list_objectives(agent)
356
+
357
+ tools = [
358
+ list_agents,
359
+ commission,
360
+ get_agent_message,
361
+ accept_agent_result,
362
+ instruct_agent,
363
+ list_objectives,
364
+ ]
365
+ for fn in tools:
366
+ attach_schema(fn, openai_tool_schema(fn))
367
+ return tools
368
+
369
+ # ── internals ─────────────────────────────────────────────────────
370
+
371
+ def _build_brief(
372
+ self,
373
+ objective: str,
374
+ checklist: list[str] | None,
375
+ agent_props: dict[str, Any] | None,
376
+ ) -> str:
377
+ parts = [f"Objective:\n{objective.strip()}"]
378
+ if checklist:
379
+ parts.append(
380
+ "Checklist:\n"
381
+ + "\n".join(f"- {item}" for item in checklist if str(item).strip())
382
+ )
383
+ if agent_props:
384
+ props_lines = [f"- {k}: {v}" for k, v in agent_props.items()]
385
+ parts.append("Agent props:\n" + "\n".join(props_lines))
386
+ return "\n\n".join(parts)
387
+
388
+ async def _run_slot(
389
+ self,
390
+ handle: AgentHandle,
391
+ slot: ObjectiveSlot,
392
+ *,
393
+ streaming_callback: StreamingCallback = None,
394
+ ) -> dict[str, Any]:
395
+ await emit_stream(
396
+ streaming_callback,
397
+ {
398
+ "agent": handle.name,
399
+ "phase": "start",
400
+ "detail": "starting",
401
+ "model": slot.model or "",
402
+ "commission_id": slot.commission_id,
403
+ "index": slot.index,
404
+ "objective": slot.objective,
405
+ },
406
+ )
407
+
408
+ started = time.perf_counter()
409
+ request = AgentRequest(
410
+ agent=handle.name,
411
+ objective=slot.objective,
412
+ skill=handle.spec.skill,
413
+ messages=list(slot.messages),
414
+ checklist=list(slot.checklist) or None,
415
+ agent_props=dict(slot.agent_props) or None,
416
+ model=slot.model,
417
+ metadata=dict(handle.spec.metadata),
418
+ )
419
+
420
+ try:
421
+ result: AgentResult = await handle.runner.run(
422
+ request, streaming_callback=streaming_callback
423
+ )
424
+ duration_ms = (time.perf_counter() - started) * 1000
425
+ report = (result.content or "").strip() or "(empty report)"
426
+ if result.messages is not None:
427
+ slot.messages = list(result.messages)
428
+ else:
429
+ slot.messages.append({"role": "assistant", "content": report})
430
+
431
+ slot.agent_message = report
432
+ slot.duration_ms = duration_ms
433
+ slot.status = "staged"
434
+ slot.result = {
435
+ "status": "staged",
436
+ "agent": handle.name,
437
+ "index": slot.index,
438
+ "report": report[:2000],
439
+ "duration_ms": duration_ms,
440
+ "model": slot.model,
441
+ "objective": slot.objective,
442
+ }
443
+ await emit_stream(
444
+ streaming_callback,
445
+ {
446
+ "agent": handle.name,
447
+ "phase": "done",
448
+ "detail": report[:500],
449
+ "model": slot.model or "",
450
+ "commission_id": slot.commission_id,
451
+ "status": "staged",
452
+ "duration_ms": duration_ms,
453
+ "objective": slot.objective,
454
+ "index": slot.index,
455
+ },
456
+ )
457
+ self.active_agent = None
458
+ return {
459
+ "status": "staged",
460
+ "agent": handle.name,
461
+ "index": slot.index,
462
+ "commission_id": slot.commission_id,
463
+ "hint": (
464
+ f"Junior reply staged at sm.agent('{handle.name}')[{slot.index}].agent_message. "
465
+ f"Call accept_agent_result('{handle.name}', {slot.index}) to attach "
466
+ f"the compact result to the core thread, or instruct_agent(...) to continue."
467
+ ),
468
+ "preview": report[:400],
469
+ "duration_ms": duration_ms,
470
+ "model": slot.model,
471
+ }
472
+ except Exception as exc: # noqa: BLE001
473
+ duration_ms = (time.perf_counter() - started) * 1000
474
+ slot.status = "failed"
475
+ slot.duration_ms = duration_ms
476
+ slot.agent_message = f"{type(exc).__name__}: {exc}"
477
+ slot.result = {
478
+ "status": "failed",
479
+ "agent": handle.name,
480
+ "index": slot.index,
481
+ "report": slot.agent_message,
482
+ "duration_ms": duration_ms,
483
+ "model": slot.model,
484
+ "objective": slot.objective,
485
+ }
486
+ await emit_stream(
487
+ streaming_callback,
488
+ {
489
+ "agent": handle.name,
490
+ "phase": "fail",
491
+ "detail": slot.agent_message,
492
+ "model": slot.model or "",
493
+ "commission_id": slot.commission_id,
494
+ "status": "failed",
495
+ "duration_ms": duration_ms,
496
+ "index": slot.index,
497
+ },
498
+ )
499
+ self.active_agent = None
500
+ return {
501
+ "status": "failed",
502
+ "agent": handle.name,
503
+ "index": slot.index,
504
+ "report": slot.agent_message,
505
+ "duration_ms": duration_ms,
506
+ "model": slot.model,
507
+ }
@@ -0,0 +1,65 @@
1
+ """OpenAI chat-completions compatible message helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import Any
7
+
8
+
9
+ Message = dict[str, Any]
10
+
11
+
12
+ class Messages:
13
+ """Mutable list of OpenAI-shaped chat messages.
14
+
15
+ Compatible with chat.completions payloads: roles ``system``, ``user``,
16
+ ``assistant``, ``tool`` plus optional ``tool_calls`` / ``tool_call_id``.
17
+ """
18
+
19
+ def __init__(self, messages: list[Message] | None = None) -> None:
20
+ self._messages: list[Message] = list(messages or [])
21
+
22
+ def __iter__(self):
23
+ return iter(self._messages)
24
+
25
+ def __len__(self) -> int:
26
+ return len(self._messages)
27
+
28
+ def __getitem__(self, index: int) -> Message:
29
+ return self._messages[index]
30
+
31
+ def to_list(self) -> list[Message]:
32
+ return deepcopy(self._messages)
33
+
34
+ def clear(self) -> None:
35
+ self._messages.clear()
36
+
37
+ def append(self, message: Message) -> Messages:
38
+ self._messages.append(dict(message))
39
+ return self
40
+
41
+ def system(self, content: str) -> Messages:
42
+ return self.append({"role": "system", "content": content})
43
+
44
+ def user(self, content: str) -> Messages:
45
+ return self.append({"role": "user", "content": content})
46
+
47
+ def assistant(self, content: str, *, tool_calls: list[dict] | None = None) -> Messages:
48
+ msg: Message = {"role": "assistant", "content": content}
49
+ if tool_calls is not None:
50
+ msg["tool_calls"] = tool_calls
51
+ return self.append(msg)
52
+
53
+ def tool(self, content: str, *, tool_call_id: str) -> Messages:
54
+ return self.append(
55
+ {"role": "tool", "content": content, "tool_call_id": tool_call_id}
56
+ )
57
+
58
+ def extend(self, messages: list[Message]) -> Messages:
59
+ for m in messages:
60
+ self.append(m)
61
+ return self
62
+
63
+ def replace(self, messages: list[Message]) -> Messages:
64
+ self._messages = [dict(m) for m in messages]
65
+ return self
@@ -0,0 +1,88 @@
1
+ """OpenAI tool schemas + callables for coordinator orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+
10
+ def openai_tool_schema(
11
+ fn: Callable[..., Any],
12
+ *,
13
+ name: str | None = None,
14
+ description: str | None = None,
15
+ ) -> dict[str, Any]:
16
+ """Build a chat.completions tools[] entry from a callable's signature/doc."""
17
+ tool_name = name or fn.__name__
18
+ doc = (description or inspect.getdoc(fn) or "").strip()
19
+ sig = inspect.signature(fn)
20
+ properties: dict[str, Any] = {}
21
+ required: list[str] = []
22
+
23
+ hints = {}
24
+ try:
25
+ hints = fn.__annotations__
26
+ except Exception:
27
+ hints = {}
28
+
29
+ for pname, param in sig.parameters.items():
30
+ if pname in {"self", "cls"}:
31
+ continue
32
+ prop: dict[str, Any] = {"type": _json_type(hints.get(pname, str))}
33
+ properties[pname] = prop
34
+ if param.default is inspect.Parameter.empty:
35
+ required.append(pname)
36
+
37
+ return {
38
+ "type": "function",
39
+ "function": {
40
+ "name": tool_name,
41
+ "description": doc.split("\n\n")[0] if doc else tool_name,
42
+ "parameters": {
43
+ "type": "object",
44
+ "properties": properties,
45
+ "required": required,
46
+ },
47
+ },
48
+ }
49
+
50
+
51
+ def _json_type(annotation: Any) -> str:
52
+ origin = getattr(annotation, "__origin__", None)
53
+ if origin is list:
54
+ return "array"
55
+ if origin is dict:
56
+ return "object"
57
+ name = getattr(annotation, "__name__", "") or str(annotation)
58
+ mapping = {
59
+ "str": "string",
60
+ "int": "integer",
61
+ "float": "number",
62
+ "bool": "boolean",
63
+ "list": "array",
64
+ "dict": "object",
65
+ }
66
+ # Optional[X] / X | None
67
+ if "None" in str(annotation) or "Optional" in str(annotation):
68
+ args = getattr(annotation, "__args__", ())
69
+ for a in args:
70
+ if a is not type(None):
71
+ return _json_type(a)
72
+ return mapping.get(name, "string")
73
+
74
+
75
+ def attach_schema(fn: Callable[..., Any], schema: dict[str, Any]) -> Callable[..., Any]:
76
+ """Attach OpenAI tool schema on the callable for host runners."""
77
+ setattr(fn, "__oa_tool_schema__", schema)
78
+ return fn
79
+
80
+
81
+ def tool_schemas(tools: list[Callable[..., Any]]) -> list[dict[str, Any]]:
82
+ out: list[dict[str, Any]] = []
83
+ for fn in tools:
84
+ schema = getattr(fn, "__oa_tool_schema__", None)
85
+ if schema is None:
86
+ schema = openai_tool_schema(fn)
87
+ out.append(schema)
88
+ return out
@@ -0,0 +1,38 @@
1
+ """Streaming / observability types for Operator Architecture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any, TypedDict
7
+
8
+
9
+ class StreamEvent(TypedDict, total=False):
10
+ """Structured event forwarded from host agents or emitted by the SM."""
11
+
12
+ agent: str
13
+ phase: str # start | token | tool_start | tool_result | tool_error | commissioned | done | fail
14
+ detail: str
15
+ model: str
16
+ objective: str
17
+ commission_id: str
18
+ status: str
19
+ duration_ms: float
20
+ index: int
21
+
22
+
23
+ StreamingCallback = (
24
+ Callable[[StreamEvent], None]
25
+ | Callable[[StreamEvent], Awaitable[None]]
26
+ | None
27
+ )
28
+
29
+
30
+ async def emit_stream(
31
+ callback: StreamingCallback,
32
+ event: StreamEvent | dict[str, Any],
33
+ ) -> None:
34
+ if callback is None:
35
+ return
36
+ result = callback(event) # type: ignore[arg-type]
37
+ if hasattr(result, "__await__"):
38
+ await result # type: ignore[misc]
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.5
2
+ Name: operator-architecture
3
+ Version: 0.2.0
4
+ Summary: Framework-agnostic multi-agent orchestration SDK (Operator Architecture)
5
+ Requires-Python: >=3.11
6
+ Provides-Extra: dev
7
+ Requires-Dist: pytest>=8.0; extra == 'dev'
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Operator Architecture
11
+
12
+ **Framework-agnostic multi-agent orchestration SDK.**
13
+
14
+ Operator Architecture (OA) manages **state**, **context**, **sub-agents**, and **orchestration**. It does **not** call LLMs, run tool loops, or ship coding tools. You plug in any agent runtime — Relay, LangChain, OpenAI Agents, HTTP services, or a plain async function.
15
+
16
+ For the earlier coding-CLI / five-pillars vision, see [ORIGINAL_CONCEPT.md](ORIGINAL_CONCEPT.md).
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install -e .
22
+ # or
23
+ uv pip install -e .
24
+ ```
25
+
26
+ Runtime dependencies: **none** (stdlib only).
27
+
28
+ ```python
29
+ from operator_architecture import (
30
+ StateMachine,
31
+ Coordinator,
32
+ AgentSpec,
33
+ AgentRequest,
34
+ AgentResult,
35
+ callable_agent,
36
+ )
37
+ ```
38
+
39
+ ## What OA owns vs what you own
40
+
41
+ | Operator Architecture | Your host |
42
+ |-----------------------|-----------|
43
+ | `StateMachine`, `Coordinator`, `AgentSpec` | Agent implementations (`AgentRunner`) |
44
+ | OpenAI-compatible message threads | Relay / LangChain / custom loops |
45
+ | `commission` → stage → `accept` / `instruct` | Models, API keys, tools, MCP, FS |
46
+ | Optional `streaming_callback` fan-in | Emitting stream events from runners |
47
+
48
+ ## Core objects
49
+
50
+ ### `AgentSpec` + `AgentRunner`
51
+
52
+ Register any number of sub-agents. Each needs a **runner** OA will call:
53
+
54
+ ```python
55
+ async def research(request: AgentRequest) -> AgentResult:
56
+ # call Relay, LangChain, HTTP, … — OA does not care
57
+ return AgentResult(content=f"Findings for: {request.objective}")
58
+
59
+ researcher = AgentSpec(
60
+ name="researcher",
61
+ description="Read-only exploration",
62
+ skill="You are a careful researcher. Answer with concrete findings.",
63
+ runner=callable_agent(research),
64
+ model="my-model", # metadata only
65
+ )
66
+ ```
67
+
68
+ Protocol:
69
+
70
+ ```python
71
+ class AgentRunner(Protocol):
72
+ async def run(
73
+ self,
74
+ request: AgentRequest,
75
+ *,
76
+ streaming_callback: StreamingCallback = None,
77
+ ) -> AgentResult: ...
78
+ ```
79
+
80
+ `AgentRequest` carries OpenAI-shaped `messages`, `objective`, `skill`, optional `checklist` / `agent_props`.
81
+ `AgentResult.content` is staged as `agent_message`.
82
+
83
+ ### `Coordinator`
84
+
85
+ Owns the user-facing skill string and optional runner for `sm.run()`:
86
+
87
+ ```python
88
+ coordinator = Coordinator(
89
+ skill="You operate the state machine…", # default skill provided
90
+ runner=my_coordinator_runner, # optional
91
+ model="coord-model",
92
+ )
93
+ ```
94
+
95
+ ### `StateMachine`
96
+
97
+ ```python
98
+ sm = StateMachine(coordinator=coordinator, agents=[researcher])
99
+ ```
100
+
101
+ No process-global singleton — hold the instance yourself (`sm = StateMachine(...)`).
102
+
103
+ ## Lifecycle
104
+
105
+ ```text
106
+ user → (optional) sm.run / host
107
+ → commission(agent, objective)
108
+ → AgentRunner.run(AgentRequest)
109
+ → stage agent_message (status=staged)
110
+ → accept_agent_result(agent, index) # compact result for core thread
111
+ or instruct_agent(agent, index, message) # continue junior
112
+ ```
113
+
114
+ ### Direct API (always available)
115
+
116
+ ```python
117
+ staged = await sm.commission("researcher", "Find all uses of vLLM")
118
+ msg = sm.get_agent_message("researcher", 1)
119
+ accepted = sm.accept("researcher", 1) # or accept_agent_result
120
+ # or:
121
+ await sm.instruct("researcher", 1, "Also check Dockerfiles")
122
+
123
+ sm.list_agents()
124
+ sm.list_objectives()
125
+ ```
126
+
127
+ Indexed access: `sm.agent("researcher")[1].agent_message`.
128
+
129
+ ### `sm.run` (optional)
130
+
131
+ If `Coordinator.runner` is set, `await sm.run(user_text, streaming_callback=...)` appends the user message and invokes that runner with:
132
+
133
+ - `metadata["tools"]` — orchestration callables
134
+ - `metadata["tool_schemas"]` — OpenAI `tools[]` schemas
135
+
136
+ **OA does not execute a tool loop.** Your runner (Relay, LangChain, …) must invoke those callables when the model requests them.
137
+
138
+ ```python
139
+ tools = sm.orchestration_tools()
140
+ # list_agents, commission, get_agent_message,
141
+ # accept_agent_result, instruct_agent, list_objectives
142
+ ```
143
+
144
+ ## OpenAI-compatible context
145
+
146
+ Coordinator and junior threads are lists of chat.completions-style dicts:
147
+
148
+ ```python
149
+ {"role": "system"|"user"|"assistant"|"tool", "content": "...", ...}
150
+ ```
151
+
152
+ Helpers: `Messages` (`.system()`, `.user()`, `.assistant()`, `.to_list()`).
153
+
154
+ ## `streaming_callback`
155
+
156
+ Optional observability hook (UI, logs, websockets). Sync or async:
157
+
158
+ ```python
159
+ async def on_stream(event: dict) -> None:
160
+ print(event["phase"], event.get("detail", "")[:80])
161
+
162
+ await sm.commission("researcher", "…", streaming_callback=on_stream)
163
+ # or
164
+ await sm.run("…", streaming_callback=on_stream)
165
+ ```
166
+
167
+ Phases: `start`, `token`, `tool_start`, `tool_result`, `tool_error`, `commissioned`, `done`, `fail`.
168
+ Runners may emit events; OA forwards them and also emits lifecycle events around commission.
169
+
170
+ ## Writing adapters
171
+
172
+ | Adapter idea | Wraps |
173
+ |--------------|--------|
174
+ | `callable_agent(fn)` | Plain async/sync function (shipped) |
175
+ | Relay agent | `encode.relay_async` / `courier_os.relay` inside `run()` |
176
+ | LangChain agent | AgentExecutor / LangGraph; map messages ↔ LC messages |
177
+ | HTTP agent | POST OpenAI-compatible or custom JSON API |
178
+
179
+ OA never imports those libraries. Keep adapters in your host.
180
+
181
+ ### Minimal Relay sketch (host code)
182
+
183
+ ```python
184
+ class RelayRunner:
185
+ def __init__(self, model, api_key, base_url, tools):
186
+ self.model, self.api_key, self.base_url, self.tools = model, api_key, base_url, tools
187
+
188
+ async def run(self, request, *, streaming_callback=None):
189
+ import encode
190
+ messages = encode.Messages()
191
+ for m in request.messages:
192
+ # map dicts into encode.Messages as needed
193
+ ...
194
+ out = await encode.relay_async(
195
+ model=self.model,
196
+ api_key=self.api_key,
197
+ base_url=self.base_url,
198
+ messages=messages,
199
+ tools=self.tools or request.metadata.get("tools"),
200
+ )
201
+ return AgentResult(content=out.content or "", raw=out)
202
+ ```
203
+
204
+ ## Example
205
+
206
+ See [`examples/minimal_callable.py`](examples/minimal_callable.py).
207
+
208
+ ## Design boundaries
209
+
210
+ - **Not** a coding CLI or competitor to Cursor/Claude Code
211
+ - **Not** an inference or tool-loop SDK (use Courier OS, encode, agentloop, LangChain, …)
212
+ - **Not** coupled to AXE or Courier OS — hosts may wrap them as runners
213
+
214
+ ## License / status
215
+
216
+ Early SDK (`0.2.0`). API may evolve; the orchestration contract (commission / stage / accept) is the stable idea.
@@ -0,0 +1,10 @@
1
+ operator_architecture/__init__.py,sha256=_GxNq2tUVCxbRAErCEvZIbAyjk6wH8j44doi2Us3Jrg,1043
2
+ operator_architecture/agent.py,sha256=P7wtgWfSCg0OAMHdfbm1ftNTGx7T88cYnUeRGsHpGaY,4333
3
+ operator_architecture/coordinator.py,sha256=G0MzTibtKL0SBMAwXi55lbZrfSE1HagUqa33ijR2Xnc,1526
4
+ operator_architecture/machine.py,sha256=6Dx0jtJ5JUh4Y5MljarHyVQEltbj6SfETkqRLY4Gs-E,18763
5
+ operator_architecture/messages.py,sha256=3K365AQKjmhYG7zbkJuTm2BBTI5IL0qkNBuEa5BkvIc,1960
6
+ operator_architecture/orchestration.py,sha256=yKbbXSdZyE7lGxcm5CV-sibFOhEdHy4wlrAmxEIrOug,2585
7
+ operator_architecture/streaming.py,sha256=-96FMN80nncSOR9aJxreIWywFXcnVmzvm2kRe0FpNFg,952
8
+ operator_architecture-0.2.0.dist-info/METADATA,sha256=uj2VfxdYjNJ1c4gTIr0YysgaqMzCQwsiqxFUePtNQZM,6570
9
+ operator_architecture-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ operator_architecture-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any