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
11 changes: 11 additions & 0 deletions apps/hub/src/draft-route-envelope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// The hub composition root must serve the package draft routes (CL-6749)
// rather than a local `{ code, message }` 422 the client cannot parse.

import { readFileSync } from "node:fs";
import { expect, test } from "bun:test";

test("hub mounts package draft routes rather than a local {code, message} 422", () => {
const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
expect(source).toContain("createAgentDefinitionDraftRoutes");
expect(source).not.toMatch(/code:\s*"drafting_failed"/);
});
132 changes: 27 additions & 105 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,16 @@ import type { HarnessConfig } from "@intx/types/runtime";
// — the gate itself only stamps this hash on the `ok:true` arm.

import {
createAgentDefinitionDraftRoutes,
createAgentDefinitionRoutes,
createDefinitionAssetHistory,
createDrizzleDefinitionSkillsStore,
createWorkflowAgentCreateRoutes,
createWorkflowCapabilityRoutes,
createWorkflowSkillPinRoutes,
type CapabilityInventoryProvider,
AgentDefinitionDraftReferenceOutOfInventoryError,
AgentDefinitionDraftReplyUnparseableError,
createMyraAgentDefinitionDrafting,
isPlannerCreatedDefinitionName,
MyraAgentDefinitionDraftingUnavailableError,
resolveMyraDefinitionIdFromDb,
type InventoryAgent,
type InventoryModel,
Expand Down Expand Up @@ -224,11 +222,7 @@ import {
createDrizzleWorkflowDeploySourceStore,
withDeploySourceRecording,
} from "@corbits/workflows";
import {
FoldedRunFailedError,
FoldedRunTimedOutError,
runOneShotFoldedPrompt,
} from "@corbits/folded-run-one-shot";
import { runOneShotFoldedPrompt } from "@corbits/folded-run-one-shot";

import {
createEventCollectorRegistry,
Expand Down Expand Up @@ -3229,106 +3223,34 @@ export async function createHub(config: HubConfig) {
// from a name + plain-language purpose, offering her the same
// inventory `capabilityInventory` above reads through. Never deploys
// on its own — the panel submits the validated draft through the
// ordinary create-agent-definition path once the person confirms. This
// route is hand-wired here rather than through a factory the package
// exports, since agent-directory's own route factories are each scoped
// to a narrower concern.
const CreateAgentDefinitionDraftBody = type({
name: "string > 0",
"purpose?": "string > 0",
});
const agentDefinitionDraftingInFlightPrincipals = new Set<string>();
function isAgentDefinitionDraftingFailure(err: unknown): boolean {
return (
err instanceof MyraAgentDefinitionDraftingUnavailableError ||
err instanceof FoldedRunTimedOutError ||
err instanceof FoldedRunFailedError ||
err instanceof AgentDefinitionDraftReplyUnparseableError ||
err instanceof AgentDefinitionDraftReferenceOutOfInventoryError
);
}
const plannerRoutes = new Hono<TenantEnv>();
plannerRoutes.post(
"/agent-definitions/draft",
createRequireGrant({
// ordinary create-agent-definition path once the person confirms.
// Failure copy is the package route's `makeErrorEnvelope` +
// `reportError` (CL-6749), not a local `{ code, message }` body.
const plannerRoutes = createAgentDefinitionDraftRoutes({
requireGrant: createRequireGrant({
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
})("workflow-definition:*", "create"),
async (c) => {
const body = CreateAgentDefinitionDraftBody(
await c.req.json().catch(() => undefined),
);
if (body instanceof type.errors) {
return c.json(
{
error: {
code: "bad_request",
message: `This couldn't be read: ${body.summary}`,
},
},
400,
);
}

const tenant = c.get("tenant");
const principal = c.get("principal");

if (agentDefinitionDraftingInFlightPrincipals.has(principal.id)) {
return c.json(
{
error: {
code: "dispatch_in_progress",
message: "Myra is already drafting your last agent.",
},
},
409,
);
}
agentDefinitionDraftingInFlightPrincipals.add(principal.id);
try {
const draft = await createMyraAgentDefinitionDrafting({
resolveMyraDefinitionId: (tenantId) =>
resolveMyraDefinitionIdFromDb(db, tenantId),
runner: {
run: (runnerInput) =>
runOneShotFoldedPrompt(
{
foldedRuns: oneShotFoldedRunsDeps,
events: sidecarRouter.events,
cryptoProviders: agentDefinitionDraftingCryptoProviders,
undeploy: (address, reason) =>
sidecarRouter.sendAgentUndeploy(address, reason),
},
runnerInput,
),
},
inventorySources: plannerInventorySources,
}).propose({
tenantId: tenant.id,
principalId: principal.id,
name: body.name,
...(body.purpose !== undefined ? { purpose: body.purpose } : {}),
});
return c.json({ draft }, 201);
} catch (err) {
if (isAgentDefinitionDraftingFailure(err)) {
return c.json(
{
error: {
code: "drafting_failed",
message:
"Myra couldn't draft a starting prompt for that. Write one yourself, or try again.",
}),
draftAgentDefinition: (input) =>
createMyraAgentDefinitionDrafting({
resolveMyraDefinitionId: (tenantId) =>
resolveMyraDefinitionIdFromDb(db, tenantId),
runner: {
run: (runnerInput) =>
runOneShotFoldedPrompt(
{
foldedRuns: oneShotFoldedRunsDeps,
events: sidecarRouter.events,
cryptoProviders: agentDefinitionDraftingCryptoProviders,
undeploy: (address, reason) =>
sidecarRouter.sendAgentUndeploy(address, reason),
},
},
422,
);
}
throw err;
} finally {
agentDefinitionDraftingInFlightPrincipals.delete(principal.id);
}
},
);
runnerInput,
),
},
inventorySources: plannerInventorySources,
}).propose(input),
});
app.route(`${TENANT_PREFIX}/planner`, plannerRoutes);

// The sanctioned path for a workflow run to reach the memory plane
Expand Down
18 changes: 8 additions & 10 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
toAPIQuery,
} from "@corbits/api-query";
import { isChatPickerModelName } from "@corbits/connections/model-capability";
import { parseErrorEnvelope } from "@corbits/error-sink";
import { tenantKeys } from "./query-client";

export type AgentDefinition = typeof WorkflowDefinitionResponse.infer;
Expand Down Expand Up @@ -83,10 +84,6 @@ async function getJSON<T>(path: string, schema: Validator<T>): Promise<T> {
return parsed;
}

const ErrorEnvelope = type({
error: { code: "string", userMessage: "string", refId: "string" },
});

async function postJSON<T>(
path: string,
schema: Validator<T>,
Expand All @@ -109,12 +106,13 @@ async function postJSON<T>(
}
const json: unknown = await response.json().catch(() => undefined);
if (!response.ok) {
const envelope = ErrorEnvelope(json);
const message =
envelope instanceof type.errors
? `The server answered ${response.status}.`
: envelope.error.userMessage;
throw new ApiQueryError(message, response.status, path);
const envelope = parseErrorEnvelope(json);
throw new ApiQueryError(
envelope?.error.userMessage ?? `The server answered ${response.status}.`,
response.status,
path,
envelope?.error.refId,
);
}
const parsed = schema(json);
if (parsed instanceof type.errors) {
Expand Down
37 changes: 29 additions & 8 deletions apps/web/src/pages/create-agent-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ import { AgentSkillsPicker } from "./agent-skills-picker";
// that true: one implementation, so a handle suggested or accepted here can
// never be a shape the router refuses to resolve.

function submitErrorFromCause(
cause: unknown,
fallback: string,
): { readonly message: string; readonly refId?: string } {
if (!(cause instanceof ApiQueryError)) return { message: fallback };
return {
message: cause.message,
...(cause.refId !== undefined ? { refId: cause.refId } : {}),
};
}

function initialsFromName(name: string): string {
const [first, second] = name.trim().split(/\s+/).filter(Boolean);
if (first === undefined) return "?";
Expand Down Expand Up @@ -238,7 +249,10 @@ export function CreateAgentPanel({
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draftFailed, setDraftFailed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<{
readonly message: string;
readonly refId?: string;
} | null>(null);
const [modelsState, setModelsState] = useState<ModelsState>({
kind: "idle",
});
Expand Down Expand Up @@ -354,9 +368,10 @@ export function CreateAgentPanel({
setDraftFailed(true);
setAdvancedOpen(true);
setSubmitError(
cause instanceof ApiQueryError
? cause.message
: "Myra couldn't draft a starting prompt for this agent.",
submitErrorFromCause(
cause,
"Myra couldn't draft a starting prompt for this agent.",
),
);
setSubmitting(false);
return;
Expand All @@ -377,9 +392,7 @@ export function CreateAgentPanel({
onCreated(created);
} catch (cause) {
setSubmitError(
cause instanceof ApiQueryError
? cause.message
: "Could not create the agent.",
submitErrorFromCause(cause, "Could not create the agent."),
);
} finally {
setSubmitting(false);
Expand All @@ -403,7 +416,15 @@ export function CreateAgentPanel({
<DialogBody>
{submitError !== null && (
<p className="mb-3 text-sm text-destructive" role="alert">
{submitError}
{submitError.message}
{submitError.refId !== undefined ? (
<>
<br />
<span className="text-xs">
Reference: {submitError.refId}
</span>
</>
) : null}
</p>
)}
{modelsState.kind === "error" && (
Expand Down
86 changes: 86 additions & 0 deletions apps/web/test/agents-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@

import { afterEach, describe, expect, test } from "bun:test";

import { ApiQueryError } from "@corbits/api-query";

import {
clearAgentModel,
draftAgentDefinition,
getAgentDefinitionBySlug,
listCatalogModels,
listRoutineRunFires,
Expand Down Expand Up @@ -343,3 +346,86 @@ describe("clearAgentModel", () => {
expect(calls[0]?.method).toBe("DELETE");
});
});

const DRAFT_FAILED_MESSAGE =
"Myra couldn't draft a starting prompt for that. Write one yourself, or try again.";

describe("draftAgentDefinition errors", () => {
test("keeps the envelope userMessage and refId on a 422", async () => {
stubFetch(() =>
json(
{
error: {
code: "drafting_failed",
userMessage: DRAFT_FAILED_MESSAGE,
refId: "ref_draft_1",
},
},
422,
),
);

try {
await draftAgentDefinition("tnt_1", { name: "Research Buddy" });
throw new Error("expected draftAgentDefinition to reject");
} catch (err) {
expect(err).toBeInstanceOf(ApiQueryError);
const error = err as ApiQueryError;
expect(error.message).toBe(DRAFT_FAILED_MESSAGE);
expect(error.refId).toBe("ref_draft_1");
expect(error.status).toBe(422);
}
});

test("keeps the envelope userMessage and refId on a 500", async () => {
stubFetch(() =>
json(
{
error: {
code: "internal_error",
userMessage: "Something went wrong. Please try again.",
refId: "ref_sink_1",
},
},
500,
),
);

try {
await draftAgentDefinition("tnt_1", { name: "Research Buddy" });
throw new Error("expected draftAgentDefinition to reject");
} catch (err) {
expect(err).toBeInstanceOf(ApiQueryError);
const error = err as ApiQueryError;
expect(error.message).toBe("Something went wrong. Please try again.");
expect(error.refId).toBe("ref_sink_1");
expect(error.status).toBe(500);
}
});

test("does not treat a legacy {code, message} body as an envelope", async () => {
stubFetch(() =>
json(
{
error: {
code: "drafting_failed",
message:
"MyraAgentDefinitionDraftingUnavailableError: no myra\n at draft",
},
},
422,
),
);

try {
await draftAgentDefinition("tnt_1", { name: "Research Buddy" });
throw new Error("expected draftAgentDefinition to reject");
} catch (err) {
expect(err).toBeInstanceOf(ApiQueryError);
const error = err as ApiQueryError;
expect(error.message).toBe("The server answered 422.");
expect(error.refId).toBeUndefined();
expect(error.message).not.toContain("no myra");
}
});
});
Loading
Loading