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
18 changes: 17 additions & 1 deletion apps/web/src/agent-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,22 @@ export async function pushAgentSource(
);
}

/** Every created agent's source asset name, `agent-<slug>-source` — the one
* naming convention this pipeline owns, so callers can recognize a created
* agent's asset without a lookup. */
export function agentDeploySourceAssetName(slug: string): string {
return `agent-${slug}-source`;
}

const AGENT_DEPLOY_SOURCE_ASSET_NAME = /^agent-.+-source$/;

/** True for any asset this deploy pipeline named — used to keep created
* agents (and Myra, checked separately by callers) out of surfaces that
* list real workflows, since both are `workflow`-kind assets. */
export function isAgentDeploySourceAssetName(name: string): boolean {
return AGENT_DEPLOY_SOURCE_ASSET_NAME.test(name);
}

export type NewAgentInput = {
readonly name: string;
readonly systemPrompt: string;
Expand Down Expand Up @@ -199,7 +215,7 @@ export async function deployAgentSource(
if (!isValidSlug(slug)) {
throw new AgentDeployError("this name doesn't produce a usable agent address");
}
const assetName = `agent-${slug}-source`;
const assetName = agentDeploySourceAssetName(slug);
const packageName = `@workbench-agent/${slug}`;

const tenantResponse = await fetchImpl(`/api/tenants/${encodeURIComponent(args.tenantId)}`);
Expand Down
228 changes: 16 additions & 212 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,25 @@
// 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,
RunApprovalsResponse,
WorkflowDefinitionResponse,
WorkflowRunHealth,
WorkflowRunResponse,
paginatedSchema,
} from "@intx/types";
// The hub seams this app still needs for agents: definitions (for the
// sidebar's own-agent-DM listing in `shell/bench-activity.ts`), top-level
// runs (for `chat/threads-api.ts`'s live-address resolution), and the
// create-agent deploy mutation. The Agents page itself now reads through
// `chat/threads-api.ts`'s `listChatAgents` — the same chat-partner listing
// — so this file no longer carries a roster/detail data model of its own.

import { WorkflowDefinitionResponse, WorkflowRunResponse, paginatedSchema } from "@intx/types";
import { type } from "arktype";
import type { ArkErrors } from "arktype";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";

import type { APIQuery } from "@/lib/api-query";
import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query";
import { isChatPickerModelName } from "./settings/inference/model-capability";
import { ApiQueryError } from "@/lib/api-query";
import { deployAgentSource, type DeployedAgent, type NewAgentInput } from "./agent-deploy";
import { chatKeys } from "./chat-path";
import { tenantKeys } from "./query-client";

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
Expand Down Expand Up @@ -72,18 +60,11 @@ export function listAgentDefinitions(tenantId: string): Promise<readonly AgentDe
).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 genuine top-level deployment runs — every non-top-level
* run (workbench host, invited agent, task) excluded server-side by the
* native `GET /workflows/runs` listing's own predicate (`address IS NOT
* NULL AND anchorRunId = id`), the same one `isTopLevelRun` uses. Used
* wherever a page needs "real deployments only" — the Agent Directory.
* NULL AND anchorRunId = id`), the same one `isTopLevelRun` uses.
* Single-tenant (accepted loss): the deleted route expanded the
* requested tenant to its whole descendant subtree via
* `getDescendantTenants`; the native listing filters one tenant, so a
Expand All @@ -95,189 +76,11 @@ export function listTopLevelRuns(tenantId: string): Promise<readonly AgentInstan
);
}

/** The tenant's visible, enabled catalog models for the create-agent form's
* model picker. Uses `/catalog/models` (paginated `ModelResponse`), not the
* bare-array discovery route at `/models` (`ModelInfo[]`) — those are
* different wire shapes. Disabled rows are filtered out here because the
* catalog may retain them. Embedding-named models, Hugging Face Hub paths,
* and bare `.gguf` names are also omitted — this endpoint carries
* no offering capability lists, so the name-only
* {@link isChatPickerModelName} gate is the available signal. */
export function listCatalogModels(tenantId: string): Promise<readonly CatalogModel[]> {
return getJSON(`/api/tenants/${tenantId}/catalog/models?limit=${PAGE_LIMIT}`, ModelsPage).then(
(page) =>
page.data.filter((model) => !model.disabled && isChatPickerModelName(model.canonicalName)),
);
}

/** A single top-level run's own detail — `GET /workflows/runs/:runId`. For
* a deployment's anchor run (which is what `AgentInstance` already lists),
* this is the same record; fetched again here only right after a fresh
* deploy, before the roster's own listing has picked it up. */
export function getAgentRun(tenantId: string, runId: string): Promise<AgentInstance> {
return getJSON(
`/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}`,
WorkflowRunResponse,
);
}

export type AgentRunHealth = typeof WorkflowRunHealth.infer;

/** `GET /workflows/runs/:runId/health` — liveness/readiness for a live run. */
export function getAgentRunHealth(tenantId: string, runId: string): Promise<AgentRunHealth> {
return getJSON(
`/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/health`,
WorkflowRunHealth,
);
}

const RunEvent = type({ seq: "number", type: "string", body: "Record<string, unknown>" });
export type AgentRunEvent = typeof RunEvent.infer;
const RunEventsResponse = type({ runId: "string", events: RunEvent.array() });

