@absolutejs/agent-inbox
Advanced tools
+94
-2
@@ -13,2 +13,10 @@ // @bun | ||
| listSubscriptions: async ({ tenantId, source, kind }) => [...subscriptions.values()].filter((item) => item.enabled && item.target.tenantId === tenantId && item.source === source && item.kinds.includes(kind)).map((item) => structuredClone(item)), | ||
| listSubscriptionInventory: async ({ tenantId, limit }) => [...subscriptions.values()].filter((item) => !tenantId || item.target.tenantId === tenantId).sort((left, right) => right.createdAt.localeCompare(left.createdAt)).slice(0, Math.max(1, Math.min(limit, 200))).map((item) => structuredClone(item)), | ||
| setSubscriptionEnabled: async ({ id, tenantId, enabled }) => { | ||
| const item = subscriptions.get(id); | ||
| if (!item || item.target.tenantId !== tenantId) | ||
| return false; | ||
| subscriptions.set(id, { ...item, enabled }); | ||
| return true; | ||
| }, | ||
| enqueue: async (value) => { | ||
@@ -23,2 +31,10 @@ const key = `${value.subscriptionId}:${value.source}:${value.sourceEventId}`; | ||
| }, | ||
| listMessages: async ({ tenantId, status, limit }) => [...messages.values()].filter((item) => (!tenantId || item.target.tenantId === tenantId) && (!status || item.status === status)).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).slice(0, Math.max(1, Math.min(limit, 200))).map((item) => structuredClone(item)), | ||
| cancelMessage: async ({ id, tenantId, now }) => { | ||
| const item = messages.get(id); | ||
| if (!item || item.target.tenantId !== tenantId || item.status !== "pending") | ||
| return false; | ||
| messages.set(id, { ...item, status: "cancelled", updatedAt: now }); | ||
| return true; | ||
| }, | ||
| claim: async ({ workerId, now, leaseExpiresAt }) => { | ||
@@ -70,2 +86,10 @@ const row = [...messages.values()].filter((item) => (item.status === "pending" && Date.parse(item.notBefore) <= Date.parse(now) || item.status === "leased" && Date.parse(item.leaseExpiresAt ?? "") <= Date.parse(now)) && (!item.expiresAt || Date.parse(item.expiresAt) > Date.parse(now))).sort((a, b) => a.notBefore.localeCompare(b.notBefore))[0]; | ||
| }, | ||
| listSchedules: async ({ tenantId, limit }) => [...schedules.values()].filter((item) => !tenantId || item.target.tenantId === tenantId).sort((left, right) => right.createdAt.localeCompare(left.createdAt)).slice(0, Math.max(1, Math.min(limit, 200))).map((item) => structuredClone(item)), | ||
| setScheduleEnabled: async ({ id, tenantId, enabled }) => { | ||
| const item = schedules.get(id); | ||
| if (!item || item.target.tenantId !== tenantId) | ||
| return false; | ||
| schedules.set(id, { ...item, enabled }); | ||
| return true; | ||
| }, | ||
| claimDueSchedule: async ({ now }) => structuredClone([...schedules.values()].filter((item) => item.enabled && Date.parse(item.nextAt) <= Date.parse(now)).sort((a, b) => a.nextAt.localeCompare(b.nextAt))[0]), | ||
@@ -109,2 +133,8 @@ advanceSchedule: async ({ id, expectedNextAt, nextAt }) => { | ||
| listSubscriptions: async (v) => (await client.query(`SELECT document FROM ${ns}.subscriptions WHERE tenant_id=$1 AND source=$2 AND enabled AND $3=ANY(kinds)`, [v.tenantId, v.source, v.kind])).rows.map((v2) => row(v2)), | ||
| listSubscriptionInventory: async (v) => { | ||
| const limit = Math.max(1, Math.min(v.limit, 200)); | ||
| const result = v.tenantId ? await client.query(`SELECT document FROM ${ns}.subscriptions WHERE tenant_id=$1 ORDER BY document->>'createdAt' DESC LIMIT $2`, [v.tenantId, limit]) : await client.query(`SELECT document FROM ${ns}.subscriptions ORDER BY document->>'createdAt' DESC LIMIT $1`, [limit]); | ||
| return result.rows.map((value) => row(value)); | ||
| }, | ||
| setSubscriptionEnabled: async (v) => (await client.query(`UPDATE ${ns}.subscriptions SET enabled=$3,document=jsonb_set(document,'{enabled}',to_jsonb($3::boolean)) WHERE id=$1 AND tenant_id=$2 RETURNING id`, [v.id, v.tenantId, v.enabled])).rows.length === 1, | ||
| enqueue: async (v) => { | ||
@@ -123,2 +153,22 @@ const result = await client.query(`INSERT INTO ${ns}.messages (id,subscription_id,source,source_event_id,status,not_before,expires_at,document) VALUES ($1,$2,$3,$4,$5,$6::timestamptz,$7::timestamptz,$8::jsonb) ON CONFLICT (subscription_id,source,source_event_id) DO UPDATE SET source_event_id=EXCLUDED.source_event_id RETURNING document`, [ | ||
| }, | ||
| listMessages: async (v) => { | ||
| const params = []; | ||
| const filters = []; | ||
| if (v.tenantId) { | ||
| params.push(v.tenantId); | ||
| filters.push(`document->'target'->>'tenantId'=$${params.length}`); | ||
| } | ||
| if (v.status) { | ||
| params.push(v.status); | ||
| filters.push(`status=$${params.length}`); | ||
| } | ||
| params.push(Math.max(1, Math.min(v.limit, 200))); | ||
| const where = filters.length ? ` WHERE ${filters.join(" AND ")}` : ""; | ||
| return (await client.query(`SELECT document FROM ${ns}.messages${where} ORDER BY document->>'updatedAt' DESC LIMIT $${params.length}`, params)).rows.map((value) => row(value)); | ||
| }, | ||
| cancelMessage: async (v) => (await client.query(`UPDATE ${ns}.messages SET status='cancelled',document=document || $3::jsonb WHERE id=$1 AND document->'target'->>'tenantId'=$2 AND status='pending' RETURNING id`, [ | ||
| v.id, | ||
| v.tenantId, | ||
| JSON.stringify({ status: "cancelled", updatedAt: v.now }) | ||
| ])).rows.length === 1, | ||
| claim: (v) => client.transaction(async (tx) => { | ||
@@ -157,2 +207,8 @@ const found = row((await tx.query(`SELECT document FROM ${ns}.messages WHERE ((status='pending' AND not_before <= $1::timestamptz) OR (status='leased' AND lease_expires_at <= $1::timestamptz)) AND (expires_at IS NULL OR expires_at > $1::timestamptz) ORDER BY not_before FOR UPDATE SKIP LOCKED LIMIT 1`, [v.now])).rows[0]); | ||
| }, | ||
| listSchedules: async (v) => { | ||
| const limit = Math.max(1, Math.min(v.limit, 200)); | ||
| const result = v.tenantId ? await client.query(`SELECT document FROM ${ns}.schedules WHERE document->'target'->>'tenantId'=$1 ORDER BY document->>'createdAt' DESC LIMIT $2`, [v.tenantId, limit]) : await client.query(`SELECT document FROM ${ns}.schedules ORDER BY document->>'createdAt' DESC LIMIT $1`, [limit]); | ||
| return result.rows.map((value) => row(value)); | ||
| }, | ||
| setScheduleEnabled: async (v) => (await client.query(`UPDATE ${ns}.schedules SET enabled=$3,document=jsonb_set(document,'{enabled}',to_jsonb($3::boolean)) WHERE id=$1 AND document->'target'->>'tenantId'=$2 RETURNING id`, [v.id, v.tenantId, v.enabled])).rows.length === 1, | ||
| claimDueSchedule: async (v) => row((await client.query(`SELECT document FROM ${ns}.schedules WHERE enabled AND next_at <= $1::timestamptz ORDER BY next_at LIMIT 1`, [v.now])).rows[0]), | ||
@@ -167,2 +223,18 @@ advanceSchedule: async (v) => (await client.query(`UPDATE ${ns}.schedules SET next_at=$3::timestamptz,document=jsonb_set(document,'{nextAt}',to_jsonb($3::text)) WHERE id=$1 AND next_at=$2::timestamptz RETURNING id`, [v.id, v.expectedNextAt, v.nextAt])).rows.length === 1 | ||
| }; | ||
| var MAX_MESSAGE_TTL_MS = 30 * 24 * 60 * 60 * 1000; | ||
| var validateTarget = (target) => { | ||
| const { actions, wallTimeMs, ...nonnegative } = target.budget; | ||
| if (!Number.isSafeInteger(actions) || actions < 1) | ||
| throw new Error("Agent inbox action budget must be positive"); | ||
| if (!Number.isSafeInteger(wallTimeMs) || wallTimeMs < 1) | ||
| throw new Error("Agent inbox wall-time budget must be positive"); | ||
| if (Object.values(nonnegative).some((value) => !Number.isSafeInteger(value) || value < 0)) | ||
| throw new Error("Agent inbox budgets must be nonnegative integers"); | ||
| }; | ||
| var validateDelivery = (value) => { | ||
| if (!Number.isSafeInteger(value.maxAttempts) || value.maxAttempts < 1 || value.maxAttempts > 10) | ||
| throw new Error("Agent inbox maxAttempts must be between 1 and 10"); | ||
| if (!Number.isSafeInteger(value.messageTtlMs) || value.messageTtlMs < 1000 || value.messageTtlMs > MAX_MESSAGE_TTL_MS) | ||
| throw new Error("Agent inbox message TTL must be between one second and 30 days"); | ||
| }; | ||
@@ -183,4 +255,14 @@ class AgentInboxVerificationError extends Error { | ||
| }) => ({ | ||
| subscribe: (value) => store.saveSubscription(value), | ||
| subscribe: (value) => { | ||
| validateTarget(value.target); | ||
| validateDelivery(value); | ||
| if (value.kinds.length === 0) | ||
| throw new Error("Agent inbox subscription requires at least one kind"); | ||
| return store.saveSubscription(value); | ||
| }, | ||
| listSubscriptions: (tenantId, limit = 100) => store.listSubscriptionInventory({ tenantId, limit }), | ||
| setSubscriptionEnabled: (input) => store.setSubscriptionEnabled(input), | ||
| schedule: (value) => { | ||
| validateTarget(value.target); | ||
| validateDelivery(value); | ||
| if (!Number.isSafeInteger(value.intervalMs) || value.intervalMs < 1000) | ||
@@ -190,2 +272,9 @@ throw new Error("Schedule interval must be at least one second"); | ||
| }, | ||
| listSchedules: (tenantId, limit = 100) => store.listSchedules({ tenantId, limit }), | ||
| setScheduleEnabled: (input) => store.setScheduleEnabled(input), | ||
| listMessages: (tenantId, status, limit = 100) => store.listMessages({ tenantId, status, limit }), | ||
| cancelMessage: (input) => store.cancelMessage({ | ||
| ...input, | ||
| now: new Date(now()).toISOString() | ||
| }), | ||
| ingest: async ({ | ||
@@ -239,4 +328,5 @@ source, | ||
| attempts: 0, | ||
| maxAttempts: 5, | ||
| maxAttempts: subscription.maxAttempts, | ||
| notBefore: timestamp, | ||
| expiresAt: new Date(now() + subscription.messageTtlMs).toISOString(), | ||
| createdAt: timestamp, | ||
@@ -272,2 +362,3 @@ updatedAt: timestamp | ||
| maxAttempts: schedule.maxAttempts, | ||
| expiresAt: new Date(now() + schedule.messageTtlMs).toISOString(), | ||
| notBefore: occurrence, | ||
@@ -332,2 +423,3 @@ createdAt: timestamp, | ||
| agent: message.target.agent, | ||
| budget: message.target.budget, | ||
| goal: message.target.goal, | ||
@@ -334,0 +426,0 @@ input: { |
+18
-0
@@ -14,3 +14,20 @@ import type { AgentInboxCodec, AgentInboxMessage, AgentInboxStore, AgentInboxSubscription, AgentInboxVerifier, AgentSchedule } from "./types"; | ||
| subscribe: (value: AgentInboxSubscription) => Promise<void>; | ||
| listSubscriptions: (tenantId?: string, limit?: number) => Promise<AgentInboxSubscription[]>; | ||
| setSubscriptionEnabled: (input: { | ||
| id: string; | ||
| tenantId: string; | ||
| enabled: boolean; | ||
| }) => Promise<boolean>; | ||
| schedule: (value: AgentSchedule) => Promise<void>; | ||
| listSchedules: (tenantId?: string, limit?: number) => Promise<AgentSchedule[]>; | ||
| setScheduleEnabled: (input: { | ||
| id: string; | ||
| tenantId: string; | ||
| enabled: boolean; | ||
| }) => Promise<boolean>; | ||
| listMessages: (tenantId?: string, status?: AgentInboxMessage["status"], limit?: number) => Promise<AgentInboxMessage[]>; | ||
| cancelMessage: (input: { | ||
| id: string; | ||
| tenantId: string; | ||
| }) => Promise<boolean>; | ||
| ingest: ({ source, eventId, kind, body, headers, }: { | ||
@@ -66,2 +83,3 @@ source: string; | ||
| idempotencyKey: string; | ||
| budget: AgentInboxMessage["target"]["budget"]; | ||
| }): Promise<unknown>; | ||
@@ -68,0 +86,0 @@ }) => ({ message, payload, }: { |
+41
-1
@@ -6,2 +6,10 @@ export type AgentIdentityPin = { | ||
| }; | ||
| export type AgentInboxBudget = { | ||
| actions: number; | ||
| costMicros: number; | ||
| inputTokens: number; | ||
| outputTokens: number; | ||
| spendMinor: number; | ||
| wallTimeMs: number; | ||
| }; | ||
| export type AgentInboxTarget = { | ||
@@ -12,2 +20,3 @@ tenantId: string; | ||
| agent: AgentIdentityPin; | ||
| budget: AgentInboxBudget; | ||
| goal: string; | ||
@@ -20,2 +29,4 @@ }; | ||
| kinds: string[]; | ||
| maxAttempts: number; | ||
| messageTtlMs: number; | ||
| enabled: boolean; | ||
@@ -37,3 +48,3 @@ createdAt: string; | ||
| }; | ||
| status: "pending" | "leased" | "completed" | "dead_letter"; | ||
| status: "pending" | "leased" | "completed" | "dead_letter" | "cancelled"; | ||
| attempts: number; | ||
@@ -59,2 +70,3 @@ maxAttempts: number; | ||
| maxAttempts: number; | ||
| messageTtlMs: number; | ||
| createdAt: string; | ||
@@ -69,3 +81,22 @@ }; | ||
| }): Promise<AgentInboxSubscription[]>; | ||
| listSubscriptionInventory(input: { | ||
| tenantId?: string; | ||
| limit: number; | ||
| }): Promise<AgentInboxSubscription[]>; | ||
| setSubscriptionEnabled(input: { | ||
| id: string; | ||
| tenantId: string; | ||
| enabled: boolean; | ||
| }): Promise<boolean>; | ||
| enqueue(value: AgentInboxMessage): Promise<AgentInboxMessage>; | ||
| listMessages(input: { | ||
| tenantId?: string; | ||
| status?: AgentInboxMessage["status"]; | ||
| limit: number; | ||
| }): Promise<AgentInboxMessage[]>; | ||
| cancelMessage(input: { | ||
| id: string; | ||
| tenantId: string; | ||
| now: string; | ||
| }): Promise<boolean>; | ||
| claim(input: { | ||
@@ -90,2 +121,11 @@ workerId: string; | ||
| saveSchedule(value: AgentSchedule): Promise<void>; | ||
| listSchedules(input: { | ||
| tenantId?: string; | ||
| limit: number; | ||
| }): Promise<AgentSchedule[]>; | ||
| setScheduleEnabled(input: { | ||
| id: string; | ||
| tenantId: string; | ||
| enabled: boolean; | ||
| }): Promise<boolean>; | ||
| claimDueSchedule(input: { | ||
@@ -92,0 +132,0 @@ now: string; |
+1
-1
| { | ||
| "name": "@absolutejs/agent-inbox", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "description": "Durable verified webhooks, event subscriptions, schedules, leases, retries, and dead letters for AI agent triggers.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+5
-0
@@ -10,2 +10,7 @@ # @absolutejs/agent-inbox | ||
| Every subscription and schedule carries a complete runtime budget, bounded | ||
| delivery attempts, and a message TTL. Tenant-scoped inventories and explicit | ||
| subscription, schedule, and pending-message controls let owner and operator | ||
| interfaces manage retained triggers without querying the package schema. | ||
| Every message pins the target agent's signed discovery identity and preserves | ||
@@ -12,0 +17,0 @@ verification provenance. The runtime adapter starts a durable |
35218
30.96%825
23.13%21
31.25%