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
5 changes: 5 additions & 0 deletions .changeset/publish-content-intake-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Publish delegated Content database intake capabilities through A2A discovery.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

import { describe, expect, it } from "vitest";

import { dispatchIntegrationRoutingHint } from "../../../../dispatch/src/server/lib/dispatch-routing.js";
import { generateActionRegistryForProject } from "../../vite/action-types-plugin.js";
import { loadActionsFromStaticRegistry } from "../action-discovery.js";
import {
buildAuthenticatedAgentA2ASkills,
filterDirectA2AActions,
} from "./action-filters-a2a.js";

const contentProjectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../templates/content",
);

const ACTION_REGISTRY_TEST_TIMEOUT_MS = 60_000;

async function loadContentActions() {
generateActionRegistryForProject(contentProjectRoot);

const registryUrl =
pathToFileURL(
path.join(contentProjectRoot, ".generated/actions-registry.ts"),
).href + `?cacheBust=${Date.now()}`;
const { default: modules } = await import(registryUrl);
return loadActionsFromStaticRegistry(modules);
}

describe("Content authenticated A2A capabilities", () => {
it(
"publishes bounded reads and message-only intake mutations for generic Dispatch delegation",
async () => {
const actions = await loadContentActions();
const externalAgentOptions = {
connectorCatalog: [
"list-content-databases",
"describe-content-database",
],
};
const skills = buildAuthenticatedAgentA2ASkills(
actions,
externalAgentOptions,
);
const skillsById = new Map(skills.map((skill) => [skill.id, skill]));

for (const actionName of [
"list-content-databases",
"describe-content-database",
]) {
expect(skillsById.get(actionName)).toMatchObject({ readOnly: true });
expect(skillsById.get(actionName)?.inputSchema).toBeDefined();
}
expect(skillsById.get("list-content-databases")?.description).toContain(
"user-authored description",
);

for (const actionName of [
"submit-content-database-form",
"add-database-item",
"update-document",
"set-document-property",
]) {
expect(skillsById.get(actionName)).toMatchObject({ readOnly: false });
expect(skillsById.get(actionName)?.inputSchema).toBeUndefined();
}

const directlyInvocable = filterDirectA2AActions(
actions,
externalAgentOptions,
);
expect(directlyInvocable).toHaveProperty("list-content-databases");
expect(directlyInvocable).toHaveProperty("describe-content-database");
expect(directlyInvocable).not.toHaveProperty("get-content-database");
expect(directlyInvocable).not.toHaveProperty(
"submit-content-database-form",
);
expect(directlyInvocable).not.toHaveProperty("update-document");

const intakeHint = dispatchIntegrationRoutingHint(
"Add this design request to the editorial intake database",
);
expect(intakeHint?.targetAgent).toBeUndefined();
expect(intakeHint?.instruction).toContain("discovered app capabilities");

expect(
dispatchIntegrationRoutingHint(
"Design a visual mockup for the editorial intake screen",
),
).toMatchObject({ targetAgent: "design" });
},
ACTION_REGISTRY_TEST_TIMEOUT_MS,
);
});
9 changes: 9 additions & 0 deletions templates/content/actions/add-database-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ const schema = databaseMutationEnvelopeSchema.extend({
export default defineAction({
description:
"Create one row in an exact ordinary Content database using its discovered schema revision. Strictly validates every non-Blocks property, applies the side effect once per idempotency key, and returns a verified receipt with stable row identity.",
publicAgent: {
expose: true,
readOnly: false,
requiresAuth: true,
isConsequential: true,
title: "Add Content Database Item",
description:
"Delegate creation of one page item in an existing Content database.",
},
schema,
audit: {
recordInputs: false,
Expand Down
92 changes: 92 additions & 0 deletions templates/content/actions/describe-content-database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { defineAction } from "@agent-native/core";
import { accessFilter } from "@agent-native/core/sharing";
import { and, eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { getDocumentContextPath } from "../server/lib/document-context.js";
import type {
ContentDatabaseDescriptionResponse,
ContentDatabaseUnavailableResponse,
} from "../shared/api.js";
import { resolveContentDatabaseRead } from "./_database-utils.js";
import {
listPropertiesForDatabase,
serializeDatabase,
} from "./_property-utils.js";
import listContentDatabases, {
ContentDatabaseResolutionError,
} from "./list-content-databases.js";

export default defineAction({
description:
"Describe one exact ordinary Content database, including its live metadata, views, and property schema but not its rows. Resolve the stable database or document ID with list-content-databases first.",
schema: z
.object({
databaseId: z.string().min(1).optional().describe("Exact database ID"),
documentId: z
.string()
.min(1)
.optional()
.describe("Exact database document/page ID"),
})
.refine(
(input) => Boolean(input.databaseId) !== Boolean(input.documentId),
"Provide exactly one of databaseId or documentId.",
),
http: { method: "GET" },
readOnly: true,
publicAgent: { expose: true, readOnly: true, requiresAuth: true },
run: async ({
databaseId,
documentId,
}): Promise<
ContentDatabaseDescriptionResponse | ContentDatabaseUnavailableResponse
> => {
let selection: Awaited<ReturnType<typeof listContentDatabases.run>>;
try {
selection = await listContentDatabases.run({ databaseId, documentId });
} catch (error) {
if (!(error instanceof ContentDatabaseResolutionError)) throw error;
throw new Error("Content database not found.");
}
const selected = selection.databases[0];
if (!selected) throw new Error("Content database not found.");

const resolved = await resolveContentDatabaseRead({
databaseId: selected.databaseId,
});
if (!resolved.available) return resolved;
if (resolved.database.systemRole) {
throw new Error("Content database not found.");
}

const db = getDb();
const [databaseDocument] = await db
.select({
id: schema.documents.id,
parentId: schema.documents.parentId,
})
.from(schema.documents)
.where(
and(
eq(schema.documents.id, selected.documentId),
accessFilter(schema.documents, schema.documentShares),
),
);
if (!databaseDocument) throw new Error("Content database not found.");

const [properties, contextPath] = await Promise.all([
listPropertiesForDatabase(resolved.database.id),
getDocumentContextPath(databaseDocument),
]);
return {
database: serializeDatabase(
{ ...resolved.database, title: selected.title },
selected.description,
),
contextPath,
properties,
};
},
});
Loading
Loading