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.
- operator_architecture/__init__.py +40 -0
- operator_architecture/agent.py +157 -0
- operator_architecture/coordinator.py +42 -0
- operator_architecture/machine.py +507 -0
- operator_architecture/messages.py +65 -0
- operator_architecture/orchestration.py +88 -0
- operator_architecture/streaming.py +38 -0
- operator_architecture-0.2.0.dist-info/METADATA +216 -0
- operator_architecture-0.2.0.dist-info/RECORD +10 -0
- operator_architecture-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|