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
3 changes: 2 additions & 1 deletion agents/myra/src/definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
10 changes: 8 additions & 2 deletions agents/myra/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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. */
Expand All @@ -53,14 +56,17 @@ 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");
}
if (input.systemPrompt === "") {
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({
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/agent-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { agent: { systemPrompt: string } }>;
};
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", () => {
Expand Down
48 changes: 33 additions & 15 deletions apps/web/src/agent-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -151,19 +151,42 @@ async function withPushToken<T>(
}
}

/** 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<string> {
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`,
Expand Down Expand Up @@ -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,
);

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/myra-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/myra-source.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
11 changes: 8 additions & 3 deletions packages/workflows/src/source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { expect, test } from "bun:test";
import {
readWorkflowSourceDefinition,
renderBundledWorkflowSourceTree,
renderWorkflowSourceTree,
RetiredWorkflowEnvelopeError,
WORKFLOW_SOURCE_ENTRY,
} from "./source";
Expand All @@ -21,8 +20,11 @@ function readerFor(tree: Readonly<Record<string, string>>) {
}

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,
});

Expand Down Expand Up @@ -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,
});

Expand Down
13 changes: 0 additions & 13 deletions packages/workflows/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 18 additions & 32 deletions packages/workflows/src/validate-push.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> {
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
Expand Down Expand Up @@ -41,53 +51,29 @@ function push(tree: Readonly<Record<string, string>>) {
}

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 };
};

Expand Down
Loading