Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 81 additions & 4 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,51 @@ const sendMessageSchema = z.object({
.describe(
"Phase 2 body-verify defense-in-depth (Mike directive 2026-05-11). When true (default), the tool sends `X-CueAPI-Verify-Echo: true` on the POST and compares the substrate-echoed `body_received` against the body sent. On mismatch, the tool throws an error with byte-level diff details so callers can detect silent body corruption (e.g. caller-side shell expansion of $(...) / `...` / ${VAR} before reaching the MCP tool). Set to `false` to opt out (rare; perf-sensitive flows only — verify adds zero substrate roundtrips since the echo rides in the same POST response). Parity with cueapi-cli `--no-verify` opt-out (#52) and cueapi-python `auto_verify` kwarg (#39)."
),
live_fallback_mode: z
.enum(["live_only", "fallback_to_background"])
.optional()
.describe(
"Agent-id-split refactor (2026-05-12) — controls behavior when the recipient is a Live-sibling agent and its Live session is silent (no fresh heartbeat). `fallback_to_background` (default — also the wire-format when omitted): substrate looks up the BG sibling via `parent_agent_id` and routes there (work gets done even if Live is detached). `live_only`: message queues against the Live agent until that specific Live session attaches. This MCP tool omits the field on the wire when set to `fallback_to_background` (or omitted) — server treats absent === fallback_to_background. Design Dock: https://trydock.ai/workspaces/agent-id-split-refactor-2026-05-12. Parity with cueapi-cli `--live-fallback-mode` (#56)."
),
});

// agent-id-split refactor (2026-05-12) — new `cueapi_create_agent` tool
// provides MCP parity with cueapi-cli `agents create` and exposes
// `parent_agent_id` (FK linking a Live sibling to its BG sibling so
// substrate can fall back from Live to BG when the Live session is
// silent). Optional field; default-omits on wire.
const createAgentSchema = z.object({
display_name: z
.string()
.min(1)
.max(255)
.describe(
"Human-readable agent name (required, 1-255 chars). Shown in the Agent Directory + on roster snapshots."
),
slug: z
.string()
.optional()
.describe(
"Per-user unique slug (optional; server derives from display_name when omitted). Stable identifier used in slug-form addresses (`<agent_slug>@<user_slug>`)."
),
webhook_url: z
.string()
.optional()
.describe(
"Push-delivery target. SSRF-validated by the server. Omit for poll-only (inbox-based) delivery. Pairs with the one-time webhook_secret returned on this create response — save it now; subsequent reads omit it."
),
metadata: z
.record(z.unknown())
.optional()
.describe(
"Arbitrary JSON metadata blob (server-opaque). Use for client-side tagging / display hints."
),
parent_agent_id: z
.string()
.optional()
.describe(
"Agent-id-split refactor (2026-05-12) — link this new agent as a sibling of the given parent agent. Used when creating a Live sibling for an existing BG agent: pass the BG agent's id here and substrate stores the FK so router can fall back from Live to BG via parent_agent_id when the Live session is silent (see `live_fallback_mode` on cueapi_send_message). Omit for standalone (parent) agents. Substrate enforces FK validity. Design Dock: https://trydock.ai/workspaces/agent-id-split-refactor-2026-05-12. Parity with cueapi-cli `--parent-agent-id` (#56)."
),
});

// ---------- tools ----------
Expand Down Expand Up @@ -710,10 +755,32 @@ export const tools: ToolDefinition[] = [
);
},
},
// Agent directory (read-only) — identity layer for the messaging primitive.
// Write operations are intentionally NOT exposed: agents are typically
// created/updated/deleted by humans via dashboard or CLI, not by MCP
// callers. Adding write surface would create the wrong abstraction.
// Agent directory — identity layer for the messaging primitive.
//
// Note (2026-05-12): the agent-id-split refactor needs `parent_agent_id`
// exposed on create so MCP callers can wire Live siblings of an
// existing BG agent. The historical "read-only — humans use dashboard/
// CLI" stance no longer holds for that workflow. `cueapi_create_agent`
// is the additive entry; update/delete remain dashboard-only for now.
{
name: "cueapi_create_agent",
description:
"Create a new agent. Returns the created record including a one-time `webhook_secret` when `webhook_url` is set (subsequent reads omit the secret — save it now or call regenerate to mint a new one which will revoke the old). Use `parent_agent_id` to link this new agent as a sibling of an existing parent (BG) agent — substrate stores the FK so the router can fall back from Live to BG via `parent_agent_id` when the Live session is silent (see `live_fallback_mode` on `cueapi_send_message`). Omit `parent_agent_id` for standalone (parent) agents. Agent-id-split refactor (2026-05-12); parity with cueapi-cli `agents create --parent-agent-id` (#56).",
schema: createAgentSchema,
handler: async (client, args) => {
const body: Record<string, unknown> = {
display_name: args.display_name,
};
if (args.slug) body.slug = args.slug;
if (args.webhook_url) body.webhook_url = args.webhook_url;
if (args.metadata) body.metadata = args.metadata;
// Agent-id-split refactor (2026-05-12) — pass-through to substrate.
// Default-omit when undefined so wire format matches pre-refactor
// senders. Same default-omit pattern as the other optional fields.
if (args.parent_agent_id) body.parent_agent_id = args.parent_agent_id;
return client.request("POST", "/v1/agents", body);
},
},
{
name: "cueapi_list_agents",
description:
Expand Down Expand Up @@ -790,6 +857,16 @@ export const tools: ToolDefinition[] = [
// on the common path.
if (args.mode && args.mode !== "auto") body.delivery_mode = args.mode;
if (args.send_at) body.send_at = args.send_at;
// Agent-id-split refactor (2026-05-12) — default-omit when value
// matches the substrate default (fallback_to_background). Same
// default-omit pattern as `mode auto`. Wire format unchanged for
// pre-refactor senders.
if (
args.live_fallback_mode &&
args.live_fallback_mode !== "fallback_to_background"
) {
body.live_fallback_mode = args.live_fallback_mode;
}
const extraHeaders: Record<string, string> = {
"X-Cueapi-From-Agent": args.from,
};
Expand Down
132 changes: 132 additions & 0 deletions tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,71 @@ describe("cueapi_send_message — HTTP contract (PR #619 BCC-light)", () => {
});
});

