From 26f8631b684ed1a96c6950d75cce1edf00f5924c Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Tue, 12 May 2026 17:30:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(layer-4):=20SDK=20additive=20=E2=80=94=20l?= =?UTF-8?q?ive=5Ffallback=5Fmode=20+=20parent=5Fagent=5Fid=20kwargs=20(cue?= =?UTF-8?q?api=20#823=20+=20#824=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-id-split refactor Layer 4 OSS-SDK additives. Hosted cueapi shipped the substrate today (PR-A #823 schema + PR-B #824 router + PR-C #825 migration + #828 hotfix + PR-D #830 orphan-binding). cueapi-core OSS substrate port is DEFERRED to a future sprint (Backlog row cmp2zi9tl001w04jxcxw3ank1 — 4-layer dependency stack: agent_live_sessions convergence → Surface 6 OSS → Lane 1 OSS → Layer 4 OSS); this PR ships the additive SDK surface in parallel so hosted-cueapi users get the new kwargs immediately. Graceful degradation: hosted accepts; cueapi-core will 422 until precursors land. ## What ships - **`MessagesResource.send(live_fallback_mode=None)`** — per-message override for substrate's Live-fallback semantic. `"live_only"` queues until target Live agent's session is heartbeating; `"fallback_to_background"` falls through to Live-sibling's BG parent (via parent_agent_id) when Live is silent. Default `None` omits the field from the wire body — server applies its default (`"fallback_to_background"` per spec lock 22:11Z 2026-05-12). - **`AgentsResource.create(parent_agent_id=None)`** — caller-supplied `agt_<12alpha>` linking the new agent to a BG parent. `None` = BG agent (default — canonical entry point for a project). Substrate enforces same-tenant + 1-level hierarchy. When supplied without explicit slug, substrate auto-derives `-live` (collision- suffix on per-user duplicates). Both kwargs are PURELY ADDITIVE — wire format identical to pre-Layer-4 callers when omitted. Docstrings cross-link the Backlog row + flag the hosted-vs-cueapi-core graceful-degradation contract explicitly so SDK users understand the OSS gap. ## Tests (8 new across 2 files) `tests/test_messages_resource.py::TestLiveFallbackMode` (3 tests): - live_fallback_mode omitted when None ⇒ field absent on wire - live_fallback_mode="live_only" passes through verbatim - live_fallback_mode="fallback_to_background" passes through verbatim `tests/test_agents_resource.py::TestCreate` (3 new in existing class): - parent_agent_id omitted when None ⇒ field absent on wire - parent_agent_id="agt_bg_parent" passes through verbatim - parent_agent_id combines cleanly with slug + webhook_url + display_name Full unit suite (161 tests): all pass. ## Companion artifacts - Backlog row cmp2zi9tl001w04jxcxw3ank1 updated 2026-05-13 ~00:32Z with explicit TRIGGER CONDITION (auto-pick-up when 3 OSS predecessors land) + LAST-VERIFIED EMPIRICAL STATE of cueapi-core (2 checks pinned for clean restart context). Future agent reading the row sees exactly what to verify before starting Layer 4 substrate port. ## cueapi-three-2 owns cli + mcp + action additives This PR is the cueapi-python additive only. Per PM CTO call msg_8e7b5f47, cli + mcp + action mirror additives are cueapi-three-2's lane. No overlap. --- cueapi/resources/agents.py | 23 +++++++++- cueapi/resources/messages.py | 21 +++++++++ tests/test_agents_resource.py | 66 ++++++++++++++++++++++++++++ tests/test_messages_resource.py | 77 +++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) diff --git a/cueapi/resources/agents.py b/cueapi/resources/agents.py index c5c6d36..2174324 100644 --- a/cueapi/resources/agents.py +++ b/cueapi/resources/agents.py @@ -29,6 +29,7 @@ def create( slug: Optional[str] = None, webhook_url: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, + parent_agent_id: Optional[str] = None, ) -> dict: """Create an agent. @@ -44,11 +45,25 @@ def create( webhook_url: Push-delivery target. SSRF-validated. Omit for poll-only. metadata: Optional JSON metadata blob. + parent_agent_id: Optional ``agt_<12-alphanumeric>`` linking + this new agent to a BG parent (agent-id-split refactor + Layer 4, cueapi #823). NULL = BG agent (the default + shape — canonical entry point for a project's + coordination address). Supplying it makes this a Live + sibling. Substrate enforces: parent must be same-tenant + + must NOT itself be a Live sibling (1-level hierarchy). + When supplied without an explicit ``slug``, server + auto-derives ``-live`` (with collision- + suffix). Currently accepted by hosted cueapi; OSS + cueapi-core rejects with 422 until the Layer 4 OSS + port lands (graceful degradation; tracked on Backlog + row cmp2zi9tl001w04jxcxw3ank1). Returns: Dict matching the server's ``AgentResponse`` shape, including ``webhook_secret`` ONCE on this response if ``webhook_url`` - was given. + was given. The response surfaces ``parent_agent_id`` — + NULL for BG agents, non-NULL for Live siblings. """ body: Dict[str, Any] = {"display_name": display_name} if slug is not None: @@ -57,6 +72,12 @@ def create( body["webhook_url"] = webhook_url if metadata is not None: body["metadata"] = metadata + if parent_agent_id is not None: + # Per agent-id-split Layer 4 (cueapi #823). Default-omit so + # wire format matches pre-Layer-4 callers; server treats + # absent === NULL (BG agent — the default shape). + # Hosted accepts; cueapi-core OSS will 422 until precursors land. + body["parent_agent_id"] = parent_agent_id return self._client._post("/v1/agents", json=body) def list( diff --git a/cueapi/resources/messages.py b/cueapi/resources/messages.py index d95c06c..978810a 100644 --- a/cueapi/resources/messages.py +++ b/cueapi/resources/messages.py @@ -46,6 +46,7 @@ def send( idempotency_key: Optional[str] = None, send_at: Optional[Union[str, datetime]] = None, auto_verify: bool = True, + live_fallback_mode: Optional[str] = None, ) -> dict: """Send a message. @@ -87,6 +88,19 @@ def send( is delivered immediately. Per-message scheduling landed in cueapi #623 — server stores ``send_at`` on the message row and the worker picks it up when due. + live_fallback_mode: Per-message override for the agent-id- + split refactor's Live-fallback semantic (cueapi #824, + Layer 4). ``"live_only"`` queues the message until the + target Live agent's session is actively heartbeating; + ``"fallback_to_background"`` falls through to the + Live-sibling's BG parent (via ``parent_agent_id``) when + the Live session is silent. Default ``None`` omits the + field from the wire body (server default applies — + ``"fallback_to_background"`` per spec lock 22:11Z + 2026-05-12). The field is currently accepted by hosted + cueapi; OSS cueapi-core rejects with 422 until the + Layer 4 OSS port lands (graceful degradation; tracked + on Backlog row cmp2zi9tl001w04jxcxw3ank1). Returns: Dict matching the server's ``MessageResponse`` shape. @@ -117,6 +131,13 @@ def send( payload["send_at"] = ( send_at.isoformat() if isinstance(send_at, datetime) else send_at ) + if live_fallback_mode is not None: + # Per agent-id-split Layer 4 (cueapi #824). Default-omit when + # caller didn't specify so wire format matches pre-Layer-4 + # callers; server applies its own default + # (``fallback_to_background`` per spec lock 22:11Z 2026-05-12). + # Hosted accepts; cueapi-core OSS will 422 until precursors land. + payload["live_fallback_mode"] = live_fallback_mode headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent} if idempotency_key is not None: diff --git a/tests/test_agents_resource.py b/tests/test_agents_resource.py index e8770d0..b5291b1 100644 --- a/tests/test_agents_resource.py +++ b/tests/test_agents_resource.py @@ -48,6 +48,72 @@ def test_with_all_optionals(self): }, ) + def test_parent_agent_id_omitted_when_none(self): + """Agent-id-split refactor Layer 4 (cueapi #823). Default None ⇒ + field NOT on wire (preserves pre-Layer-4 shape — BG agent is the + canonical entry point).""" + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_x", "slug": "team-comm", "display_name": "Team Comm", + "status": "online", + } + r = AgentsResource(mock_client) + + r.create(display_name="Team Comm") + + call = mock_client._post.call_args + assert "parent_agent_id" not in call.kwargs["json"] + + def test_parent_agent_id_passes_through(self): + """Caller supplies parent_agent_id ⇒ flows into request body + verbatim. Substrate (hosted) interprets as a Live-sibling create + + auto-derives ``-live`` when slug omitted; cueapi-core + OSS 422s until precursors land (graceful degradation per spec).""" + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_live_x", "slug": "team-comm-live", + "display_name": "Team Comm Live", "status": "online", + "parent_agent_id": "agt_bg_parent", + } + r = AgentsResource(mock_client) + + r.create( + display_name="Team Comm Live", + parent_agent_id="agt_bg_parent", + ) + + mock_client._post.assert_called_once_with( + "/v1/agents", + json={ + "display_name": "Team Comm Live", + "parent_agent_id": "agt_bg_parent", + }, + ) + + def test_parent_agent_id_combines_with_other_optionals(self): + """Live-sibling create with explicit slug (Q5 labeled-session + convention) — parent_agent_id + slug + webhook combine cleanly + in the body.""" + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_live_label", "slug": "team-comm-live-debug", + } + r = AgentsResource(mock_client) + + r.create( + display_name="Team Comm Live (debug)", + slug="team-comm-live-debug", + webhook_url="https://x.example/live-hook", + parent_agent_id="agt_bg_parent", + ) + + call = mock_client._post.call_args + body = call.kwargs["json"] + assert body["parent_agent_id"] == "agt_bg_parent" + assert body["slug"] == "team-comm-live-debug" + assert body["webhook_url"] == "https://x.example/live-hook" + assert body["display_name"] == "Team Comm Live (debug)" + class TestList: def test_defaults_omit_filters(self): diff --git a/tests/test_messages_resource.py b/tests/test_messages_resource.py index 9eafdfc..98214ec 100644 --- a/tests/test_messages_resource.py +++ b/tests/test_messages_resource.py @@ -206,6 +206,83 @@ def test_send_without_send_at_omits_field(self): assert "send_at" not in call.kwargs["json"] +class TestLiveFallbackMode: + """Agent-id-split refactor Layer 4 (cueapi #824) — live_fallback_mode kwarg. + + Per-message override for substrate's Live-fallback semantic. ``live_only`` + queues until the target Live agent's session is actively heartbeating; + ``fallback_to_background`` falls through to the Live-sibling's BG parent + when Live is silent. Default-omit when None so wire format matches + pre-Layer-4 callers; server applies its default + (``fallback_to_background`` per spec lock 22:11Z 2026-05-12). + + Backlog row cmp2zi9tl001w04jxcxw3ank1 tracks the cueapi-core OSS port; + hosted accepts; cueapi-core 422 until precursors land (graceful + degradation). + """ + + def test_live_fallback_mode_omitted_when_none(self): + """Default None ⇒ field NOT on wire (preserves pre-Layer-4 shape).""" + from unittest.mock import MagicMock + from cueapi.resources.messages import MessagesResource + + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send(from_agent="sender@x", to="recipient@y", body="hi") + + call = mock_client._post.call_args + assert "live_fallback_mode" not in call.kwargs["json"] + + def test_live_fallback_mode_live_only_passes_through(self): + """``live_only`` flows verbatim into the request body.""" + from unittest.mock import MagicMock + from cueapi.resources.messages import MessagesResource + + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + live_fallback_mode="live_only", + ) + + mock_client._post.assert_called_once_with( + "/v1/messages", + json={ + "to": "recipient@y", + "body": "hi", + "live_fallback_mode": "live_only", + }, + headers={"X-Cueapi-From-Agent": "sender@x", "X-CueAPI-Verify-Echo": "true"}, + ) + + def test_live_fallback_mode_fallback_to_background_passes_through(self): + """``fallback_to_background`` flows verbatim. Explicit-default value + is wired-out so callers can disambiguate "I explicitly want fallback" + from "I didn't specify".""" + from unittest.mock import MagicMock + from cueapi.resources.messages import MessagesResource + + mock_client = MagicMock() + mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"} + r = MessagesResource(mock_client) + + r.send( + from_agent="sender@x", + to="recipient@y", + body="hi", + live_fallback_mode="fallback_to_background", + ) + + call = mock_client._post.call_args + assert call.kwargs["json"]["live_fallback_mode"] == "fallback_to_background" + + class TestAutoVerify: """Phase 2 of body-verify defense in depth (Mike directive 2026-05-11).