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