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,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
+ ]