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.
- sigrix_runtime/__init__.py +18 -0
- sigrix_runtime/configuration.py +110 -0
- sigrix_runtime/execution.py +465 -0
- sigrix_runtime/loader.py +336 -0
- sigrix_runtime/postern/__init__.py +61 -0
- sigrix_runtime/postern/__main__.py +460 -0
- sigrix_runtime/postern/_worker.py +116 -0
- sigrix_runtime/postern/describe.py +559 -0
- sigrix_runtime/postern/engine.py +468 -0
- sigrix_runtime/postern/entitlement.py +898 -0
- sigrix_runtime/postern/errors.py +226 -0
- sigrix_runtime/postern/mcp.py +561 -0
- sigrix_runtime/postern/pull.py +624 -0
- sigrix_runtime/postern/server.py +1154 -0
- sigrix_runtime/postern/transport.py +209 -0
- sigrix_runtime/postern/version_check.py +126 -0
- sigrix_runtime/py.typed +0 -0
- sigrix_runtime/quiet.py +52 -0
- sigrix_runtime/runner_env.py +36 -0
- sigrix_runtime/sandbox.py +95 -0
- sigrix_runtime/workforce.py +442 -0
- sigrix_runtime-0.1.0.dist-info/METADATA +173 -0
- sigrix_runtime-0.1.0.dist-info/RECORD +27 -0
- sigrix_runtime-0.1.0.dist-info/WHEEL +4 -0
- sigrix_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- sigrix_runtime-0.1.0.dist-info/licenses/LICENSE +201 -0
- sigrix_runtime-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Sigrix runtime helpers shared by every listing bundle.
|
|
2
|
+
|
|
3
|
+
Submodules:
|
|
4
|
+
execution - the one run path; main.py and postern both call it
|
|
5
|
+
sandbox - file-IO sandbox + CrewAI tool wrappers
|
|
6
|
+
loader - YAML config loader and CrewAI crew builder
|
|
7
|
+
workforce - crew-of-crews manifest loader + Flow orchestration
|
|
8
|
+
quiet - local/non-interactive run defaults
|
|
9
|
+
configuration - the buyer's setup answers as a kickoff input
|
|
10
|
+
runner_env - the runner's own settings, kept out of what it runs
|
|
11
|
+
postern - the Postern v0 server (describe/run/stream/status)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
#: The package's one version, which its build reads. A bundle states its own
|
|
17
|
+
#: version in its ``VERSION`` file.
|
|
18
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The buyer's setup answers as a kickoff input.
|
|
2
|
+
|
|
3
|
+
A crew's Step 5 variables are its **one canonical configuration**. The
|
|
4
|
+
seller's values are baked
|
|
5
|
+
into the reviewed YAML when it is generated; the *buyer's* answers ride the
|
|
6
|
+
run-input channel instead — never a per-buyer YAML rewrite, so *reviewed bytes
|
|
7
|
+
= shipped bytes = run-captured bytes* still holds and ``run_fingerprint`` keeps
|
|
8
|
+
meaning what it meant.
|
|
9
|
+
|
|
10
|
+
``main.py`` reads the bundle's ``variables.json`` and passes this block beside
|
|
11
|
+
the brief::
|
|
12
|
+
|
|
13
|
+
crew.kickoff(inputs={"prompt": brief, "configuration": block})
|
|
14
|
+
|
|
15
|
+
and the compiled task descriptions carry a ``{configuration}`` slot next to
|
|
16
|
+
``{prompt}`` (``crew_config_compiler.RUN_INPUT_LINE``).
|
|
17
|
+
|
|
18
|
+
**The block carries its own separator.** It is ``""`` when nothing is filled
|
|
19
|
+
in, and otherwise *opens with a blank line* — the slot sits directly after the
|
|
20
|
+
brief (``The user's request: {prompt}{configuration}``), so a buyer who has
|
|
21
|
+
answered nothing gets a task description byte-for-byte identical to what the
|
|
22
|
+
same YAML rendered before this input existed. A missing ``variables.json``, an
|
|
23
|
+
empty one, and one whose values are all blank are therefore indistinguishable
|
|
24
|
+
at run time, which is the compatibility promise the decision record makes.
|
|
25
|
+
|
|
26
|
+
Only the wizard's ``variables`` reach the run. The sibling ``platform`` key
|
|
27
|
+
names the chat platform ``crew.md`` was compiled for — it says nothing about a
|
|
28
|
+
local CrewAI run, so it is deliberately left out of the block.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
VARIABLES_FILENAME = "variables.json"
|
|
39
|
+
|
|
40
|
+
# Opens the block. Phrased as an instruction because the crew reads it: the
|
|
41
|
+
# seller's defaults are already baked into the YAML around it, so these are
|
|
42
|
+
# the values that should win where they overlap.
|
|
43
|
+
CONFIGURATION_HEADING = "The buyer's setup values for this run — prefer these where they apply:"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_variables(bundle_root: Path) -> dict[str, str]:
|
|
47
|
+
"""The buyer's answers from ``variables.json``, or ``{}``.
|
|
48
|
+
|
|
49
|
+
Looks next to ``main.py`` first (the crew bundle's runnable-first layout,
|
|
50
|
+
where the runtime and ``variables.json`` share the bundle root), then one
|
|
51
|
+
directory up (the legacy mega-prompt layout, where the runtime lives under
|
|
52
|
+
``runnable/`` and ``variables.json`` stays at the bundle root).
|
|
53
|
+
|
|
54
|
+
Never raises: a missing, unreadable or malformed file is the same answer as
|
|
55
|
+
an unanswered wizard — no configuration — because a bad file must not cost
|
|
56
|
+
the buyer their run.
|
|
57
|
+
"""
|
|
58
|
+
for candidate in (bundle_root / VARIABLES_FILENAME, bundle_root.parent / VARIABLES_FILENAME):
|
|
59
|
+
try:
|
|
60
|
+
if not candidate.is_file():
|
|
61
|
+
continue
|
|
62
|
+
raw = json.loads(candidate.read_text(encoding="utf-8"))
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
continue
|
|
65
|
+
if not isinstance(raw, dict):
|
|
66
|
+
continue
|
|
67
|
+
variables = raw.get("variables")
|
|
68
|
+
return _coerce_variables(variables if isinstance(variables, dict) else {})
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _coerce_variables(raw: Mapping[str, Any]) -> dict[str, str]:
|
|
73
|
+
"""Scalar ``key -> value`` pairs, blanks dropped, file order preserved."""
|
|
74
|
+
values: dict[str, str] = {}
|
|
75
|
+
for key, value in raw.items():
|
|
76
|
+
name = str(key).strip()
|
|
77
|
+
if not name or isinstance(value, (dict, list)) or value is None or isinstance(value, bool):
|
|
78
|
+
continue
|
|
79
|
+
text = str(value).strip()
|
|
80
|
+
if text:
|
|
81
|
+
values[name] = text
|
|
82
|
+
return values
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_configuration_block(variables: Mapping[str, Any]) -> str:
|
|
86
|
+
"""Heading + ``- key: value`` lines, or ``""`` when nothing is filled in.
|
|
87
|
+
|
|
88
|
+
A non-empty block opens with a blank line so the ``{configuration}`` slot
|
|
89
|
+
can sit flush against ``{prompt}`` — see the module docstring for why that
|
|
90
|
+
is what makes the empty case degrade byte-for-byte.
|
|
91
|
+
"""
|
|
92
|
+
values = _coerce_variables(variables or {})
|
|
93
|
+
if not values:
|
|
94
|
+
return ""
|
|
95
|
+
lines = [CONFIGURATION_HEADING]
|
|
96
|
+
for key, value in values.items():
|
|
97
|
+
head, _, rest = value.partition("\n")
|
|
98
|
+
lines.append(f"- {key}: {head}")
|
|
99
|
+
# Continuation lines are indented under their bullet so a multi-line
|
|
100
|
+
# answer cannot be misread as the start of another variable.
|
|
101
|
+
lines.extend(f" {line}" for line in rest.splitlines())
|
|
102
|
+
return "\n\n" + "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"CONFIGURATION_HEADING",
|
|
107
|
+
"VARIABLES_FILENAME",
|
|
108
|
+
"load_variables",
|
|
109
|
+
"render_configuration_block",
|
|
110
|
+
]
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""The one execution path every Sigrix listing bundle runs through.
|
|
2
|
+
|
|
3
|
+
``main.py`` is a command-line front end over this module and
|
|
4
|
+
``sigrix_runtime.postern`` is an HTTP one. Neither owns the run: they own
|
|
5
|
+
argv parsing and a socket respectively, and both call :func:`execute`.
|
|
6
|
+
|
|
7
|
+
That split is the whole point. A crew's kickoff inputs are a contract
|
|
8
|
+
spanning five files, and a second caller
|
|
9
|
+
that built its own ``crew.kickoff(...)`` would be a sixth place the
|
|
10
|
+
``{prompt}`` / ``{configuration}`` pair has to stay in step — the failure
|
|
11
|
+
this codebase has already paid for once. There is one kickoff site per run
|
|
12
|
+
shape here, and the front ends reach them through :func:`execute`.
|
|
13
|
+
|
|
14
|
+
What a front end still owns:
|
|
15
|
+
|
|
16
|
+
* how the result is rendered — printed, or serialised into a Postern
|
|
17
|
+
``run`` body;
|
|
18
|
+
* whether a run happens at all — an entitlement gate is the server's, and
|
|
19
|
+
the command line has none;
|
|
20
|
+
* what to say about a failure. :func:`execute` raises, having recorded the
|
|
21
|
+
traceback where ``doctor.py`` looks for it.
|
|
22
|
+
|
|
23
|
+
Nothing here imports crewai at module load. The loader defers it, the
|
|
24
|
+
workforce flow defers it, and this module has no import of its own — so a
|
|
25
|
+
process that only needs :func:`load_run_config` (the Postern server
|
|
26
|
+
deriving a ``describe`` for a bundle whose virtualenv is not built yet)
|
|
27
|
+
pays nothing for the runtime it is not going to start.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
import traceback
|
|
36
|
+
from collections.abc import Callable, Mapping
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from sigrix_runtime import configuration, loader, quiet, sandbox, workforce
|
|
42
|
+
from sigrix_runtime.runner_env import (
|
|
43
|
+
RUNNER_ENV_PREFIXES,
|
|
44
|
+
drop_runner_settings,
|
|
45
|
+
is_runner_setting,
|
|
46
|
+
without_runner_settings,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Written on any failed run, read by ``doctor.py``'s ``check_last_error``.
|
|
50
|
+
# It lives here rather than in a front end so a buyer who only ever starts
|
|
51
|
+
# the Postern server still gets the trace doctor asks them for.
|
|
52
|
+
LAST_ERROR_FILENAME = ".last_error"
|
|
53
|
+
|
|
54
|
+
WORKSPACE_DIRNAME = "workspace"
|
|
55
|
+
|
|
56
|
+
# The reserved Postern input key (SPEC 4.1.1) and the run input this
|
|
57
|
+
# starter has always kicked off with are the same string, which is what
|
|
58
|
+
# lets a Postern ``run`` body reach an unmodified reviewed config.
|
|
59
|
+
PROMPT_INPUT_KEY = "prompt"
|
|
60
|
+
|
|
61
|
+
# The environment variable crewai reads its model from, and the one the
|
|
62
|
+
# generated ``.env.example`` documents as the optional override. The single
|
|
63
|
+
# crew path (below) prefers the model crewai itself resolved over this — see
|
|
64
|
+
# ``_resolved_model_id`` — so this is now only the workforce path's answer,
|
|
65
|
+
# since ``execute()`` builds no crew there this module can introspect.
|
|
66
|
+
MODEL_ENV_KEY = "OPENAI_MODEL_NAME"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MissingDependencyError(RuntimeError):
|
|
70
|
+
"""The bundle's requirements are not installed in this Python.
|
|
71
|
+
|
|
72
|
+
Raised in place of a bare ``ModuleNotFoundError`` so a front end can
|
|
73
|
+
say what to do about it — the most common first-run stumble is a venv
|
|
74
|
+
that was never activated, and a raw crewai traceback names none of the
|
|
75
|
+
three commands that fix it. ``module`` is what was missing.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, module: str) -> None:
|
|
79
|
+
super().__init__(f"missing dependency: {module!r} — this Python can't see the bundle's packages.")
|
|
80
|
+
self.module = module
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class RunStep:
|
|
85
|
+
"""One completed unit of work, in Postern's ``usage.steps`` shape.
|
|
86
|
+
|
|
87
|
+
``input_tokens``/``output_tokens`` default to ``None`` rather than ``0``
|
|
88
|
+
for the same reason ``model_id`` defaults to ``""``: absent means this
|
|
89
|
+
run never determined a value, where ``0`` would claim the step spent
|
|
90
|
+
nothing. A crew whose usage this module could read reports real
|
|
91
|
+
(possibly zero) numbers; one it could not says nothing at all.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
latency_ms: int
|
|
96
|
+
model_id: str = ""
|
|
97
|
+
input_tokens: int | None = None
|
|
98
|
+
output_tokens: int | None = None
|
|
99
|
+
|
|
100
|
+
def as_dict(self) -> dict[str, Any]:
|
|
101
|
+
payload: dict[str, Any] = {"name": self.name, "latency_ms": self.latency_ms}
|
|
102
|
+
if self.model_id:
|
|
103
|
+
payload["model_id"] = self.model_id
|
|
104
|
+
if self.input_tokens is not None:
|
|
105
|
+
payload["input_tokens"] = self.input_tokens
|
|
106
|
+
if self.output_tokens is not None:
|
|
107
|
+
payload["output_tokens"] = self.output_tokens
|
|
108
|
+
return payload
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class RunOutcome:
|
|
113
|
+
"""What a completed run produced, before any front end renders it."""
|
|
114
|
+
|
|
115
|
+
text: str
|
|
116
|
+
files_written: list[str] = field(default_factory=list)
|
|
117
|
+
steps: list[RunStep] = field(default_factory=list)
|
|
118
|
+
input_tokens: int = 0
|
|
119
|
+
output_tokens: int = 0
|
|
120
|
+
model_id: str = ""
|
|
121
|
+
duration_seconds: float = 0.0
|
|
122
|
+
# True when the bundle carried a workforce.yaml manifest and it loaded.
|
|
123
|
+
# A front end that wants to say "orchestrated N unit crews" needs to know
|
|
124
|
+
# which of the two shapes ran; nothing else does.
|
|
125
|
+
workforce: bool = False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class RunConfig:
|
|
130
|
+
"""The bundle's own declaration of what it is, without running it.
|
|
131
|
+
|
|
132
|
+
Loaded by both :func:`execute` and by the Postern server's ``describe``
|
|
133
|
+
fallback, which is why it carries the tool refs and the agent/task
|
|
134
|
+
names rather than only the objects the loader builds from them.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
tool_refs: list[str]
|
|
138
|
+
task_names: list[str]
|
|
139
|
+
agent_names: list[str]
|
|
140
|
+
workforce: bool
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_run_config(bundle_root: Path) -> RunConfig:
|
|
144
|
+
"""Parse the bundle's config without constructing anything runnable.
|
|
145
|
+
|
|
146
|
+
Prefers the workforce manifest, exactly as :func:`execute` does, so the
|
|
147
|
+
two cannot disagree about which shape a bundle is. A manifest that
|
|
148
|
+
fails to load degrades to the flattened config here for the same reason
|
|
149
|
+
it does there — the flattened form is the reviewed, run-captured
|
|
150
|
+
fallback, and a buyer always has something runnable.
|
|
151
|
+
"""
|
|
152
|
+
config_dir = bundle_root / "config"
|
|
153
|
+
manifest = _load_workforce_manifest(config_dir)[0]
|
|
154
|
+
if manifest is not None:
|
|
155
|
+
# ``load_workforce`` has already run each unit through the single-crew
|
|
156
|
+
# loader and hung the result on the unit, so there is nothing to parse
|
|
157
|
+
# again here.
|
|
158
|
+
tool_refs: list[str] = []
|
|
159
|
+
task_names: list[str] = []
|
|
160
|
+
agent_names: list[str] = []
|
|
161
|
+
for unit in manifest.units:
|
|
162
|
+
unit_config = unit.config
|
|
163
|
+
if unit_config is None:
|
|
164
|
+
continue
|
|
165
|
+
tool_refs.extend(ref for ref in unit_config.tool_refs if ref not in tool_refs)
|
|
166
|
+
task_names.extend(f"{unit.key}.{task.name}" for task in unit_config.tasks)
|
|
167
|
+
agent_names.extend(f"{unit.key}.{agent.name}" for agent in unit_config.agents)
|
|
168
|
+
return RunConfig(tool_refs=tool_refs, task_names=task_names, agent_names=agent_names, workforce=True)
|
|
169
|
+
|
|
170
|
+
config = loader.load_config(config_dir)
|
|
171
|
+
return RunConfig(
|
|
172
|
+
tool_refs=list(config.tool_refs),
|
|
173
|
+
task_names=[task.name for task in config.tasks],
|
|
174
|
+
agent_names=[agent.name for agent in config.agents],
|
|
175
|
+
workforce=False,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def prepare_environment(bundle_root: Path) -> Path:
|
|
180
|
+
"""Load ``.env``, apply the quiet defaults, and ensure ``workspace/``.
|
|
181
|
+
|
|
182
|
+
Ordering is load-bearing and is the ordering ``main.py`` has always
|
|
183
|
+
used: the buyer's ``.env`` wins over the quiet defaults, and both land
|
|
184
|
+
before anything imports crewai — so a run stays local and
|
|
185
|
+
non-interactive unless the buyer opted in.
|
|
186
|
+
|
|
187
|
+
**The runner's own settings come straight back out again**.
|
|
188
|
+
``Engine._spawn`` keeps them out of the subprocess, but ``.env.example``
|
|
189
|
+
tells the buyer to copy itself to ``.env`` with ``SIGRIX_TOKEN`` in it —
|
|
190
|
+
so on the documented happy path this ``load_dotenv`` puts the buyer's
|
|
191
|
+
account-wide token back into a process running a seller's crew. Nothing
|
|
192
|
+
below this line reads any of them; see :data:`RUNNER_ENV_PREFIXES`.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
from dotenv import load_dotenv
|
|
196
|
+
except ModuleNotFoundError as exc:
|
|
197
|
+
raise MissingDependencyError(exc.name or "python-dotenv") from exc
|
|
198
|
+
load_dotenv(bundle_root / ".env")
|
|
199
|
+
drop_runner_settings()
|
|
200
|
+
quiet.apply_quiet_env_defaults()
|
|
201
|
+
workspace = bundle_root / WORKSPACE_DIRNAME
|
|
202
|
+
workspace.mkdir(exist_ok=True)
|
|
203
|
+
return workspace
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def execute(
|
|
207
|
+
bundle_root: Path,
|
|
208
|
+
prompt: str,
|
|
209
|
+
*,
|
|
210
|
+
variables: Mapping[str, Any] | None = None,
|
|
211
|
+
on_step: Callable[[RunStep], None] | None = None,
|
|
212
|
+
on_notice: Callable[[str], None] | None = None,
|
|
213
|
+
) -> RunOutcome:
|
|
214
|
+
"""Run the bundle against ``prompt`` and return what it produced.
|
|
215
|
+
|
|
216
|
+
``variables`` are the buyer's setup answers. Omitted, they are read
|
|
217
|
+
from ``variables.json`` — which is what the command line wants, since
|
|
218
|
+
that file *is* how a buyer reconfigures a run there. A caller that has
|
|
219
|
+
its own answers passes them instead: a Postern ``run`` declares those
|
|
220
|
+
same keys as inputs (SPEC 4.1.1), so a client supplying one has to be
|
|
221
|
+
able to override the file for that run without editing it.
|
|
222
|
+
|
|
223
|
+
``on_step`` is called as each task finishes, which is what a Postern
|
|
224
|
+
``stream`` relays as a ``step`` event. Only the *finished* edge is
|
|
225
|
+
reported, and deliberately: a hierarchical crew's manager decides which
|
|
226
|
+
member runs, so a ``started`` event emitted from the task list would be
|
|
227
|
+
a guess about work that may never happen. The specification asks for at
|
|
228
|
+
least a name and an edge, not for the pair.
|
|
229
|
+
|
|
230
|
+
``on_notice`` receives the run's own asides — the setup values in play,
|
|
231
|
+
a workforce manifest that failed to load — as plain sentences. The
|
|
232
|
+
command line prints them to stderr; the server logs them.
|
|
233
|
+
|
|
234
|
+
Raises whatever the run raised, after writing the traceback to
|
|
235
|
+
``.last_error`` for ``doctor.py``.
|
|
236
|
+
"""
|
|
237
|
+
started = time.monotonic()
|
|
238
|
+
last_error = bundle_root / LAST_ERROR_FILENAME
|
|
239
|
+
|
|
240
|
+
setup_values = configuration.load_variables(bundle_root) if variables is None else dict(variables)
|
|
241
|
+
config_block = configuration.render_configuration_block(setup_values)
|
|
242
|
+
if setup_values and on_notice is not None:
|
|
243
|
+
on_notice(
|
|
244
|
+
f"Using {len(setup_values)} setup value(s) from "
|
|
245
|
+
f"{configuration.VARIABLES_FILENAME}: {', '.join(setup_values)}"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
workspace = prepare_environment(bundle_root)
|
|
249
|
+
before_run = _workspace_files(workspace)
|
|
250
|
+
|
|
251
|
+
# The workforce path builds no crew this module can introspect, so this
|
|
252
|
+
# is its whole answer: the buyer's own explicit override, or nothing.
|
|
253
|
+
# The single-crew branch below upgrades it to what CrewAI actually
|
|
254
|
+
# resolved, which is why this is reassigned rather than read again later.
|
|
255
|
+
model_id = str(os.environ.get(MODEL_ENV_KEY) or "").strip()
|
|
256
|
+
steps: list[RunStep] = []
|
|
257
|
+
step_clock = {"last": time.perf_counter()}
|
|
258
|
+
|
|
259
|
+
def _record(
|
|
260
|
+
name: str, *, model_id: str = "", input_tokens: int | None = None, output_tokens: int | None = None
|
|
261
|
+
) -> None:
|
|
262
|
+
now = time.perf_counter()
|
|
263
|
+
step = RunStep(
|
|
264
|
+
name=name,
|
|
265
|
+
latency_ms=int((now - step_clock["last"]) * 1000),
|
|
266
|
+
model_id=model_id,
|
|
267
|
+
input_tokens=input_tokens,
|
|
268
|
+
output_tokens=output_tokens,
|
|
269
|
+
)
|
|
270
|
+
step_clock["last"] = now
|
|
271
|
+
steps.append(step)
|
|
272
|
+
if on_step is not None:
|
|
273
|
+
# Raising here aborts the run, which is how a disconnected
|
|
274
|
+
# stream client stops an agent: the relay's write fails, the
|
|
275
|
+
# exception unwinds through crewai, and no further task starts.
|
|
276
|
+
on_step(step)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
manifest, manifest_error = _load_workforce_manifest(bundle_root / "config")
|
|
280
|
+
if manifest is not None:
|
|
281
|
+
result = workforce.run_workforce(manifest, prompt=prompt, workspace=workspace, configuration=config_block)
|
|
282
|
+
else:
|
|
283
|
+
if manifest_error is not None and on_notice is not None:
|
|
284
|
+
on_notice(
|
|
285
|
+
f"workforce.yaml could not be loaded ({manifest_error}); falling back to the flattened crew config."
|
|
286
|
+
)
|
|
287
|
+
config = loader.load_config(bundle_root / "config")
|
|
288
|
+
tools = sandbox.build_tools(config.tool_refs, workspace=workspace)
|
|
289
|
+
crew = loader.build_crew(config, tools)
|
|
290
|
+
model_id = _resolved_model_id(crew) or model_id
|
|
291
|
+
_attach_task_callback(crew, _record, model_id=model_id)
|
|
292
|
+
result = crew.kickoff(inputs={"prompt": prompt, "configuration": config_block})
|
|
293
|
+
except ModuleNotFoundError as exc:
|
|
294
|
+
# crewai and its tree are imported lazily, so a bundle whose
|
|
295
|
+
# requirements were never installed fails here rather than at import.
|
|
296
|
+
# Reported as the actionable error rather than as an agent failure.
|
|
297
|
+
last_error.write_text(traceback.format_exc(), encoding="utf-8")
|
|
298
|
+
raise MissingDependencyError(exc.name or "crewai") from exc
|
|
299
|
+
except Exception:
|
|
300
|
+
last_error.write_text(traceback.format_exc(), encoding="utf-8")
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
if last_error.exists():
|
|
304
|
+
last_error.unlink()
|
|
305
|
+
|
|
306
|
+
after_run = _workspace_files(workspace)
|
|
307
|
+
outcome = RunOutcome(
|
|
308
|
+
text=str(result),
|
|
309
|
+
files_written=sorted(after_run - before_run),
|
|
310
|
+
steps=steps,
|
|
311
|
+
duration_seconds=time.monotonic() - started,
|
|
312
|
+
workforce=manifest is not None,
|
|
313
|
+
model_id=model_id,
|
|
314
|
+
)
|
|
315
|
+
_attach_usage(outcome, result)
|
|
316
|
+
return outcome
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --- Internals -------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _load_workforce_manifest(config_dir: Path) -> tuple[Any | None, Exception | None]:
|
|
323
|
+
"""The manifest, or ``None`` plus the reason it could not be loaded.
|
|
324
|
+
|
|
325
|
+
An AI Workforce bundle carries ``workforce.yaml`` next to the flattened
|
|
326
|
+
single-crew config. The manifest is the primary form; a manifest that
|
|
327
|
+
fails to load degrades to the flattened config rather than failing the
|
|
328
|
+
run.
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
return workforce.load_workforce(config_dir), None
|
|
332
|
+
except workforce.WorkforceConfigError as exc:
|
|
333
|
+
return None, exc
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _attach_task_callback(crew: Any, record: Callable[..., None], *, model_id: str) -> None:
|
|
337
|
+
"""Report each finished task through crewai's own callback, if it has one.
|
|
338
|
+
|
|
339
|
+
Guarded rather than assumed: ``task_callback`` is not part of anything
|
|
340
|
+
this bundle pins, and a crewai release that drops or renames it should
|
|
341
|
+
cost a run its progress reporting, never the run itself.
|
|
342
|
+
|
|
343
|
+
**Per-step tokens are a delta, not a measurement crewai hands over.**
|
|
344
|
+
Usage is tracked cumulatively per agent —
|
|
345
|
+
``agent.llm.get_token_usage_summary()`` / ``agent._token_process`` — and
|
|
346
|
+
``TaskOutput`` itself carries no usage field at all (re-checked against
|
|
347
|
+
the pinned 1.15.20 at that bump: its fields are description, name,
|
|
348
|
+
expected_output, summary, raw, pydantic, json_dict, agent, output_format,
|
|
349
|
+
messages, tool_failures). So a step's tokens are read as (the crew's running total
|
|
350
|
+
right after this task) minus (the total after the previous one) — the
|
|
351
|
+
only per-task granularity the public API exposes, and exact for a
|
|
352
|
+
sequential crew. For a fan-out one (``_process: parallel``'s
|
|
353
|
+
``async_execution`` tasks), ``task_callback`` can fire from more than one
|
|
354
|
+
thread, so two tasks finishing close together can split a delta unevenly
|
|
355
|
+
between them; the deltas still sum to the crew's real total, which is
|
|
356
|
+
what ``cost_usd`` is computed from, so that number stays correct even
|
|
357
|
+
when one step's does not. The lock keeps the running-total bookkeeping
|
|
358
|
+
itself race-free — a plain read-then-write here would double-count or
|
|
359
|
+
drop tokens under exactly that concurrency.
|
|
360
|
+
"""
|
|
361
|
+
lock = threading.Lock()
|
|
362
|
+
previous = {"prompt_tokens": 0, "completion_tokens": 0}
|
|
363
|
+
|
|
364
|
+
def _callback(task_output: Any) -> None:
|
|
365
|
+
# The task's own name is never truncated: it is the task's identity,
|
|
366
|
+
# and a short YAML key, not prose. Only the
|
|
367
|
+
# fallback needs a bound: a task with no ``name=`` reports none on
|
|
368
|
+
# its ``TaskOutput`` either, and its ``description`` is the seller's
|
|
369
|
+
# own prompt text, unbounded, which is what the cut-to-120 protects
|
|
370
|
+
# against — see ``loader.build_crew``'s docstring for why a task
|
|
371
|
+
# should carry ``name=`` at all.
|
|
372
|
+
name = str(getattr(task_output, "name", "") or "").strip()
|
|
373
|
+
if not name:
|
|
374
|
+
description = str(getattr(task_output, "description", "") or "").strip()
|
|
375
|
+
name = description.splitlines()[0][:120] if description else ""
|
|
376
|
+
input_tokens: int | None = None
|
|
377
|
+
output_tokens: int | None = None
|
|
378
|
+
with lock:
|
|
379
|
+
snapshot = _crew_usage_snapshot(crew)
|
|
380
|
+
if snapshot is not None:
|
|
381
|
+
prompt_total, completion_total = snapshot
|
|
382
|
+
input_tokens = max(0, prompt_total - previous["prompt_tokens"])
|
|
383
|
+
output_tokens = max(0, completion_total - previous["completion_tokens"])
|
|
384
|
+
previous["prompt_tokens"] = prompt_total
|
|
385
|
+
previous["completion_tokens"] = completion_total
|
|
386
|
+
record(name or "task", model_id=model_id, input_tokens=input_tokens, output_tokens=output_tokens)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
crew.task_callback = _callback
|
|
390
|
+
except Exception: # noqa: BLE001 - progress reporting is not the run
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _resolved_model_id(crew: Any) -> str:
|
|
395
|
+
"""The model crewai actually resolved for this crew, read off an agent.
|
|
396
|
+
|
|
397
|
+
``Agent.llm`` resolves to a real LLM object the moment the agent is
|
|
398
|
+
constructed — crewai's own default when the buyer set nothing — so its
|
|
399
|
+
``.model`` is what actually produced the tokens being counted, unlike
|
|
400
|
+
``OPENAI_MODEL_NAME``, which most buyers never set and which then leaves
|
|
401
|
+
every step, and ``cost_usd``, silently absent. Every agent this
|
|
402
|
+
loader builds shares one model — nothing passes a per-agent ``llm=``
|
|
403
|
+
override — so the first one found, worker or manager, is the run's.
|
|
404
|
+
"""
|
|
405
|
+
for agent in [*(getattr(crew, "agents", None) or []), getattr(crew, "manager_agent", None)]:
|
|
406
|
+
if agent is None:
|
|
407
|
+
continue
|
|
408
|
+
model = getattr(getattr(agent, "llm", None), "model", "")
|
|
409
|
+
if model:
|
|
410
|
+
return str(model)
|
|
411
|
+
return ""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _crew_usage_snapshot(crew: Any) -> tuple[int, int] | None:
|
|
415
|
+
"""``(prompt_tokens, completion_tokens)`` right now, cumulative, or ``None``.
|
|
416
|
+
|
|
417
|
+
``calculate_usage_metrics`` is the same method ``kickoff()`` calls at the
|
|
418
|
+
end to populate its own result; calling it mid-run just re-reads each
|
|
419
|
+
agent's running counters early, live, rather than a separate in-progress
|
|
420
|
+
API.
|
|
421
|
+
"""
|
|
422
|
+
try:
|
|
423
|
+
usage = crew.calculate_usage_metrics()
|
|
424
|
+
except Exception: # noqa: BLE001 - progress reporting is not the run
|
|
425
|
+
return None
|
|
426
|
+
if usage is None:
|
|
427
|
+
return None
|
|
428
|
+
return int(getattr(usage, "prompt_tokens", 0) or 0), int(getattr(usage, "completion_tokens", 0) or 0)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _attach_usage(outcome: RunOutcome, result: Any) -> None:
|
|
432
|
+
"""Copy crewai's token totals onto the outcome, when it reports them.
|
|
433
|
+
|
|
434
|
+
Absent rather than zero when it does not: Postern's ``usage`` is
|
|
435
|
+
``SHOULD`` be present "when the runner can determine it", and a zero
|
|
436
|
+
token count is a determination rather than an absence.
|
|
437
|
+
"""
|
|
438
|
+
usage = getattr(result, "token_usage", None)
|
|
439
|
+
if usage is None:
|
|
440
|
+
return
|
|
441
|
+
outcome.input_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
442
|
+
outcome.output_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _workspace_files(workspace: Path) -> set[str]:
|
|
446
|
+
return {p.relative_to(workspace).as_posix() for p in workspace.rglob("*") if p.is_file()}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
__all__ = [
|
|
450
|
+
"LAST_ERROR_FILENAME",
|
|
451
|
+
"MODEL_ENV_KEY",
|
|
452
|
+
"MissingDependencyError",
|
|
453
|
+
"PROMPT_INPUT_KEY",
|
|
454
|
+
"WORKSPACE_DIRNAME",
|
|
455
|
+
"RunConfig",
|
|
456
|
+
"RunOutcome",
|
|
457
|
+
"RUNNER_ENV_PREFIXES",
|
|
458
|
+
"RunStep",
|
|
459
|
+
"drop_runner_settings",
|
|
460
|
+
"execute",
|
|
461
|
+
"is_runner_setting",
|
|
462
|
+
"load_run_config",
|
|
463
|
+
"prepare_environment",
|
|
464
|
+
"without_runner_settings",
|
|
465
|
+
]
|