diff --git a/agents/myra/src/definition.test.ts b/agents/myra/src/definition.test.ts index 4c0a5e984..6fa44e9a4 100644 --- a/agents/myra/src/definition.test.ts +++ b/agents/myra/src/definition.test.ts @@ -5,9 +5,10 @@ import { expect, test } from "bun:test"; import type { StepPrimitive, WorkflowDefinition } from "@intx/workflow"; -import { ASSISTANT_STEP_ID, buildMyraWorkflow } from "./index"; +import { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID, buildMyraWorkflow } from "./index"; const INPUT = { + workflowId: ASSISTANT_WORKFLOW_ID, triggerAddress: "ins_dep000000000000@example.test", inferencePreferences: [{ provider: "anthropic", model: "claude-test" }], systemPrompt: "You are Myra.", diff --git a/agents/myra/src/index.ts b/agents/myra/src/index.ts index ef484d2ba..c76b7ed56 100644 --- a/agents/myra/src/index.ts +++ b/agents/myra/src/index.ts @@ -14,7 +14,7 @@ import type { WorkflowDefinition } from "@intx/workflow"; import { mail } from "@intx/tools-mail/sidecar-bundle"; import { posix } from "@intx/tools-posix/sidecar-bundle"; -import { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "./workflow-ids"; +import { ASSISTANT_STEP_ID } from "./workflow-ids"; export { ASSISTANT_SYSTEM_PROMPT } from "./system-prompt"; export { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "./workflow-ids"; @@ -32,6 +32,9 @@ export const MYRA_TOOL_FACTORIES = [mail, posix] as unknown as readonly Annotate /** Everything the definition needs that is per-deployment data. */ export interface MyraWorkflowInput { + /** The definition id: Myra's fixed `ASSISTANT_WORKFLOW_ID`, or a created + * agent's slug — the bundle is generic over which agent it builds. */ + readonly workflowId: string; /** The deployment's mail address; each inbound mail is one run. */ readonly triggerAddress: string; /** Provider/model preferences, in order; resolved at deploy time. */ @@ -53,6 +56,9 @@ export interface MyraWorkflowInput { * manifest: the factories above are the whole tool surface. */ export function buildMyraWorkflow(input: MyraWorkflowInput): WorkflowDefinition { + if (input.workflowId === "") { + throw new Error("buildMyraWorkflow requires a non-empty workflowId"); + } if (input.triggerAddress === "") { throw new Error("buildMyraWorkflow requires a non-empty triggerAddress"); } @@ -60,7 +66,7 @@ export function buildMyraWorkflow(input: MyraWorkflowInput): WorkflowDefinition throw new Error("buildMyraWorkflow requires a non-empty systemPrompt"); } return defineWorkflow({ - id: ASSISTANT_WORKFLOW_ID, + id: input.workflowId, trigger: { type: "mail", to: input.triggerAddress }, steps: { assistant: step({ diff --git a/apps/web/src/agent-deploy.test.ts b/apps/web/src/agent-deploy.test.ts index da91c3782..df152874f 100644 --- a/apps/web/src/agent-deploy.test.ts +++ b/apps/web/src/agent-deploy.test.ts @@ -1,6 +1,38 @@ import { describe, expect, test } from "bun:test"; -import { agentDeploySourceAssetName, agentSlugFromSourceAssetName } from "./agent-deploy"; +import { + agentDeploySourceAssetName, + agentSlugFromSourceAssetName, + buildAgentDefinitionJson, +} from "./agent-deploy"; + +describe("buildAgentDefinitionJson", () => { + test("the JSON projection agrees with the bundle input it is pushed alongside", () => { + // pushAgentSource renders both from the same args, but nothing else + // typechecks that they describe the same run — this pins that they do. + const args = { + slug: "research-buddy", + systemPrompt: "You research things.", + triggerAddress: "research-buddy@example.test", + declaredSources: [{ provider: "anthropic", model: "claude-test" }], + }; + const projection = buildAgentDefinitionJson(args) as { + id: string; + triggers: readonly { to: string }[]; + steps: Record; + }; + const buildInput = { + workflowId: args.slug, + triggerAddress: args.triggerAddress, + inferencePreferences: args.declaredSources, + systemPrompt: args.systemPrompt, + }; + + expect(projection.id).toBe(buildInput.workflowId); + expect(projection.triggers[0]?.to).toBe(buildInput.triggerAddress); + expect(Object.values(projection.steps)[0]?.agent.systemPrompt).toBe(buildInput.systemPrompt); + }); +}); describe("agentSlugFromSourceAssetName", () => { test("recovers the slug agentDeploySourceAssetName wrapped", () => { diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts index 7b7014612..3081eab53 100644 --- a/apps/web/src/agent-deploy.ts +++ b/apps/web/src/agent-deploy.ts @@ -4,7 +4,7 @@ // the stock `POST /workflows/deployments`. Generalized over {name, // displayName, systemPrompt} so the create-agent panel can deploy any // agent through the one path the platform actually backs. -import { renderWorkflowSourceTree } from "@corbits/workflows/client"; +import { renderBundledWorkflowSourceTree } from "@corbits/workflows/client"; import { type } from "arktype"; import { WorkflowDeploymentResponse } from "@intx/types"; @@ -151,19 +151,42 @@ async function withPushToken( } } -/** Renders this agent's built definition as a source tree and pushes it to - * its asset's `main`. Returns the commit sha the deploy pins to. */ +/** Renders this agent's built definition as the same bundled source tree + * Myra deploys (`pushMyraSource`) — the bundle's `buildMyraWorkflow` is + * generic over which agent it builds — and pushes it to the asset's `main`. + * Returns the commit sha the deploy pins to. */ export async function pushAgentSource( tenantId: string, assetId: string, assetName: string, packageName: string, - workflowJson: unknown, + args: { + readonly slug: string; + readonly systemPrompt: string; + readonly triggerAddress: string; + readonly declaredSources: readonly { readonly provider: string; readonly model: string }[]; + }, fetchImpl: typeof fetch = fetch, ): Promise { - const tree = renderWorkflowSourceTree({ + const { MYRA_BUNDLE_BUILD_EXPORT, MYRA_WORKFLOW_BUNDLE } = await import("@corbits/myra/bundle"); + const tree = renderBundledWorkflowSourceTree({ packageName, - workflowJson: JSON.stringify(workflowJson), + bundle: MYRA_WORKFLOW_BUNDLE, + buildExport: MYRA_BUNDLE_BUILD_EXPORT, + buildInput: { + workflowId: args.slug, + triggerAddress: args.triggerAddress, + inferencePreferences: args.declaredSources.map((source) => ({ ...source })), + systemPrompt: args.systemPrompt, + }, + workflowJson: JSON.stringify( + buildAgentDefinitionJson({ + slug: args.slug, + systemPrompt: args.systemPrompt, + triggerAddress: args.triggerAddress, + declaredSources: args.declaredSources, + }), + ), }); const url = new URL( `/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`, @@ -284,20 +307,15 @@ export async function deployAgentSource( } const assetId = await ensureAgentSourceAsset(args.tenantId, assetName, name, fetchImpl); - const workflowJson = buildAgentDefinitionJson({ - slug, - systemPrompt, - // Grant configuration only, not a routable address: the hub mints the - // agent's real address (its run address) at deploy time. - triggerAddress: `${slug}@${tenant.domain}`, - declaredSources: offering.declaredSources, - }); + // Grant configuration only, not a routable address: the hub mints the + // agent's real address (its run address) at deploy time. + const triggerAddress = `${slug}@${tenant.domain}`; const commitSha = await pushAgentSource( args.tenantId, assetId, assetName, packageName, - workflowJson, + { slug, systemPrompt, triggerAddress, declaredSources: offering.declaredSources }, fetchImpl, ); diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index ee13a882b..50a7fabb0 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -186,6 +186,7 @@ export async function pushMyraSource( bundle: MYRA_WORKFLOW_BUNDLE, buildExport: MYRA_BUNDLE_BUILD_EXPORT, buildInput: { + workflowId: ASSISTANT_WORKFLOW_ID, triggerAddress, inferencePreferences: declaredSources.map((source) => ({ ...source })), systemPrompt: ASSISTANT_SYSTEM_PROMPT, diff --git a/apps/web/src/myra-source.ts b/apps/web/src/myra-source.ts index d7fb7cbb1..d5205b094 100644 --- a/apps/web/src/myra-source.ts +++ b/apps/web/src/myra-source.ts @@ -1,6 +1,6 @@ -// Myra's deploy source: a `workflow`-kind asset holding the two-file -// codebase `renderWorkflowSourceTree` emits, pushed over the stock git -// smart-HTTP route and deployed as a source tree at that commit. That is +// Myra's deploy source: a `workflow`-kind asset holding the three-file +// codebase `renderBundledWorkflowSourceTree` emits, pushed over the stock +// git smart-HTTP route and deployed as a source tree at that commit. That is // the one variant the stock deploy route can anchor from a browser: it // requires a `workflow`-kind asset, and a `workflow` asset only takes its // code by git push. diff --git a/packages/workflows/src/source.test.ts b/packages/workflows/src/source.test.ts index 243470241..38115d020 100644 --- a/packages/workflows/src/source.test.ts +++ b/packages/workflows/src/source.test.ts @@ -3,7 +3,6 @@ import { expect, test } from "bun:test"; import { readWorkflowSourceDefinition, renderBundledWorkflowSourceTree, - renderWorkflowSourceTree, RetiredWorkflowEnvelopeError, WORKFLOW_SOURCE_ENTRY, } from "./source"; @@ -21,8 +20,11 @@ function readerFor(tree: Readonly>) { } test("the rendered tree is a manifest, the entry, and the definition projection", () => { - const tree = renderWorkflowSourceTree({ + const tree = renderBundledWorkflowSourceTree({ packageName: "@workbench-agent/research-buddy", + bundle: "export function build(input) { return input; }", + buildExport: "build", + buildInput: { id: "wf_agent_research-buddy" }, workflowJson: WORKFLOW_JSON, }); @@ -57,8 +59,11 @@ test("an asset with no definition projection reads as the named retirement error }); test("reading a source-form asset answers its serialized definition", async () => { - const tree = renderWorkflowSourceTree({ + const tree = renderBundledWorkflowSourceTree({ packageName: "@workbench-agent/research-buddy", + bundle: "export function build(input) { return input; }", + buildExport: "build", + buildInput: { id: "wf_agent_research-buddy" }, workflowJson: WORKFLOW_JSON, }); diff --git a/packages/workflows/src/source.ts b/packages/workflows/src/source.ts index 7a642ec47..33d205106 100644 --- a/packages/workflows/src/source.ts +++ b/packages/workflows/src/source.ts @@ -30,19 +30,6 @@ function manifestFor(packageName: string): string { )}\n`; } -/** The source tree a serialized, function-free definition renders into: - * the entry re-exports the JSON verbatim. */ -export function renderWorkflowSourceTree(args: { - packageName: string; - workflowJson: string; -}): WorkflowSourceTree { - return { - [WORKFLOW_SOURCE_MANIFEST_PATH]: manifestFor(args.packageName), - [WORKFLOW_SOURCE_ENTRY_PATH]: `export default ${args.workflowJson};\n`, - [WORKFLOW_SOURCE_DEFINITION_PATH]: `${args.workflowJson}\n`, - }; -} - /** The source tree a bundled entry renders into. `bundle` is one * self-contained ESM module exporting `buildExport`; the trailing call * supplies the per-deploy values and is what the platform evaluates. diff --git a/packages/workflows/src/validate-push.test.ts b/packages/workflows/src/validate-push.test.ts index 698e0dfb2..719f3432c 100644 --- a/packages/workflows/src/validate-push.test.ts +++ b/packages/workflows/src/validate-push.test.ts @@ -1,17 +1,27 @@ // Round-trip against the real upstream validator, not our renderer's // comments about it. `workflowKindHandler.validatePush` // (`vendor/intx/hub-sessions/src/workflow-kind.ts`) is the only consumer of -// the tree `renderWorkflowSourceTree` writes; nothing else in this repo -// checks the pair stays in sync, so a renderer/validator drift would +// the tree `renderBundledWorkflowSourceTree` writes; nothing else in this +// repo checks the pair stays in sync, so a renderer/validator drift would // otherwise surface only as a push rejection in production. import { expect, test } from "bun:test"; import { workflowKindHandler } from "@intx/hub-sessions"; -import { renderWorkflowSourceTree } from "./source"; +import { renderBundledWorkflowSourceTree } from "./source"; const WORKFLOW_JSON = JSON.stringify({ id: "wf_agent_research-buddy" }); +function renderedTree(): Readonly> { + return renderBundledWorkflowSourceTree({ + packageName: "@workbench-agent/research-buddy", + bundle: "export function build(input) { return input; }", + buildExport: "build", + buildInput: { id: "wf_agent_research-buddy" }, + workflowJson: WORKFLOW_JSON, + }); +} + const encoder = new TextEncoder(); /** A minimal in-memory tree reader, matching the shape used by @@ -41,53 +51,29 @@ function push(tree: Readonly>) { } test("a rendered single-package tree passes the real validatePush", async () => { - const tree = renderWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - workflowJson: WORKFLOW_JSON, - }); - - const result = await push(tree); + const result = await push(renderedTree()); expect(result).toEqual({ ok: true }); }); test("the renderer never emits an envelope-only capability-declarations.json", async () => { - const tree = renderWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - workflowJson: WORKFLOW_JSON, - }); - - expect(Object.keys(tree)).not.toContain("capability-declarations.json"); + expect(Object.keys(renderedTree())).not.toContain("capability-declarations.json"); }); test("the renderer never commits a node_modules directory", async () => { - const tree = renderWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - workflowJson: WORKFLOW_JSON, - }); - - expect(Object.keys(tree)).not.toContain("node_modules"); + expect(Object.keys(renderedTree())).not.toContain("node_modules"); }); test("the renderer never leaves an envelope-valid workflow.json beside the package.json", async () => { - const tree = renderWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - workflowJson: WORKFLOW_JSON, - }); - // The renderer's only paths are package.json, workflow.js and // definition.json; the // retired workflow.json envelope path never appears in its output, so the // ambiguous-tree rejection has no way to fire against what we emit. - expect(Object.keys(tree)).not.toContain("workflow.json"); + expect(Object.keys(renderedTree())).not.toContain("workflow.json"); }); test("the renderer's package.json always declares a non-empty, contained interchange.workflow entry", async () => { - const tree = renderWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - workflowJson: WORKFLOW_JSON, - }); - const manifest = JSON.parse(tree["package.json"] as string) as { + const manifest = JSON.parse(renderedTree()["package.json"] as string) as { interchange: { workflow: string }; };