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
1 change: 1 addition & 0 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test": "bun test"
},
"dependencies": {
"@corbits/agent-directory": "workspace:*",
"@corbits/chat": "workspace:*",
"@corbits/commands": "workspace:*",
"@corbits/folded-runs": "workspace:*",
Expand Down
18 changes: 18 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
startWorkflowCommand,
} from "@corbits/chat";
import { createCryptoProviderCache } from "@corbits/folded-runs";
import { createAgentDefinitionRoutes } from "@corbits/agent-directory";
import {
createDrizzleWebhookTriggerStore,
createWebhookIngressRoutes,
Expand Down Expand Up @@ -314,6 +315,23 @@ export async function createHub(config: HubConfig) {
commands: commandRegistry,
};
app.route(`${TENANT_PREFIX}/chat`, createChatRoutes(chatDeps));
// Agent definitions a person authors by hand from the Agents page's
// create form, materialized the same way the platform's own starter
// agents are (see `@corbits/agent-directory`'s doc comment). Shares
// `chatGrantStore`/`chatConditionRegistry` with every other extension
// mounted here — there is nothing chat-specific about that pair, it
// is just this composition root's one db-backed grant store.
app.route(
`${TENANT_PREFIX}/agent-definitions`,
createAgentDefinitionRoutes({
db,
assetService,
requireGrant: createRequireGrant({
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
}),
}),
);
app.route(
`${TENANT_PREFIX}/chat`,
createCommandRoutes({
Expand Down
214 changes: 214 additions & 0 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// The Agents page's one seam to the hub: agent definitions (templates
// an agent can be launched from), their deployed instances, and the
// tenant's model catalog — each fetched with the platform's own wire
// schemas, validated at the boundary exactly like every other query in
// `./api.ts`. Kept separate from that file because these three
// endpoints are tenant-scoped (the path needs a resolved `tenantId`
// before it can even be built), unlike the fixed `/api/me/...` paths
// `useAPIQuery` there is built around.

import {
ModelResponse,
WorkflowDefinitionResponse,
WorkflowRunResponse,
paginatedSchema,
} from "@intx/types";
import { type } from "arktype";
import type { ArkErrors } from "arktype";
import { useEffect, useState } from "react";

import type { APIQuery } from "./api";

export type AgentDefinition = typeof WorkflowDefinitionResponse.infer;
export type AgentInstance = typeof WorkflowRunResponse.infer;
export type CatalogModel = typeof ModelResponse.infer;

const DefinitionsPage = paginatedSchema(WorkflowDefinitionResponse);
const InstancesPage = paginatedSchema(WorkflowRunResponse);
const ModelsPage = paginatedSchema(ModelResponse);

// The REST pagination ceiling (see `vendor/intx/hub-api/src/pagination.ts`).
// A bench with more agents or instances than this needs real pagination on
// this page, not raised here — tracked as a known limit, not silently
// worked around.
const PAGE_LIMIT = 100;

export class AgentDirectoryError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
}
}

type Validator<T> = (data: unknown) => T | ArkErrors;

async function getJSON<T>(path: string, schema: Validator<T>): Promise<T> {
let response: Response;
try {
response = await fetch(path, { headers: { accept: "application/json" } });
} catch (cause) {
throw new AgentDirectoryError(
cause instanceof Error ? cause.message : String(cause),
);
}
if (response.status === 401) {
throw new AgentDirectoryError("Not signed in.", 401);
}
if (!response.ok) {
throw new AgentDirectoryError(
`The hub answered ${response.status} for ${path}.`,
response.status,
);
}
const parsed = schema(await response.json().catch(() => undefined));
if (parsed instanceof type.errors) {
throw new AgentDirectoryError(
`Unexpected response shape from ${path}: ${parsed.summary}`,
);
}
return parsed;
}

const ErrorEnvelope = type({ error: { message: "string" } });

async function postJSON<T>(
path: string,
schema: Validator<T>,
body: unknown,
): Promise<T> {
let response: Response;
try {
response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
} catch (cause) {
throw new AgentDirectoryError(
cause instanceof Error ? cause.message : String(cause),
);
}
const json: unknown = await response.json().catch(() => undefined);
if (!response.ok) {
const envelope = ErrorEnvelope(json);
const message =
envelope instanceof type.errors
? `The hub answered ${response.status} for ${path}.`
: envelope.error.message;
throw new AgentDirectoryError(message, response.status);
}
const parsed = schema(json);
if (parsed instanceof type.errors) {
throw new AgentDirectoryError(
`Unexpected response shape from ${path}: ${parsed.summary}`,
);
}
return parsed;
}

export function listAgentDefinitions(
tenantId: string,
): Promise<readonly AgentDefinition[]> {
return getJSON(
`/api/tenants/${tenantId}/workflows/definitions?limit=${PAGE_LIMIT}`,
DefinitionsPage,
).then((page) => page.data);
}

export function listAgentInstances(
tenantId: string,
): Promise<readonly AgentInstance[]> {
return getJSON(
`/api/tenants/${tenantId}/workflows/runs?limit=${PAGE_LIMIT}`,
InstancesPage,
).then((page) => page.data);
}

/** The tenant's visible, enabled catalog models, for the create-agent
* form's model picker. Never invented client-side — only what the
* catalog actually resolves against at launch time. */
export function listCatalogModels(
tenantId: string,
): Promise<readonly CatalogModel[]> {
return getJSON(
`/api/tenants/${tenantId}/models?limit=${PAGE_LIMIT}`,
ModelsPage,
).then((page) => page.data.filter((model) => !model.disabled));
}

export type CreateAgentDefinitionInput = {
readonly name: string;
readonly handle: string;
readonly description?: string;
readonly systemPrompt: string;
readonly model?: string;
};

export function createAgentDefinition(
tenantId: string,
input: CreateAgentDefinitionInput,
): Promise<AgentDefinition> {
return postJSON(
`/api/tenants/${tenantId}/agent-definitions`,
WorkflowDefinitionResponse,
input,
);
}

export type AgentDirectoryData = {
readonly tenantId: string;
readonly definitions: readonly AgentDefinition[];
readonly instances: readonly AgentInstance[];
readonly models: readonly CatalogModel[];
};

/**
* Loads a bench's full agent directory in one shot, re-fetching whenever
* `tenantId` changes or `reloadKey` is bumped — the same "no push, refetch
* on demand" convention `useAPIQuery` uses, so a freshly created
* definition shows up the moment the create dialog closes.
*/
export function useAgentDirectory(
tenantId: string | undefined,
reloadKey: number,
): APIQuery<AgentDirectoryData> {
const [state, setState] = useState<APIQuery<AgentDirectoryData>>({
kind: "loading",
});

useEffect(() => {
if (tenantId === undefined) return;
let cancelled = false;
setState({ kind: "loading" });
Promise.all([
listAgentDefinitions(tenantId),
listAgentInstances(tenantId),
listCatalogModels(tenantId),
])
.then(([definitions, instances, models]) => {
if (cancelled) return;
setState({
kind: "ready",
data: { tenantId, definitions, instances, models },
});
})
.catch((cause: unknown) => {
if (cancelled) return;
if (cause instanceof AgentDirectoryError && cause.status === 401) {
setState({ kind: "unauthenticated" });
return;
}
setState({
kind: "error",
message: cause instanceof Error ? cause.message : String(cause),
});
});
return () => {
cancelled = true;
};
}, [tenantId, reloadKey]);

return state;
}
72 changes: 72 additions & 0 deletions apps/web/src/pages/agents-directory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Pure logic behind the Agents page: filtering out the chat anchor
// machinery's channel hosts (they are plumbing, not an agent a person
// created), full-text search across the fields a person actually reads
// (never an id), and flagging an instance whose definition has since
// gone missing from the tenant's own listing.

import { isChannelHostDefinitionName } from "@corbits/chat/channel-host-naming";

import type { AgentDefinition, AgentInstance } from "../agents-api";

/** Every definition and instance a bench holds, minus the chat anchor
* machinery's channel hosts — those are internal plumbing, never a
* user-facing agent. */
export function purposeAgentDefinitions(
definitions: readonly AgentDefinition[],
): readonly AgentDefinition[] {
return definitions.filter((d) => !isChannelHostDefinitionName(d.name));
}

export function purposeAgentInstances(
instances: readonly AgentInstance[],
): readonly AgentInstance[] {
return instances.filter(
(instance) => !isChannelHostDefinitionName(instance.definitionName),
);
}

export function filterDefinitions(
definitions: readonly AgentDefinition[],
query: string,
): readonly AgentDefinition[] {
const needle = query.trim().toLowerCase();
if (needle === "") return definitions;
return definitions.filter(
(d) =>
d.name.toLowerCase().includes(needle) ||
(d.description ?? "").toLowerCase().includes(needle),
);
}

export function filterInstances<T extends AgentInstance>(
instances: readonly T[],
query: string,
): readonly T[] {
const needle = query.trim().toLowerCase();
if (needle === "") return instances;
return instances.filter((i) =>
i.definitionName.toLowerCase().includes(needle),
);
}

/**
* An instance is orphaned when the tenant's own definitions listing no
* longer carries its `definitionId` — the definition was deleted or,
* more commonly, has scrolled past the page's fetch window. A
* definition row's own FK to the run means this can never mean "no
* definition ever existed"; it means "not resolvable from here", which
* is exactly the distinction the UI floor cares about: never hide an
* instance the page cannot fully explain, mark it instead.
*/
export function isOrphanedInstance(
instance: AgentInstance,
definitionsById: ReadonlyMap<string, AgentDefinition>,
): boolean {
return !definitionsById.has(instance.definitionId);
}

export function definitionsById(
definitions: readonly AgentDefinition[],
): ReadonlyMap<string, AgentDefinition> {
return new Map(definitions.map((d) => [d.id, d]));
}
Loading
Loading