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
16 changes: 16 additions & 0 deletions apps/web/src/agent-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";

import { agentDeploySourceAssetName, agentSlugFromSourceAssetName } from "./agent-deploy";

describe("agentSlugFromSourceAssetName", () => {
test("recovers the slug agentDeploySourceAssetName wrapped", () => {
expect(agentSlugFromSourceAssetName(agentDeploySourceAssetName("echo-bot"))).toBe("echo-bot");
});

test("is null for a name this pipeline didn't produce", () => {
expect(agentSlugFromSourceAssetName("echo-bot")).toBeNull();
expect(agentSlugFromSourceAssetName("agent-agent-echo-bot-source-source")).toBe(
"agent-echo-bot-source",
);
});
});
18 changes: 16 additions & 2 deletions apps/web/src/agent-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function agentDeploySourceAssetName(slug: string): string {
return `agent-${slug}-source`;
}

const AGENT_DEPLOY_SOURCE_ASSET_NAME = /^agent-.+-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
Expand All @@ -192,10 +192,24 @@ export function isAgentDeploySourceAssetName(name: string): boolean {
return AGENT_DEPLOY_SOURCE_ASSET_NAME.test(name);
}

/** The inverse of `agentDeploySourceAssetName`: recovers the slug this
* pipeline deployed an asset under, so a caller re-deploying an existing
* agent can reuse its slug instead of re-deriving one from its display
* name. Null when the name isn't this pipeline's `agent-<slug>-source`
* shape. */
export function agentSlugFromSourceAssetName(assetName: string): string | null {
const match = AGENT_DEPLOY_SOURCE_ASSET_NAME.exec(assetName);
return match?.[1] ?? null;
}

export type NewAgentInput = {
readonly name: string;
readonly systemPrompt: string;
readonly schedule?: string;
/** The agent's address slug, when a caller already knows it (e.g.
* redeploying or re-joining an existing agent) — used verbatim instead
* of being re-derived from `name`, so the asset name stays stable. */
readonly slug?: string;
};

export type DeployedAgent = typeof WorkflowDeploymentResponse.infer;
Expand All @@ -216,7 +230,7 @@ export async function deployAgentSource(
const systemPrompt = args.input.systemPrompt.trim();
if (systemPrompt === "") throw new AgentDeployError("an agent needs a system prompt");

const slug = slugify(name);
const slug = args.input.slug ?? slugify(name);
if (!isValidSlug(slug)) {
throw new AgentDeployError("this name doesn't produce a usable agent address");
}
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/chat/threads-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";

import { agentDeploySourceAssetName } from "../agent-deploy";
import { MYRA_SOURCE_CONFIG } from "../myra-source";
import { displayAgentName } from "./threads-api";

describe("displayAgentName", () => {
test("renders Myra's fixed display name for her asset", () => {
expect(displayAgentName(MYRA_SOURCE_CONFIG.assetName)).toBe(MYRA_SOURCE_CONFIG.displayName);
});

test("title-cases a deployed agent's slug with hyphens as spaces", () => {
expect(displayAgentName(agentDeploySourceAssetName("echo-bot"))).toBe("Echo Bot");
expect(displayAgentName(agentDeploySourceAssetName("scribe"))).toBe("Scribe");
});

test("falls back to the raw name for anything unrecognized", () => {
expect(displayAgentName("some-other-asset")).toBe("some-other-asset");
});
});
18 changes: 13 additions & 5 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { type } from "arktype";
import { WorkflowDeploymentResponse } from "@intx/types";
import { reportError } from "@corbits/error-sink";

import { agentSlugFromSourceAssetName } from "../agent-deploy";
import { listTopLevelRuns } from "../agents-api";
import { MYRA_SOURCE_CONFIG } from "../myra-source";

Expand Down Expand Up @@ -90,11 +91,18 @@ export type ChatSummary = {
};

/** Myra is the one default agent; her deploy asset name is not a display
* name anyone should have to read. */
function displayAgentName(definitionName: string): string {
return definitionName === MYRA_SOURCE_CONFIG.assetName
? MYRA_SOURCE_CONFIG.displayName
: definitionName;
* name anyone should have to read. Any other agent's deploy asset name
* encodes its slug (`agent-<slug>-source`), which renders title-cased with
* hyphens as spaces ("echo-bot" -> "Echo Bot"). */
export function displayAgentName(definitionName: string): string {
if (definitionName === MYRA_SOURCE_CONFIG.assetName) return MYRA_SOURCE_CONFIG.displayName;
const slug = agentSlugFromSourceAssetName(definitionName);
if (slug === null) return definitionName;
return slug
.split("-")
.filter(Boolean)
.map((word) => word[0]?.toUpperCase() + word.slice(1))
.join(" ");
}

/** Myra is always in a new workbench and never a pickable option. */
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/workbench-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// resolved offering, which does inherit.

import { isMyraAgent, listRoomParticipants, sendToRoom } from "@/chat/threads-api";
import { deployAgentSource } from "./agent-deploy";
import { agentSlugFromSourceAssetName, deployAgentSource } from "./agent-deploy";
import { readAgentSource } from "./agent-source-read";
import { deployMyraSource } from "./myra-deploy";
import { createFetchStockHub } from "./needs-converge";
Expand Down Expand Up @@ -96,10 +96,14 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
// source is read back out of the bench and re-pushed into the child,
// since a child's deploy rejects the parent's inherited asset outright.
for (const picked of input.pickedAgents ?? []) {
const slug = agentSlugFromSourceAssetName(picked.assetName);
if (slug === null) {
throw new Error(`${picked.assetName} isn't a recognized agent source asset`);
}
const source = await readAgentSource(input.benchTenantId, picked.id, picked.assetName);
await deployAgentSource({
tenantId,
input: { name: picked.name, systemPrompt: source.systemPrompt },
input: { name: picked.name, systemPrompt: source.systemPrompt, slug },
});
}
} catch (cause) {
Expand Down Expand Up @@ -156,9 +160,16 @@ export async function redeployRoomAgent(
await hub.deployWorkflow(roomTenantId, deployInput);
return;
}
const slug = agentSlugFromSourceAssetName(agent.assetName);
if (slug === null) {
throw new WorkbenchCreateError(
`${agent.assetName} isn't a recognized agent source asset`,
"deploy",
);
}
const source = await readAgentSource(roomTenantId, agent.id, agent.assetName);
await deployAgentSource({
tenantId: roomTenantId,
input: { name: agent.name, systemPrompt: source.systemPrompt },
input: { name: agent.name, systemPrompt: source.systemPrompt, slug },
});
}
Loading