/** `GET /workflows/runs/:runId/events` — the run's committed, seq-ordered
* event log. */
export function getAgentRunEvents(
tenantId: string,
runId: string,
): Promise<readonly AgentRunEvent[]> {
return getJSON(
`/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/events`,
RunEventsResponse,
).then((page) => page.events);
}

export type AgentRunApprovals = typeof RunApprovalsResponse.infer;

/** `GET /workflows/runs/:runId/approvals` — the run's approval decisions,
* newest first. */
export function getAgentRunApprovals(tenantId: string, runId: string): Promise<AgentRunApprovals> {
return getJSON(
`/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/approvals`,
RunApprovalsResponse,
);
}

export type AgentDirectoryData = {
readonly tenantId: string;
readonly definitions: readonly AgentDefinition[];
readonly instances: readonly AgentInstance[];
readonly models: readonly CatalogModel[];
/** Set when the model catalog failed independently; definitions and
* instances still load so the page stays usable. */
readonly modelsError?: string;
};

type ModelsOutcome =
| { readonly ok: true; readonly models: readonly CatalogModel[] }
| { readonly ok: false; readonly message: string };

/**
* Loads a bench's agent directory. Definitions and instances are required;
* the model catalog is best-effort so its failure alone never blanks the
* page — surfaced as `modelsError` rather than a silent empty catalog.
* `instances` comes from `listTopLevelRuns`, which already excludes every
* non-top-level run (workbench host, invited agent) server-side — the
* native `GET /workflows/runs` listing's own predicate — so this page
* never has to derive that exclusion itself from a tenant's workbenches.
*/
export async function loadAgentDirectory(tenantId: string): Promise<AgentDirectoryData> {
const [definitions, instances, modelsOutcome] = await Promise.all([
listAgentDefinitions(tenantId),
listTopLevelRuns(tenantId),
listCatalogModels(tenantId).then(
(models): ModelsOutcome => ({ ok: true, models }),
(cause: unknown): ModelsOutcome => ({
ok: false,
message: cause instanceof Error ? cause.message : String(cause),
}),
),
]);

return {
tenantId,
definitions,
instances,
models: modelsOutcome.ok ? modelsOutcome.models : [],
...(modelsOutcome.ok ? {} : { modelsError: modelsOutcome.message }),
};
}

/**
* Loads a bench's full agent directory. One query owns definitions +
* instances + models (models are best-effort inside `loadAgentDirectory`,
* surfacing `modelsError`) so the page keeps a single loading/error
* envelope. Pass no reloadKey — invalidate `tenantKeys.agentDirectory(tenantId)`
* after create.
*/
export function useAgentDirectory(tenantId: string | undefined): APIQuery<AgentDirectoryData> {
const result = useQuery({
queryKey:
tenantId === undefined
? (["tenant", "none", "agents", "directory"] as const)
: tenantKeys.agentDirectory(tenantId),
enabled: tenantId !== undefined,
queryFn: async () => {
if (tenantId === undefined) {
throw new Error("tenantId required when agent directory is enabled");
}
try {
return await loadAgentDirectory(tenantId);
} catch (cause) {
if (cause instanceof ApiQueryError && cause.status === 401) {
throw new UnauthenticatedError();
}
throw cause;
}
},
});
return toAPIQuery(result);
}

/** A selected agent run's liveness/readiness, polled only while a detail
* view has it open — `enabled` gates the fetch on a run actually existing. */
export function useAgentRunHealth(
tenantId: string | null,
runId: string | null,
): APIQuery<AgentRunHealth> {
const result = useQuery({
queryKey: ["agent-run-health", tenantId, runId] as const,
enabled: tenantId !== null && runId !== null,
queryFn: () => getAgentRunHealth(tenantId as string, runId as string),
});
return toAPIQuery(result);
}

/** A selected agent run's committed event log. */
export function useAgentRunEvents(
tenantId: string | null,
runId: string | null,
): APIQuery<readonly AgentRunEvent[]> {
const result = useQuery({
queryKey: ["agent-run-events", tenantId, runId] as const,
enabled: tenantId !== null && runId !== null,
queryFn: () => getAgentRunEvents(tenantId as string, runId as string),
});
return toAPIQuery(result);
}

/** A selected agent run's approval decisions, newest first. */
export function useAgentRunApprovals(
tenantId: string | null,
runId: string | null,
): APIQuery<AgentRunApprovals> {
const result = useQuery({
queryKey: ["agent-run-approvals", tenantId, runId] as const,
enabled: tenantId !== null && runId !== null,
queryFn: () => getAgentRunApprovals(tenantId as string, runId as string),
});
return toAPIQuery(result);
}

/**
* Deploys a hand-authored agent through the stock workflow-deploy path
* (`agent-deploy.ts`), then invalidates the bench's agent directory so the
* roster picks up the new deployment without a manual refetch.
* (`agent-deploy.ts`), then invalidates the bench's agent directory and
* chat-agent roster so both pick up the new deployment without a manual
* refetch.
*/
export function useDeployAgentMutation(tenantId: string) {
const queryClient = useQueryClient();
Expand All @@ -286,6 +89,7 @@ export function useDeployAgentMutation(tenantId: string) {
deployAgentSource({ tenantId, input }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: tenantKeys.agentDirectory(tenantId) });
void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) });
},
});
}
79 changes: 0 additions & 79 deletions apps/web/src/agents-directory.ts

This file was deleted.

Loading
Loading