Skip to content

Commit bd9b21b

Browse files
authored
feat(layer-4): SDK additive — live_fallback_mode + parent_agent_id kwargs (cueapi #823 + #824 parity) (#44)
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 `<parent_slug>-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.
1 parent 9669ccc commit bd9b21b

4 files changed

Lines changed: 186 additions & 1 deletion

File tree

cueapi/resources/agents.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def create(
2929
slug: Optional[str] = None,
3030
webhook_url: Optional[str] = None,
3131
metadata: Optional[Dict[str, Any]] = None,
32+
parent_agent_id: Optional[str] = None,
3233
) -> dict:
3334
"""Create an agent.
3435
@@ -44,11 +45,25 @@ def create(
4445
webhook_url: Push-delivery target. SSRF-validated. Omit for
4546
poll-only.
4647
metadata: Optional JSON metadata blob.
48+
parent_agent_id: Optional ``agt_<12-alphanumeric>`` linking
49+
this new agent to a BG parent (agent-id-split refactor
50+
Layer 4, cueapi #823). NULL = BG agent (the default
51+
shape — canonical entry point for a project's
52+
coordination address). Supplying it makes this a Live
53+
sibling. Substrate enforces: parent must be same-tenant
54+
+ must NOT itself be a Live sibling (1-level hierarchy).
55+
When supplied without an explicit ``slug``, server
56+
auto-derives ``<parent_slug>-live`` (with collision-
57+
suffix). Currently accepted by hosted cueapi; OSS
58+
cueapi-core rejects with 422 until the Layer 4 OSS
59+
port lands (graceful degradation; tracked on Backlog
60+
row cmp2zi9tl001w04jxcxw3ank1).
4761
4862
Returns:
4963
Dict matching the server's ``AgentResponse`` shape, including
5064
``webhook_secret`` ONCE on this response if ``webhook_url``
51-
was given.
65+
was given. The response surfaces ``parent_agent_id`` —
66+
NULL for BG agents, non-NULL for Live siblings.
5267
"""
5368
body: Dict[str, Any] = {"display_name": display_name}
5469
if slug is not None:
@@ -57,6 +72,12 @@ def create(
5772
body["webhook_url"] = webhook_url
5873
if metadata is not None:
5974
body["metadata"] = metadata
75+
if parent_agent_id is not None:
76+
# Per agent-id-split Layer 4 (cueapi #823). Default-omit so
77+
# wire format matches pre-Layer-4 callers; server treats
78+
# absent === NULL (BG agent — the default shape).
79+
# Hosted accepts; cueapi-core OSS will 422 until precursors land.
80+
body["parent_agent_id"] = parent_agent_id
6081
return self._client._post("/v1/agents", json=body)
6182

6283
def list(

cueapi/resources/messages.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def send(
4646
idempotency_key: Optional[str] = None,
4747
send_at: Optional[Union[str, datetime]] = None,
4848
auto_verify: bool = True,
49+
live_fallback_mode: Optional[str] = None,
4950
) -> dict:
5051
"""Send a message.
5152
@@ -87,6 +88,19 @@ def send(
8788
is delivered immediately. Per-message scheduling landed
8889
in cueapi #623 — server stores ``send_at`` on the
8990
message row and the worker picks it up when due.
91+
live_fallback_mode: Per-message override for the agent-id-
92+
split refactor's Live-fallback semantic (cueapi #824,
93+
Layer 4). ``"live_only"`` queues the message until the
94+
target Live agent's session is actively heartbeating;
95+
``"fallback_to_background"`` falls through to the
96+
Live-sibling's BG parent (via ``parent_agent_id``) when
97+
the Live session is silent. Default ``None`` omits the
98+
field from the wire body (server default applies —
99+
``"fallback_to_background"`` per spec lock 22:11Z
100+
2026-05-12). The field is currently accepted by hosted
101+
cueapi; OSS cueapi-core rejects with 422 until the
102+
Layer 4 OSS port lands (graceful degradation; tracked
103+
on Backlog row cmp2zi9tl001w04jxcxw3ank1).
90104
91105
Returns:
92106
Dict matching the server's ``MessageResponse`` shape.
@@ -117,6 +131,13 @@ def send(
117131
payload["send_at"] = (
118132
send_at.isoformat() if isinstance(send_at, datetime) else send_at
119133
)
134+
if live_fallback_mode is not None:
135+
# Per agent-id-split Layer 4 (cueapi #824). Default-omit when
136+
# caller didn't specify so wire format matches pre-Layer-4
137+
# callers; server applies its own default
138+
# (``fallback_to_background`` per spec lock 22:11Z 2026-05-12).
139+
# Hosted accepts; cueapi-core OSS will 422 until precursors land.
140+
payload["live_fallback_mode"] = live_fallback_mode
120141

121142
headers: Dict[str, str] = {"X-Cueapi-From-Agent": from_agent}
122143
if idempotency_key is not None:

tests/test_agents_resource.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,72 @@ def test_with_all_optionals(self):
4848
},
4949
)
5050

51+
def test_parent_agent_id_omitted_when_none(self):
52+
"""Agent-id-split refactor Layer 4 (cueapi #823). Default None ⇒
53+
field NOT on wire (preserves pre-Layer-4 shape — BG agent is the
54+
canonical entry point)."""
55+
mock_client = MagicMock()
56+
mock_client._post.return_value = {
57+
"id": "agt_x", "slug": "team-comm", "display_name": "Team Comm",
58+
"status": "online",
59+
}
60+
r = AgentsResource(mock_client)
61+
62+
r.create(display_name="Team Comm")
63+
64+
call = mock_client._post.call_args
65+
assert "parent_agent_id" not in call.kwargs["json"]
66+
67+
def test_parent_agent_id_passes_through(self):
68+
"""Caller supplies parent_agent_id ⇒ flows into request body
69+
verbatim. Substrate (hosted) interprets as a Live-sibling create
70+
+ auto-derives ``<parent_slug>-live`` when slug omitted; cueapi-core
71+
OSS 422s until precursors land (graceful degradation per spec)."""
72+
mock_client = MagicMock()
73+
mock_client._post.return_value = {
74+
"id": "agt_live_x", "slug": "team-comm-live",
75+
"display_name": "Team Comm Live", "status": "online",
76+
"parent_agent_id": "agt_bg_parent",
77+
}
78+
r = AgentsResource(mock_client)
79+
80+
r.create(
81+
display_name="Team Comm Live",
82+
parent_agent_id="agt_bg_parent",
83+
)
84+
85+
mock_client._post.assert_called_once_with(
86+
"/v1/agents",
87+
json={
88+
"display_name": "Team Comm Live",
89+
"parent_agent_id": "agt_bg_parent",
90+
},
91+
)
92+
93+
def test_parent_agent_id_combines_with_other_optionals(self):
94+
"""Live-sibling create with explicit slug (Q5 labeled-session
95+
convention) — parent_agent_id + slug + webhook combine cleanly
96+
in the body."""
97+
mock_client = MagicMock()
98+
mock_client._post.return_value = {
99+
"id": "agt_live_label", "slug": "team-comm-live-debug",
100+
}
101+
r = AgentsResource(mock_client)
102+
103+
r.create(
104+
display_name="Team Comm Live (debug)",
105+
slug="team-comm-live-debug",
106+
webhook_url="https://x.example/live-hook",
107+
parent_agent_id="agt_bg_parent",
108+
)
109+
110+
call = mock_client._post.call_args
111+
body = call.kwargs["json"]
112+
assert body["parent_agent_id"] == "agt_bg_parent"
113+
assert body["slug"] == "team-comm-live-debug"
114+
assert body["webhook_url"] == "https://x.example/live-hook"
115+
assert body["display_name"] == "Team Comm Live (debug)"
116+
51117

52118
class TestList:
53119
def test_defaults_omit_filters(self):

tests/test_messages_resource.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,83 @@ def test_send_without_send_at_omits_field(self):
206206
assert "send_at" not in call.kwargs["json"]
207207

208208

209+
class TestLiveFallbackMode:
210+
"""Agent-id-split refactor Layer 4 (cueapi #824) — live_fallback_mode kwarg.
211+
212+
Per-message override for substrate's Live-fallback semantic. ``live_only``
213+
queues until the target Live agent's session is actively heartbeating;
214+
``fallback_to_background`` falls through to the Live-sibling's BG parent
215+
when Live is silent. Default-omit when None so wire format matches
216+
pre-Layer-4 callers; server applies its default
217+
(``fallback_to_background`` per spec lock 22:11Z 2026-05-12).
218+
219+
Backlog row cmp2zi9tl001w04jxcxw3ank1 tracks the cueapi-core OSS port;
220+
hosted accepts; cueapi-core 422 until precursors land (graceful
221+
degradation).
222+
"""
223+
224+
def test_live_fallback_mode_omitted_when_none(self):
225+
"""Default None ⇒ field NOT on wire (preserves pre-Layer-4 shape)."""
226+
from unittest.mock import MagicMock
227+
from cueapi.resources.messages import MessagesResource
228+
229+
mock_client = MagicMock()
230+
mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"}
231+
r = MessagesResource(mock_client)
232+
233+
r.send(from_agent="sender@x", to="recipient@y", body="hi")
234+
235+
call = mock_client._post.call_args
236+
assert "live_fallback_mode" not in call.kwargs["json"]
237+
238+
def test_live_fallback_mode_live_only_passes_through(self):
239+
"""``live_only`` flows verbatim into the request body."""
240+
from unittest.mock import MagicMock
241+
from cueapi.resources.messages import MessagesResource
242+
243+
mock_client = MagicMock()
244+
mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"}
245+
r = MessagesResource(mock_client)
246+
247+
r.send(
248+
from_agent="sender@x",
249+
to="recipient@y",
250+
body="hi",
251+
live_fallback_mode="live_only",
252+
)
253+
254+
mock_client._post.assert_called_once_with(
255+
"/v1/messages",
256+
json={
257+
"to": "recipient@y",
258+
"body": "hi",
259+
"live_fallback_mode": "live_only",
260+
},
261+
headers={"X-Cueapi-From-Agent": "sender@x", "X-CueAPI-Verify-Echo": "true"},
262+
)
263+
264+
def test_live_fallback_mode_fallback_to_background_passes_through(self):
265+
"""``fallback_to_background`` flows verbatim. Explicit-default value
266+
is wired-out so callers can disambiguate "I explicitly want fallback"
267+
from "I didn't specify"."""
268+
from unittest.mock import MagicMock
269+
from cueapi.resources.messages import MessagesResource
270+
271+
mock_client = MagicMock()
272+
mock_client._post.return_value = {"id": "msg_x", "delivery_state": "queued"}
273+
r = MessagesResource(mock_client)
274+
275+
r.send(
276+
from_agent="sender@x",
277+
to="recipient@y",
278+
body="hi",
279+
live_fallback_mode="fallback_to_background",
280+
)
281+
282+
call = mock_client._post.call_args
283+
assert call.kwargs["json"]["live_fallback_mode"] == "fallback_to_background"
284+
285+
209286
class TestAutoVerify:
210287
"""Phase 2 of body-verify defense in depth (Mike directive 2026-05-11).
211288

0 commit comments

Comments
 (0)