From 1c386386597b6d8a7af96bd7c3f33bfe05a5534e Mon Sep 17 00:00:00 2001 From: shumway Date: Sun, 10 May 2026 21:36:44 +0200 Subject: [PATCH] feat(atelier): worker_engine_factory state convention + helper module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the optional state-key convention that lets host wrappers (AtelierOS, OpenClaw, ...) inject a WorkerEngine factory into the AWP state dict so workers can route their LLM calls through the host's engine layer instead of bypassing it. Closes the architecture gap identified during the AtelierOS Phase-4 wiring (ADR-0003 § Architecture invariant): host-side "engine_id" audit events have no enclosing AWP-task-id correlation when workers reach for an LLM directly. The convention is OPTIONAL — workers ignoring the key keep working exactly as before. Only workers wanting host-engine routing need to adopt it. Three changes: * awp/atelier_integration.py — pure-Python helper module: - WORKER_ENGINE_FACTORY_KEY constant ("worker_engine_factory") - extract_engine_factory(state) → callable | None - extract_engine_id(state, default) → str | None - resolve_engine(state, engine_id?) → WorkerEngine | None (composes factory lookup + call, fail-graceful) - has_atelier_context(state) → bool Zero hard deps on AtelierOS; works for any host following the same convention. * spec/versions/1.0/layers/04-memory-state.md — adds "worker_engine_factory" to the reserved-keys table in §2.3 with OPTIONAL status. Workers MUST NOT write to this key directly; orchestrator (host) owns it. * reference/python/tests/test_atelier_integration.py — 20 unittest cases covering all four helpers' happy + edge + failure paths (None state, non-callable value, wrong-type meta, raising factory, etc.). Backwards compatibility: 100%. Workers and hosts that don't know about this key see no behaviour change. Spec status: OPTIONAL convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../python/src/awp/atelier_integration.py | 129 +++++++++++++++ .../python/tests/test_atelier_integration.py | 150 ++++++++++++++++++ spec/versions/1.0/layers/04-memory-state.md | 1 + 3 files changed, 280 insertions(+) create mode 100644 reference/python/src/awp/atelier_integration.py create mode 100644 reference/python/tests/test_atelier_integration.py diff --git a/reference/python/src/awp/atelier_integration.py b/reference/python/src/awp/atelier_integration.py new file mode 100644 index 0000000..0251ecb --- /dev/null +++ b/reference/python/src/awp/atelier_integration.py @@ -0,0 +1,129 @@ +"""AtelierOS integration helpers. + +AtelierOS (https://github.com/veegee82/AtelierOS) wraps a single LLM-CLI +agent (Claude Code, Codex CLI, Gemini CLI, ...) into a multi-channel, +multi-persona service with audit-evident hash-chain logging. When +AtelierOS dispatches a complex task to AWP via ``AWPAgent.run(task, +state)``, it injects two optional auto-fields into the state dict so +AWP-workers can route their LLM calls through the AtelierOS Engine +layer instead of bypassing it: + + * ``state["worker_engine_factory"]`` — a callable + ``factory(engine_id: str | None = None) -> WorkerEngine | None`` + The worker calls the factory to obtain a configured engine instance + (Claude Code, Codex CLI, ...) for its LLM step. Calling without an + arg returns the chat's default engine; passing a specific id + overrides per worker step (Phase-4 multi-engine workflows). + + * ``state["meta"]["engine_id"]`` — the resolved default engine id for + audit attribution. AtelierOS uses this to verify the integrity rule + "every audit event with engine_id has an enclosing awp_task_id". + +Both fields are **optional**. Workers that do not honour the convention +keep working — they just bypass the AtelierOS Engine layer and run +whatever LLM their internals reach for. This module exists to make the +honoured path easy to write: import the helpers, the boilerplate is +done. + +This module has zero hard dependencies on AtelierOS — it works against +any caller that follows the same convention. +""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Optional + +# The reserved state-key carrying the engine factory. Spec layer-04 +# section 2.2 names this as a reserved key (workers MUST NOT write to +# it; the orchestrator owns it). +WORKER_ENGINE_FACTORY_KEY: str = "worker_engine_factory" + +# The conventional location for the engine_id label in state.meta. +META_ENGINE_ID_PATH: tuple = ("meta", "engine_id") + + +def extract_engine_factory(state: Dict[str, Any]) -> Optional[Callable[..., Any]]: + """Return the worker_engine_factory callable from state, or None. + + Safe to call on any state dict — missing key, wrong type, or + non-callable value all return None gracefully. Workers can therefore + write:: + + factory = extract_engine_factory(state) + if factory is None: + # No AtelierOS context — fall back to internal LLM client. + engine = None + else: + engine = factory("claude_code") # or factory() for default + # ... use engine to make the LLM call ... + """ + if not isinstance(state, dict): + return None + value = state.get(WORKER_ENGINE_FACTORY_KEY) + if value is None or not callable(value): + return None + return value + + +def extract_engine_id(state: Dict[str, Any], + default: Optional[str] = None) -> Optional[str]: + """Return state["meta"]["engine_id"] or default. + + Useful for workers that want to log/audit which engine they ended up + using without forcing the factory call. + """ + if not isinstance(state, dict): + return default + meta = state.get("meta") + if not isinstance(meta, dict): + return default + eid = meta.get("engine_id") + if isinstance(eid, str) and eid: + return eid + return default + + +def resolve_engine(state: Dict[str, Any], + engine_id: Optional[str] = None) -> Optional[Any]: + """Convenience: pull the factory from state and call it once. + + Returns the WorkerEngine instance for ``engine_id`` (or the chat's + default if engine_id is None), or None if either: + + * the factory is missing from state (caller has no AtelierOS + context), OR + * the factory exists but returns None for the requested engine + (engine binary not installed on this host, etc.). + + Workers that want a specific engine for *this step* (e.g. a code- + generation step that prefers ``claude_code`` even when the chat + defaults to ``codex_cli``) should pass an explicit engine_id. + """ + factory = extract_engine_factory(state) + if factory is None: + return None + try: + return factory(engine_id) if engine_id else factory() + except Exception: + # Factory contract is "fail graceful, return None on missing". + # Wrap defensively in case a malformed factory raises. + return None + + +def has_atelier_context(state: Dict[str, Any]) -> bool: + """True iff this state was injected by an AtelierOS-style caller. + + Useful for branching code that should behave differently when AWP + runs standalone vs. inside AtelierOS — e.g. emitting AtelierOS- + audit-compatible events only when the context is present. + """ + return extract_engine_factory(state) is not None + + +__all__ = [ + "WORKER_ENGINE_FACTORY_KEY", + "META_ENGINE_ID_PATH", + "extract_engine_factory", + "extract_engine_id", + "resolve_engine", + "has_atelier_context", +] diff --git a/reference/python/tests/test_atelier_integration.py b/reference/python/tests/test_atelier_integration.py new file mode 100644 index 0000000..5b85428 --- /dev/null +++ b/reference/python/tests/test_atelier_integration.py @@ -0,0 +1,150 @@ +"""Tests for awp.atelier_integration helpers. + +These cover the convention-side guarantees AWP-workers rely on when +running inside AtelierOS: + + * extract_engine_factory: returns the callable, None on missing / + wrong type / non-callable + * extract_engine_id: reads state["meta"]["engine_id"], falls back + * resolve_engine: factory + call composed; returns None gracefully + when factory missing OR factory raises + * has_atelier_context: True iff factory is present and callable + +Pure-Python, no AWP-runtime dependency, sub-millisecond per case. + +Run: python -m unittest reference.python.tests.test_atelier_integration +""" +from __future__ import annotations + +import unittest + +from awp.atelier_integration import ( + WORKER_ENGINE_FACTORY_KEY, + extract_engine_factory, + extract_engine_id, + has_atelier_context, + resolve_engine, +) + + +class _StubEngine: + """Stand-in for a WorkerEngine — just enough to be identifiable.""" + + def __init__(self, engine_id: str): + self.engine_id = engine_id + + +def _factory(engine_id: str | None = None) -> _StubEngine | None: + """Stand-in worker_engine_factory.""" + if engine_id is None: + engine_id = "claude_code" # the convention's default + if engine_id in ("claude_code", "codex_cli"): + return _StubEngine(engine_id) + return None + + +def _raising_factory(engine_id: str | None = None): + raise RuntimeError("malformed factory") + + +class ExtractEngineFactoryTests(unittest.TestCase): + def test_returns_callable_when_present(self): + state = {WORKER_ENGINE_FACTORY_KEY: _factory} + out = extract_engine_factory(state) + self.assertIs(out, _factory) + + def test_missing_key_returns_none(self): + self.assertIsNone(extract_engine_factory({})) + + def test_non_dict_state_returns_none(self): + self.assertIsNone(extract_engine_factory(None)) # type: ignore[arg-type] + self.assertIsNone(extract_engine_factory("not a dict")) # type: ignore[arg-type] + self.assertIsNone(extract_engine_factory([])) # type: ignore[arg-type] + + def test_non_callable_value_returns_none(self): + self.assertIsNone(extract_engine_factory({WORKER_ENGINE_FACTORY_KEY: "string"})) + self.assertIsNone(extract_engine_factory({WORKER_ENGINE_FACTORY_KEY: 42})) + self.assertIsNone(extract_engine_factory({WORKER_ENGINE_FACTORY_KEY: None})) + + def test_reserved_key_constant_value(self): + # Spec layer-04 names this exact key. Changing it is a breaking + # convention change. + self.assertEqual(WORKER_ENGINE_FACTORY_KEY, "worker_engine_factory") + + +class ExtractEngineIdTests(unittest.TestCase): + def test_returns_meta_engine_id(self): + state = {"meta": {"engine_id": "claude_code"}} + self.assertEqual(extract_engine_id(state), "claude_code") + + def test_missing_meta_returns_default(self): + self.assertIsNone(extract_engine_id({})) + self.assertEqual(extract_engine_id({}, "fallback"), "fallback") + + def test_meta_not_a_dict(self): + self.assertIsNone(extract_engine_id({"meta": "not a dict"})) + self.assertEqual( + extract_engine_id({"meta": [1, 2, 3]}, "fallback"), + "fallback", + ) + + def test_engine_id_not_a_string(self): + self.assertIsNone(extract_engine_id({"meta": {"engine_id": 42}})) + self.assertIsNone(extract_engine_id({"meta": {"engine_id": ""}})) + + def test_non_dict_state(self): + self.assertIsNone(extract_engine_id(None)) # type: ignore[arg-type] + self.assertEqual( + extract_engine_id("not a dict", "fallback"), # type: ignore[arg-type] + "fallback", + ) + + +class ResolveEngineTests(unittest.TestCase): + def test_resolves_default_engine(self): + state = {WORKER_ENGINE_FACTORY_KEY: _factory} + engine = resolve_engine(state) + self.assertIsNotNone(engine) + self.assertEqual(engine.engine_id, "claude_code") + + def test_resolves_specific_engine(self): + state = {WORKER_ENGINE_FACTORY_KEY: _factory} + engine = resolve_engine(state, "codex_cli") + self.assertIsNotNone(engine) + self.assertEqual(engine.engine_id, "codex_cli") + + def test_unknown_engine_id_returns_none(self): + state = {WORKER_ENGINE_FACTORY_KEY: _factory} + self.assertIsNone(resolve_engine(state, "nonexistent")) + + def test_no_factory_returns_none(self): + self.assertIsNone(resolve_engine({})) + self.assertIsNone(resolve_engine({}, "claude_code")) + + def test_raising_factory_returns_none_graceful(self): + state = {WORKER_ENGINE_FACTORY_KEY: _raising_factory} + self.assertIsNone(resolve_engine(state)) + self.assertIsNone(resolve_engine(state, "claude_code")) + + def test_non_dict_state(self): + self.assertIsNone(resolve_engine(None)) # type: ignore[arg-type] + + +class HasAtelierContextTests(unittest.TestCase): + def test_true_when_factory_present(self): + state = {WORKER_ENGINE_FACTORY_KEY: _factory} + self.assertTrue(has_atelier_context(state)) + + def test_false_when_missing(self): + self.assertFalse(has_atelier_context({})) + + def test_false_when_non_callable(self): + self.assertFalse(has_atelier_context({WORKER_ENGINE_FACTORY_KEY: "x"})) + + def test_false_when_state_is_not_a_dict(self): + self.assertFalse(has_atelier_context(None)) # type: ignore[arg-type] + self.assertFalse(has_atelier_context([])) # type: ignore[arg-type] + + +if __name__ == "__main__": + unittest.main() diff --git a/spec/versions/1.0/layers/04-memory-state.md b/spec/versions/1.0/layers/04-memory-state.md index de21705..c6e1845 100644 --- a/spec/versions/1.0/layers/04-memory-state.md +++ b/spec/versions/1.0/layers/04-memory-state.md @@ -67,6 +67,7 @@ The following state keys are reserved by the runtime. Agents MUST NOT write to t | `_errors` | Error accumulator for the current run. | | `_trace` | Trace context for distributed tracing. | | `_workflow` | Workflow-level metadata (name, version). | +| `worker_engine_factory` | OPTIONAL host-injected callable. When present, callers wrapping AWP (e.g. AtelierOS) inject a factory `factory(engine_id: str | None = None) -> WorkerEngine | None` that workers MAY call to obtain an LLM-CLI engine instance for their LLM step. Workers calling the factory route their LLM calls through the host's engine layer; workers ignoring it use whatever LLM client they were built against. The reference helper is `awp.atelier_integration` (`extract_engine_factory`, `resolve_engine`). Spec status: OPTIONAL convention; absence is the legacy single-LLM-source behaviour. | ### 2.4 Persistence