feat(agents): provision inbox triggers

This commit is contained in:
jarvis
2026-09-03 20:55:50 -04:00
parent 4ddf131acd
commit 7b56a67d42
7 changed files with 139 additions and 13 deletions
+20 -2
View File
@@ -1,6 +1,6 @@
import { createEnborClient, type EnborClient } from "@realmroot/enbor-sdk";
import { type CreateTriggerRequest, createEnborClient, EnborApiError, type EnborClient, type Trigger } from "@realmroot/enbor-sdk";
const REQUEST_TIMEOUT_MS = 10_000;
const REQUEST_TIMEOUT_MS = 30_000;
export function createAgencyClient(
baseUrl: string,
@@ -20,5 +20,23 @@ export function createAgencyClient(
client.raw.setConfig({
fetch: (input, init) => sdkFetch(new Request(input, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })),
});
const createTrigger = client.triggers.create;
client.triggers.create = ((body: CreateTriggerRequest, idempotencyKey?: string) =>
idempotencyKey ? createIdempotentTrigger(client, body, idempotencyKey) : createTrigger(body)) as EnborClient["triggers"]["create"];
return client;
}
async function createIdempotentTrigger(client: EnborClient, body: CreateTriggerRequest, idempotencyKey: string): Promise<Trigger> {
const result = (await client.raw.post({
url: "/api/v1/triggers",
body,
headers: { "Content-Type": "application/json", "idempotency-key": idempotencyKey },
})) as { data?: Trigger; error?: unknown; response?: Response };
if (result.response?.ok && result.error === undefined && result.data) return result.data;
const responseBody = result.error ?? result.data;
throw new EnborApiError(
result.response?.status,
typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody ?? {}),
responseBody,
);
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function registerAgentRoutes(api: Hono<{ Bindings: Env }>): void {
const model = optionalNullableString(body.model, "Agent.model", 200);
const skills = optionalStringArray(body.skills, "Agent.skills");
const idempotencyKey = externalCreationIdempotencyKey(c);
const { client } = await agencyDependencies(c, ["identities:write", "agents:write"]);
const { client } = await agencyDependencies(c, ["identities:write", "agents:write", "triggers:write"]);
const agent = await createAgencyAgent(client, {
name,
username,
+45 -2
View File
@@ -1,5 +1,9 @@
import { type Agent, EnborApiError, type EnborClient, type RuntimeName } from "@realmroot/enbor-sdk";
const AGENT_KANBAN_SKILL = "saltbo/agent-kanban@agent-kanban";
const TASK_INBOX_PROMPT =
"A task notification from Agent Kanban is ready. Use the Agent Kanban work skill to read the referenced Task, claim it, perform the requested work, record useful progress, and submit the result for review.";
export interface CreateAgencyAgentInput {
name: string;
description?: string | null;
@@ -20,15 +24,16 @@ export async function createAgencyAgent(client: EnborClient, input: CreateAgency
},
await derivedKey(input.idempotencyKey, "identity"),
);
let agent: Agent;
try {
return await client.agents.create(
agent = await client.agents.create(
{
metadata: { name: input.name, description: input.description },
spec: {
systemPrompt: input.systemPrompt,
provider: input.provider,
model: input.model,
skills: input.skills,
skills: withAgentKanbanSkill(input.skills),
identityRef: identity.metadata.uid,
},
},
@@ -47,6 +52,44 @@ export async function createAgencyAgent(client: EnborClient, input: CreateAgency
}
throw error;
}
const createTrigger = client.triggers.create as (
body: Parameters<EnborClient["triggers"]["create"]>[0],
idempotencyKey?: string,
) => ReturnType<EnborClient["triggers"]["create"]>;
const trigger = await createTrigger(
{
metadata: { name: triggerName(input.name) },
spec: {
source: { type: "inbox" },
template: {
metadata: {
labels: { "agent-kanban.dev/managed-by": "agent-kanban" },
annotations: { "agent-kanban.dev/agent-id": agent.metadata.uid },
},
spec: {
agentId: agent.metadata.uid,
environmentId: null,
runtime: input.runtime,
promptTemplate: TASK_INBOX_PROMPT,
},
},
},
},
await derivedKey(input.idempotencyKey, "trigger"),
);
if (trigger.status.subscription?.phase !== "active") {
throw new EnborApiError(502, "Enbor did not activate the Agent Inbox Trigger", trigger);
}
return agent;
}
function withAgentKanbanSkill(skills: string[] | undefined): string[] {
return skills?.includes(AGENT_KANBAN_SKILL) ? skills : [...(skills ?? []), AGENT_KANBAN_SKILL];
}
function triggerName(agentName: string): string {
const suffix = " task inbox";
return `${agentName.slice(0, 160 - suffix.length)}${suffix}`;
}
async function derivedKey(parent: string, stage: string): Promise<string> {
+2 -1
View File
@@ -28,7 +28,8 @@ Feature: Agent projections
Scenario: Create an Agent with its Realmroot identity
Given an authorized caller supplies complete Agent configuration
When the caller creates an Agent through AK
Then AK creates a same-tenant Realmroot Identity and bound Enbor Agent
Then AK creates a same-tenant Realmroot Identity and bound Enbor Agent with the Agent Kanban work skill
And AK creates an active Inbox Trigger that uses automatic Environment placement
And replays the compound operation without duplicate resources when its Idempotency-Key is retried
And stores no local Agent entity
@@ -9,7 +9,7 @@ afterEach(() => {
});
describe("Agency SDK client", () => {
it("applies a 10 second deadline signal to SDK requests", async () => {
it("applies a 30 second deadline signal to SDK requests", async () => {
const controller = new AbortController();
const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
const requests: Request[] = [];
@@ -25,7 +25,7 @@ describe("Agency SDK client", () => {
await client.projects.list({ limit: 100 });
expect(timeout).toHaveBeenCalledOnce();
expect(timeout).toHaveBeenCalledWith(10_000);
expect(timeout).toHaveBeenCalledWith(30_000);
expect(requests).toHaveLength(1);
expect(requests[0]!.signal.aborted).toBe(false);
controller.abort();
@@ -451,11 +451,13 @@ describe("Agent and Machine projection HTTP resources", () => {
const synchronizeAgentCreations = twoRequestBarrier();
let identityCreates = 0;
let agentCreates = 0;
let triggerCreates = 0;
const identityUpstreamKeys: string[] = [];
const agentUpstreamKeys: string[] = [];
const triggerUpstreamKeys: string[] = [];
vi.stubGlobal(
"fetch",
delegatedAgencyFetch(["identities:write", "agents:write"], async (request) => {
delegatedAgencyFetch(["identities:write", "agents:write", "triggers:write"], async (request) => {
const path = new URL(request.url).pathname;
if (path === "/api/v1/identities") {
identityCreates += 1;
@@ -472,13 +474,33 @@ describe("Agent and Machine projection HTTP resources", () => {
systemPrompt: "Handle concurrent work",
provider: null,
model: null,
skills: [],
skills: ["saltbo/agent-kanban@agent-kanban"],
allowedTools: [],
identity: { subject: "agent-concurrent-subject", username: "concurrent-agent", runtime: "codex" },
},
status: { phase: "active", schedulable: true },
});
}
if (path === "/api/v1/triggers") {
triggerCreates += 1;
triggerUpstreamKeys.push(request.headers.get("Idempotency-Key")!);
expect(await request.json()).toMatchObject({
spec: {
source: { type: "inbox" },
template: {
metadata: {
labels: { "agent-kanban.dev/managed-by": "agent-kanban" },
annotations: { "agent-kanban.dev/agent-id": "agent-concurrent" },
},
spec: { agentId: "agent-concurrent", environmentId: null, runtime: "codex" },
},
},
});
return Response.json({
metadata: metadata("trigger-concurrent", "Concurrent Agent task inbox"),
status: { subscription: { phase: "active" } },
});
}
throw new Error(`Unexpected Enbor request ${request.method} ${path}`);
}),
);
@@ -494,6 +516,7 @@ describe("Agent and Machine projection HTTP resources", () => {
expect(missingKey.status).toBe(400);
expect(identityCreates).toBe(0);
expect(agentCreates).toBe(0);
expect(triggerCreates).toBe(0);
const responses = await Promise.all([browserPost("/agents", body, key), browserPost("/agents", body, key)]);
expect(responses.map((response) => response.status)).toEqual([201, 201]);
@@ -509,6 +532,7 @@ describe("Agent and Machine projection HTTP resources", () => {
expect(snapshots[1]).toEqual(snapshots[0]);
expect(identityCreates).toBe(2);
expect(agentCreates).toBe(2);
expect(triggerCreates).toBe(2);
await expect(
fixture.db
.prepare("SELECT COUNT(*) AS count FROM resource_idempotency_records WHERE resource_kind = ? AND idempotency_key = ?")
@@ -520,14 +544,18 @@ describe("Agent and Machine projection HTTP resources", () => {
expect(conflict.status).toBe(422);
expect(identityCreates).toBe(2);
expect(agentCreates).toBe(2);
expect(triggerCreates).toBe(2);
const otherSession = await browserSessionFor("projection-human-other");
const otherCaller = await browserPost("/agents", { ...body, name: "Other caller Agent" }, key, otherSession);
expect(otherCaller.status, await otherCaller.clone().text()).toBe(201);
expect(identityUpstreamKeys[0]).toBe(identityUpstreamKeys[1]);
expect(agentUpstreamKeys[0]).toBe(agentUpstreamKeys[1]);
expect(triggerUpstreamKeys[0]).toBe(triggerUpstreamKeys[1]);
expect(new Set([identityUpstreamKeys[0], agentUpstreamKeys[0], triggerUpstreamKeys[0]]).size).toBe(3);
expect(identityUpstreamKeys[2]).not.toBe(identityUpstreamKeys[0]);
expect(agentUpstreamKeys[2]).not.toBe(agentUpstreamKeys[0]);
expect(triggerUpstreamKeys[2]).not.toBe(triggerUpstreamKeys[0]);
await expect(
fixture.db
.prepare("SELECT COUNT(*) AS count FROM resource_idempotency_records WHERE resource_kind = ? AND idempotency_key = ?")
@@ -4,16 +4,19 @@ import { createAgencyAgent } from "../../../server/usecases/agents/projectAgents
const identity = { metadata: { uid: "identity-1" } } as Identity;
const agent = { metadata: { uid: "agent-1" } } as Agent;
const trigger = { status: { subscription: { phase: "active" } } };
function harness() {
const createIdentity = vi.fn().mockResolvedValue(identity);
const deleteIdentity = vi.fn().mockResolvedValue(undefined);
const createAgent = vi.fn().mockResolvedValue(agent);
const createTrigger = vi.fn().mockResolvedValue(trigger);
const client = {
identities: { create: createIdentity, delete: deleteIdentity },
agents: { create: createAgent },
triggers: { create: createTrigger },
} as unknown as EnborClient;
return { client, createIdentity, deleteIdentity, createAgent };
return { client, createIdentity, deleteIdentity, createAgent, createTrigger };
}
const input = {
@@ -30,7 +33,7 @@ const input = {
describe("Agent SDK orchestration", () => {
it("[spec: agents/create-bound-agent] creates the SDK Identity before the bound SDK Agent", async () => {
const { client, createIdentity, deleteIdentity, createAgent } = harness();
const { client, createIdentity, deleteIdentity, createAgent, createTrigger } = harness();
await expect(createAgencyAgent(client, input)).resolves.toBe(agent);
expect(createIdentity).toHaveBeenCalledWith(
@@ -44,16 +47,49 @@ describe("Agent SDK orchestration", () => {
systemPrompt: "Build reliable APIs",
provider: "openai",
model: "gpt-5.6",
skills: ["agent-kanban"],
skills: ["agent-kanban", "saltbo/agent-kanban@agent-kanban"],
identityRef: "identity-1",
},
},
expect.stringMatching(/^ak-[a-f0-9]{64}$/),
);
expect(createTrigger).toHaveBeenCalledWith(
{
metadata: { name: "Backend task inbox" },
spec: {
source: { type: "inbox" },
template: {
metadata: {
labels: { "agent-kanban.dev/managed-by": "agent-kanban" },
annotations: { "agent-kanban.dev/agent-id": "agent-1" },
},
spec: {
agentId: "agent-1",
environmentId: null,
runtime: "codex",
promptTemplate: expect.stringContaining("Use the Agent Kanban work skill"),
},
},
},
},
expect.stringMatching(/^ak-[a-f0-9]{64}$/),
);
expect(createIdentity.mock.calls[0]![1]).not.toBe(createAgent.mock.calls[0]![1]);
expect(createTrigger.mock.calls[0]![1]).not.toBe(createIdentity.mock.calls[0]![1]);
expect(createTrigger.mock.calls[0]![1]).not.toBe(createAgent.mock.calls[0]![1]);
expect(deleteIdentity).not.toHaveBeenCalled();
});
it("[spec: agents/create-bound-agent] fails when Enbor does not activate the Inbox Trigger", async () => {
const { client, createTrigger } = harness();
createTrigger.mockResolvedValue({ status: { subscription: { phase: "error" } } });
await expect(createAgencyAgent(client, input)).rejects.toMatchObject({
status: 502,
responseText: "Enbor did not activate the Agent Inbox Trigger",
});
});
it("[spec: agents/create-bound-agent] deletes the created Identity when SDK Agent creation is permanently rejected", async () => {
const { client, createAgent, deleteIdentity } = harness();
const rejection = new EnborApiError(422, "invalid Agent", { type: "validation" });