sigrix-runtime 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,336 @@
1
+ """YAML loader + validator for Sigrix listing bundles.
2
+
3
+ Parses ``agents.yaml`` and ``tasks.yaml`` into structured data, validates
4
+ references and tool support, and (when ``build_crew`` is invoked)
5
+ constructs CrewAI ``Agent`` / ``Task`` / ``Crew`` objects.
6
+
7
+ The pure parsing/validation half is testable without crewai installed.
8
+ ``build_crew`` imports crewai lazily.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ SUPPORTED_TOOLS = frozenset({"file_read", "file_write", "web_search", "serper_search"})
20
+ SUPPORTED_PROCESSES = frozenset({"sequential", "hierarchical", "parallel"})
21
+
22
+ PROCESS_KEY = "_process"
23
+ # Hierarchical crews name their manager agent here (top-level tasks.yaml key,
24
+ # next to ``_process``). CrewAI requires a hierarchical Crew to carry a
25
+ # manager and requires that manager NOT to be listed among its agents, so the
26
+ # loader needs to know which agent to lift out.
27
+ MANAGER_KEY = "_manager"
28
+
29
+
30
+ class BundleConfigError(ValueError):
31
+ """Raised when agents.yaml or tasks.yaml fails validation."""
32
+
33
+
34
+ @dataclass
35
+ class AgentSpec:
36
+ name: str
37
+ role: str
38
+ goal: str
39
+ backstory: str
40
+ tools: list[str] = field(default_factory=list)
41
+
42
+
43
+ @dataclass
44
+ class TaskSpec:
45
+ name: str
46
+ description: str
47
+ expected_output: str
48
+ # Empty means unassigned: allowed only under ``_process: hierarchical``,
49
+ # where the manager delegates the task to the member it chooses.
50
+ agent: str = ""
51
+ context: list[str] = field(default_factory=list)
52
+ # Fan-out: an async task is kicked off without being awaited;
53
+ # a later task lists it in ``context`` to join on its result. This is how
54
+ # ``_process: parallel`` panels run members concurrently.
55
+ async_execution: bool = False
56
+
57
+
58
+ @dataclass
59
+ class BundleConfig:
60
+ agents: list[AgentSpec]
61
+ tasks: list[TaskSpec]
62
+ process: str = "sequential"
63
+ manager: str = "" # agent name from ``_manager``; set iff hierarchical
64
+
65
+ @property
66
+ def tool_refs(self) -> list[str]:
67
+ seen: list[str] = []
68
+ for agent in self.agents:
69
+ for tool_ref in agent.tools:
70
+ if tool_ref not in seen:
71
+ seen.append(tool_ref)
72
+ return seen
73
+
74
+
75
+ def load_config(config_dir: Path) -> BundleConfig:
76
+ agents_path = config_dir / "agents.yaml"
77
+ tasks_path = config_dir / "tasks.yaml"
78
+
79
+ if not agents_path.exists():
80
+ raise BundleConfigError(f"agents.yaml not found at {agents_path}")
81
+ if not tasks_path.exists():
82
+ raise BundleConfigError(f"tasks.yaml not found at {tasks_path}")
83
+
84
+ raw_agents = yaml.safe_load(agents_path.read_text(encoding="utf-8")) or {}
85
+ raw_tasks = yaml.safe_load(tasks_path.read_text(encoding="utf-8")) or {}
86
+
87
+ if not isinstance(raw_agents, dict):
88
+ raise BundleConfigError("agents.yaml must be a mapping of agent_name -> spec")
89
+ if not isinstance(raw_tasks, dict):
90
+ raise BundleConfigError("tasks.yaml must be a mapping of task_name -> spec")
91
+
92
+ process = "sequential"
93
+ if PROCESS_KEY in raw_tasks:
94
+ candidate = raw_tasks.pop(PROCESS_KEY)
95
+ if not isinstance(candidate, str):
96
+ raise BundleConfigError(f"{PROCESS_KEY} must be a string")
97
+ process = candidate
98
+
99
+ manager = ""
100
+ if MANAGER_KEY in raw_tasks:
101
+ candidate = raw_tasks.pop(MANAGER_KEY)
102
+ if not isinstance(candidate, str):
103
+ raise BundleConfigError(f"{MANAGER_KEY} must be a string")
104
+ manager = candidate
105
+
106
+ agents = [_parse_agent(name, spec) for name, spec in raw_agents.items()]
107
+ tasks = [_parse_task(name, spec) for name, spec in raw_tasks.items()]
108
+
109
+ config = BundleConfig(agents=agents, tasks=tasks, process=process, manager=manager)
110
+ _validate(config)
111
+ return config
112
+
113
+
114
+ def _parse_agent(name: str, spec: Any) -> AgentSpec:
115
+ if not isinstance(spec, dict):
116
+ raise BundleConfigError(f"agent {name!r}: spec must be a mapping")
117
+ required = {"role", "goal", "backstory"}
118
+ missing = required - spec.keys()
119
+ if missing:
120
+ raise BundleConfigError(f"agent {name!r}: missing required fields: {sorted(missing)}")
121
+ tools = spec.get("tools") or []
122
+ if not isinstance(tools, list):
123
+ raise BundleConfigError(f"agent {name!r}: tools must be a list")
124
+ return AgentSpec(
125
+ name=name,
126
+ role=str(spec["role"]),
127
+ goal=str(spec["goal"]),
128
+ backstory=str(spec["backstory"]),
129
+ tools=[str(t) for t in tools],
130
+ )
131
+
132
+
133
+ def _parse_task(name: str, spec: Any) -> TaskSpec:
134
+ if not isinstance(spec, dict):
135
+ raise BundleConfigError(f"task {name!r}: spec must be a mapping")
136
+ # ``agent`` is validated later: it may be omitted only under
137
+ # ``_process: hierarchical`` (the manager delegates the task).
138
+ required = {"description", "expected_output"}
139
+ missing = required - spec.keys()
140
+ if missing:
141
+ raise BundleConfigError(f"task {name!r}: missing required fields: {sorted(missing)}")
142
+ context = spec.get("context") or []
143
+ if not isinstance(context, list):
144
+ raise BundleConfigError(f"task {name!r}: context must be a list")
145
+ async_execution = spec.get("async_execution", False)
146
+ if not isinstance(async_execution, bool):
147
+ raise BundleConfigError(f"task {name!r}: async_execution must be a boolean")
148
+ return TaskSpec(
149
+ name=name,
150
+ description=str(spec["description"]),
151
+ expected_output=str(spec["expected_output"]),
152
+ agent=str(spec.get("agent") or ""),
153
+ context=[str(c) for c in context],
154
+ async_execution=async_execution,
155
+ )
156
+
157
+
158
+ def _validate(config: BundleConfig) -> None:
159
+ if not config.agents:
160
+ raise BundleConfigError("agents.yaml must define at least one agent")
161
+
162
+ agent_names = [a.name for a in config.agents]
163
+ if len(set(agent_names)) != len(agent_names):
164
+ raise BundleConfigError("agents.yaml has duplicate agent names")
165
+
166
+ for agent in config.agents:
167
+ for tool_ref in agent.tools:
168
+ if tool_ref not in SUPPORTED_TOOLS:
169
+ raise BundleConfigError(
170
+ f"agent {agent.name!r}: unsupported tool {tool_ref!r}. Supported tools: {sorted(SUPPORTED_TOOLS)}"
171
+ )
172
+
173
+ if not config.tasks:
174
+ raise BundleConfigError("tasks.yaml must define at least one task")
175
+
176
+ task_names = [t.name for t in config.tasks]
177
+ if len(set(task_names)) != len(task_names):
178
+ raise BundleConfigError("tasks.yaml has duplicate task names")
179
+
180
+ if config.process not in SUPPORTED_PROCESSES:
181
+ raise BundleConfigError(f"unsupported process {config.process!r}. Supported: {sorted(SUPPORTED_PROCESSES)}")
182
+
183
+ hierarchical = config.process == "hierarchical"
184
+ agent_name_set = set(agent_names)
185
+
186
+ # CrewAI requires a hierarchical Crew to carry a manager, and requires
187
+ # that manager NOT to be listed among its agents — so a hierarchical
188
+ # bundle must name one via ``_manager``, and the loader lifts it out at
189
+ # build time. Reject at load what would crash at runtime.
190
+ if hierarchical and not config.manager:
191
+ raise BundleConfigError(f"{PROCESS_KEY}: hierarchical requires {MANAGER_KEY} to name the manager agent")
192
+ if config.manager and not hierarchical:
193
+ raise BundleConfigError(f"{MANAGER_KEY} is only valid with {PROCESS_KEY}: hierarchical")
194
+ if config.manager:
195
+ if config.manager not in agent_name_set:
196
+ raise BundleConfigError(f"{MANAGER_KEY}: references undefined agent {config.manager!r}")
197
+ if len(agent_name_set) < 2:
198
+ raise BundleConfigError(
199
+ f"{MANAGER_KEY}: a hierarchical crew needs at least one member agent besides the manager"
200
+ )
201
+ manager_spec = next(a for a in config.agents if a.name == config.manager)
202
+ if manager_spec.tools:
203
+ # CrewAI rejects a manager agent that carries tools.
204
+ raise BundleConfigError(f"{MANAGER_KEY}: manager agent {config.manager!r} must not have tools")
205
+
206
+ task_name_set = set(task_names)
207
+ for task in config.tasks:
208
+ if not task.agent:
209
+ if not hierarchical:
210
+ raise BundleConfigError(
211
+ f"task {task.name!r}: 'agent' is required (only {PROCESS_KEY}: hierarchical tasks may omit it)"
212
+ )
213
+ elif task.agent not in agent_name_set:
214
+ raise BundleConfigError(f"task {task.name!r}: references undefined agent {task.agent!r}")
215
+ elif task.agent == config.manager:
216
+ raise BundleConfigError(
217
+ f"task {task.name!r}: must not be assigned to the manager agent {config.manager!r} "
218
+ "(the manager delegates; leave the task unassigned instead)"
219
+ )
220
+ for ctx in task.context:
221
+ if ctx not in task_name_set:
222
+ raise BundleConfigError(f"task {task.name!r}: context references undefined task {ctx!r}")
223
+
224
+ # Fan-out rules. An async task is launched without being
225
+ # awaited, so a trailing one would finish after the crew returns (its
226
+ # output silently lost), and one that declares ``context`` would have to
227
+ # block on other tasks — defeating the point. Reject at load what would
228
+ # misbehave at runtime.
229
+ if config.tasks[-1].async_execution:
230
+ raise BundleConfigError(
231
+ f"task {config.tasks[-1].name!r}: the final task must not set async_execution "
232
+ "(nothing would wait for its result)"
233
+ )
234
+ for task in config.tasks:
235
+ if task.async_execution and task.context:
236
+ raise BundleConfigError(
237
+ f"task {task.name!r}: an async_execution task must not declare context "
238
+ "(async tasks fan out independently; a later synchronous task joins them)"
239
+ )
240
+ if config.process == "parallel" and not any(t.async_execution for t in config.tasks):
241
+ raise BundleConfigError(
242
+ f"{PROCESS_KEY}: parallel requires at least one async_execution task "
243
+ "(without one it would run as a plain sequential chain)"
244
+ )
245
+
246
+ _check_acyclic(config.tasks)
247
+
248
+
249
+ def _check_acyclic(tasks: list[TaskSpec]) -> None:
250
+ deps = {t.name: set(t.context) for t in tasks}
251
+ resolved: set[str] = set()
252
+ while len(resolved) < len(deps):
253
+ progress = False
254
+ for name, requires in deps.items():
255
+ if name in resolved:
256
+ continue
257
+ if requires <= resolved:
258
+ resolved.add(name)
259
+ progress = True
260
+ if not progress:
261
+ unresolved = sorted(set(deps) - resolved)
262
+ raise BundleConfigError(f"task DAG has a cycle involving: {unresolved}")
263
+
264
+
265
+ def build_crew(config: BundleConfig, tools: list[Any]) -> Any:
266
+ """Construct a CrewAI ``Crew`` object from the parsed config.
267
+
268
+ The ``tools`` list is in the same order as ``config.tool_refs`` (this
269
+ is the contract ``sandbox.build_tools`` honours).
270
+
271
+ A hierarchical config (``_process: hierarchical`` + ``_manager``) builds
272
+ the manager as CrewAI's ``manager_agent`` — excluded from ``agents=``
273
+ (CrewAI rejects a manager listed there) and free to delegate an
274
+ unassigned task to whichever member it chooses.
275
+
276
+ Each task carries its own ``name=``: CrewAI's ``TaskOutput`` —
277
+ what a ``task_callback`` receives — mirrors it verbatim, and
278
+ ``execution.py``'s progress reporting reads that name for a Postern
279
+ ``usage.steps[*].name``. Leave it unset and CrewAI's own ``TaskOutput``
280
+ reports none either, so the step falls back to the task's *description* —
281
+ the seller's prompt text, truncated mid-word at 120 characters.
282
+ """
283
+ # Every crew-construction path (buyer main.py, the server-side example
284
+ # runner, tests) inherits the quiet posture; setdefault semantics keep
285
+ # caller overrides intact.
286
+ from sigrix_runtime.quiet import apply_quiet_env_defaults
287
+
288
+ apply_quiet_env_defaults()
289
+
290
+ from crewai import Agent, Crew, Process, Task
291
+
292
+ tools_by_ref: dict[str, Any] = dict(zip(config.tool_refs, tools, strict=True))
293
+
294
+ crewai_agents: dict[str, Any] = {
295
+ spec.name: Agent(
296
+ role=spec.role,
297
+ goal=spec.goal,
298
+ backstory=spec.backstory,
299
+ tools=[tools_by_ref[t] for t in spec.tools],
300
+ allow_delegation=(spec.name == config.manager),
301
+ verbose=False,
302
+ )
303
+ for spec in config.agents
304
+ }
305
+
306
+ crewai_tasks: dict[str, Any] = {}
307
+ for spec in config.tasks:
308
+ crewai_tasks[spec.name] = Task(
309
+ name=spec.name,
310
+ description=spec.description,
311
+ expected_output=spec.expected_output,
312
+ agent=crewai_agents[spec.agent] if spec.agent else None,
313
+ context=[crewai_tasks[c] for c in spec.context],
314
+ async_execution=spec.async_execution,
315
+ )
316
+
317
+ process_map = {
318
+ "sequential": Process.sequential,
319
+ "hierarchical": Process.hierarchical,
320
+ # CrewAI's public Process enum has no parallel mode; a parallel
321
+ # bundle runs as a sequential pipeline whose ``async_execution``
322
+ # member tasks fan out concurrently and whose final synchronous
323
+ # task (``context`` = the members) joins them — real fan-out, not
324
+ # a silent chain.
325
+ "parallel": Process.sequential,
326
+ }
327
+
328
+ manager_agent = crewai_agents.pop(config.manager) if config.manager else None
329
+
330
+ return Crew(
331
+ agents=list(crewai_agents.values()),
332
+ tasks=list(crewai_tasks.values()),
333
+ process=process_map[config.process],
334
+ manager_agent=manager_agent,
335
+ verbose=False,
336
+ )
@@ -0,0 +1,61 @@
1
+ """Postern v0 for a Sigrix listing bundle — the reference implementation.
2
+
3
+ Postern (``sigrix-io/postern``) is the open execution and entitlement
4
+ protocol for packaged AI agents: four verbs over local HTTP —
5
+ ``describe`` / ``run`` / ``stream`` / ``status`` — and one entitlement flow.
6
+ This package serves them for the bundle it is started inside::
7
+
8
+ python -m sigrix_runtime.postern
9
+
10
+ It is also what the ``sigrix/runner`` image runs as its entrypoint. Those
11
+ are two deployment shapes of one codebase rather than two implementations;
12
+ if they ever diverge, that is the bug.
13
+
14
+ **Nothing here is a second execution path.** ``run`` and ``stream`` reach
15
+ the agent through ``sigrix_runtime.execution``, which is the same function
16
+ ``python main.py "…"`` calls. A crew's kickoff inputs are a contract
17
+ spanning several files, and a server that built its own ``crew.kickoff``
18
+ would be one more place for it to drift.
19
+
20
+ **Zero dependencies beyond the standard library.** The bundle's
21
+ ``requirements.txt`` is something a buyer installs on their own machine,
22
+ and four routes on loopback do not justify adding a web framework and its
23
+ tree to it. ``http.server`` serves them; ``http.client`` makes the one
24
+ outbound call this protocol defines.
25
+
26
+ Layout:
27
+
28
+ ``errors`` the specification's error envelope, as exceptions
29
+ ``describe`` the agent's contract: shipped by the distributor, or derived
30
+ ``transport`` the one outbound connection, and SPEC 7's rule on it
31
+ ``entitlement`` the distributor client and the state machine of SPEC 5.7
32
+ ``pull`` bundle retrieval (SPEC 5.6), which is the image's boot step
33
+ ``execution`` the run engine — a subprocess per run, so a timeout is real
34
+ ``server`` the HTTP surface: routing, CORS, and the SSE framing
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ # The specification version every payload carries. The ``/postern/v0/``
40
+ # path prefix is deliberately coarser than this (SPEC 5.3, VERSIONING.md):
41
+ # the prefix moves only on a breaking revision, and this field is the only
42
+ # sanctioned way to know exactly what you are talking to.
43
+ POSTERN_VERSION = "0.1"
44
+
45
+ # SPEC 3. This runner implements all four verbs, so it reports Level 3.
46
+ # ``stream`` emits no ``delta`` — see ``server`` for why that is conformant
47
+ # rather than a shortfall.
48
+ CONFORMANCE_LEVEL = 3
49
+
50
+ PATH_PREFIX = "/postern/v0"
51
+
52
+ DEFAULT_PORT = 8787
53
+ DEFAULT_HOST = "127.0.0.1"
54
+
55
+ __all__ = [
56
+ "CONFORMANCE_LEVEL",
57
+ "DEFAULT_HOST",
58
+ "DEFAULT_PORT",
59
+ "PATH_PREFIX",
60
+ "POSTERN_VERSION",
61
+ ]