// Agent-id-split refactor (2026-05-12) — live_fallback_mode parameter
describe("live_fallback_mode parameter (agent-id-split refactor)", () => {
it("default (live_fallback_mode omitted) does not include the field in body", async () => {
// Server default = fallback_to_background; wire-format must match
// pre-refactor senders when caller doesn't pass the field. Same
// shape as the `mode` default-omit behavior.
const tool = findTool("cueapi_send_message");
const { client, calls } = stubClient();
await tool.handler(client, {
to: "agt_bob",
from: "agt_alice",
subject: "x",
body: "y",
});
expect(calls[0].body).not.toHaveProperty("live_fallback_mode");
});

it("explicit live_fallback_mode='fallback_to_background' is also omitted", async () => {
// Explicit-default === default; CLI omits on the wire. Symmetric
// with `mode: 'auto'` and the cueapi-cli default-omit pattern.
const tool = findTool("cueapi_send_message");
const { client, calls } = stubClient();
await tool.handler(client, {
to: "agt_bob",
from: "agt_alice",
subject: "x",
body: "y",
live_fallback_mode: "fallback_to_background",
});
expect(calls[0].body).not.toHaveProperty("live_fallback_mode");
});

it("live_fallback_mode='live_only' is passed through as body.live_fallback_mode", async () => {
// The opt-in case — substrate routes the message strictly to the
// Live sibling, no parent-agent fallback.
const tool = findTool("cueapi_send_message");
const { client, calls } = stubClient();
await tool.handler(client, {
to: "agt_bob",
from: "agt_alice",
subject: "x",
body: "y",
live_fallback_mode: "live_only",
});
expect(calls[0].body).toMatchObject({
live_fallback_mode: "live_only",
});
});

it("invalid live_fallback_mode value rejected client-side via Zod enum", async () => {
// Zod enum gates the value before it can hit the wire; same shape
// as the `mode` invalid-enum test above.
const tool = findTool("cueapi_send_message");
expect(() =>
tool.schema.parse({
to: "agt_bob",
from: "agt_alice",
subject: "x",
body: "y",
live_fallback_mode: "neither_one",
})
).toThrow();
});
});

// cueapi #623 — per-message send_at scheduling
describe("send_at parameter (cueapi #623 — scheduled send)", () => {
it("send_at is omitted by default (server treats absent === send-now)", async () => {
Expand Down Expand Up @@ -1673,6 +1738,73 @@ describe("agent directory tools — HTTP contract", () => {
expect(calls[0].query).toEqual({});
});

// Agent-id-split refactor (2026-05-12) — cueapi_create_agent tool.
// MCP parity with cueapi-cli `agents create --parent-agent-id` (#56).
it("cueapi_create_agent → POST /v1/agents with minimal display_name", async () => {
const tool = findTool("cueapi_create_agent");
const { client, calls } = stubClient();
await tool.handler(client, { display_name: "Solo Agent" });

expect(calls[0].method).toBe("POST");
expect(calls[0].path).toBe("/v1/agents");
expect(calls[0].body).toEqual({ display_name: "Solo Agent" });
});

it("cueapi_create_agent default-omits parent_agent_id when undefined", async () => {
// Standalone (parent) agents should look identical to pre-refactor
// create requests on the wire. Same default-omit pattern as the
// other optional fields.
const tool = findTool("cueapi_create_agent");
const { client, calls } = stubClient();
await tool.handler(client, {
display_name: "Solo",
slug: "solo",
});
expect(calls[0].body).not.toHaveProperty("parent_agent_id");
});

it("cueapi_create_agent passes parent_agent_id through to body", async () => {
// The opt-in case for the agent-id-split refactor: link this new
// Live-sibling agent to the BG sibling's id so substrate can fall
// back from Live to BG when the Live session is silent.
const tool = findTool("cueapi_create_agent");
const { client, calls } = stubClient();
await tool.handler(client, {
display_name: "LinkedIn Content Agent (Live)",
slug: "linkedin-content-agent-live",
parent_agent_id: "agt_parentbg0001",
});
expect(calls[0].body).toMatchObject({
display_name: "LinkedIn Content Agent (Live)",
slug: "linkedin-content-agent-live",
parent_agent_id: "agt_parentbg0001",
});
});

it("cueapi_create_agent passes webhook_url + metadata through", async () => {
const tool = findTool("cueapi_create_agent");
const { client, calls } = stubClient();
await tool.handler(client, {
display_name: "Hooked",
webhook_url: "https://example.test/hook",
metadata: { team: "platform" },
});
expect(calls[0].body).toMatchObject({
display_name: "Hooked",
webhook_url: "https://example.test/hook",
metadata: { team: "platform" },
});
});

it("cueapi_create_agent schema requires display_name", () => {
// Zod gating: missing display_name is a parse error before the
// request hits the wire.
const tool = findTool("cueapi_create_agent");
expect(() =>
tool.schema.parse({ slug: "no-name" })
).toThrow();
});

it("cueapi_list_agents online_only takes precedence over status", async () => {
// Server contract: --online-only (PR #40) is mutually exclusive with
// --status. When both are passed, online_only wins. Pin so the order
Expand Down