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: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ e2e/browser/shots/
# `bun pm pack` / `bun run release` tarballs — built on demand, never
# committed (CL-8167).
*.tgz

# Generated Myra workflow bundle (built on demand, never committed)
agents/myra/bundle/
19 changes: 13 additions & 6 deletions agents/myra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"src",
"!src/**/*.test.ts",
"!src/**/*.test.tsx",
"bundle",
"LICENSE",
"README.md"
],
Expand All @@ -20,22 +21,28 @@
".": "./src/index.ts",
"./prompt": "./src/system-prompt.ts",
"./tool-packages": "./src/tool-packages.ts",
"./workflow-ids": "./src/workflow-ids.ts"
"./workflow-ids": "./src/workflow-ids.ts",
"./bundle": "./src/bundle.ts"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
"test": "bun test",
"build": "bun run scripts/build-bundle.ts"
},
"dependencies": {
"@corbits/catalog-tools": "0.1.1",
"@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3"
},
"devDependencies": {
"@corbits/access-tools": "workspace:*",
"@corbits/agent-directory-tools": "workspace:*",
"@corbits/capability-tools": "workspace:*",
"@corbits/catalog-tools": "workspace:*",
"@corbits/interaction-tools": "workspace:*",
"@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3",
"@corbits/skills-tools": "workspace:*",
"@corbits/workflow-authoring-tools": "workspace:*"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
},
Expand Down
56 changes: 56 additions & 0 deletions agents/myra/scripts/build-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Bundles Myra's workflow entry into one self-contained ESM module.
//
// The sidecar's source deploy evaluates the pushed `workflow.js` as a frozen
// closure with nothing to resolve bare imports against, so every `@intx/*`
// and tool-package import has to be inlined here.
import { mkdir, writeFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import path from "node:path";

const packageRoot = path.resolve(import.meta.dir, "..");

export const MYRA_BUNDLE_FILE = "workflow-bundle.js";
export const MYRA_BUNDLE_PATH = path.join(packageRoot, "bundle", MYRA_BUNDLE_FILE);

/** The name the bundle exports, which the rendered entry calls. */
export const MYRA_BUNDLE_BUILD_EXPORT = "buildMyraWorkflow";

export async function buildMyraBundle(): Promise<string> {
const built = await Bun.build({
entrypoints: [path.join(packageRoot, "src", "index.ts")],
target: "bun",
format: "esm",
minify: false,
sourcemap: "none",
throw: true,
});
const artifact = built.outputs[0];
if (artifact === undefined) {
throw new Error("buildMyraBundle: Bun.build produced no output");
}
const code = await artifact.text();
// A package specifier left in the bundle is a silent runtime failure inside
// the sidecar's closure, so it fails here instead. Node builtins stay
// external on purpose: the closure is evaluated by bun, which resolves them.
const unresolved = [...code.matchAll(/(?:^|\n)\s*import\s[^\n]*?from\s*"([^"]+)"/g)]
.map((match) => match[1] ?? "")
.filter((specifier) => !specifier.startsWith(".") && !isBuiltin(specifier));
if (unresolved.length > 0) {
throw new Error(
`buildMyraBundle: bundle still carries unresolved imports: ${[...new Set(unresolved)].join(", ")}`,
);
}
return code;
}

export async function writeMyraBundle(): Promise<string> {
const code = await buildMyraBundle();
await mkdir(path.dirname(MYRA_BUNDLE_PATH), { recursive: true });
await writeFile(MYRA_BUNDLE_PATH, code);
return MYRA_BUNDLE_PATH;
}

if (import.meta.main) {
const written = await writeMyraBundle();
console.log(`[myra] bundled workflow entry -> ${written}`);
}
49 changes: 0 additions & 49 deletions agents/myra/src/boundary.test.ts

This file was deleted.

11 changes: 11 additions & 0 deletions agents/myra/src/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// The built bundle's text, handed to the client as a string so the deploy
// can render it into the asset's `workflow.js`. `bundle/` is generated by
// `scripts/build-bundle.ts` and gitignored; the web build produces it before
// Vite resolves this import.
import bundleText from "../bundle/workflow-bundle.js?raw";

/** One self-contained ESM module exporting `MYRA_BUNDLE_BUILD_EXPORT`. */
export const MYRA_WORKFLOW_BUNDLE: string = bundleText;

/** The bundle export the rendered entry calls with per-deploy values. */
export const MYRA_BUNDLE_BUILD_EXPORT = "buildMyraWorkflow";
100 changes: 19 additions & 81 deletions agents/myra/src/definition.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
// Tests for this package's own contract: the shape our factory
// commits to, its serialization guarantees, and its boundary. The
// platform's own normalization and validation are its business, not
// re-proven here.
// The two facts about Myra's entry that a deploy silently depends on: she
// carries real tool factories (the source lineage resolves no pins, so an
// empty list means an agent with no tools), and she stays single-step.

import { expect, test } from "bun:test";
import type { StepPrimitive, WorkflowDefinition } from "@intx/workflow";

import {
ASSISTANT_STEP_ID,
ASSISTANT_SYSTEM_PROMPT,
ASSISTANT_WORKFLOW_ID,
buildAssistantWorkflow,
serializeAssistantWorkflow,
} from "./index";
import { ASSISTANT_STEP_ID, buildMyraWorkflow } from "./index";

const INPUT = {
triggerAddress: "ins_dep000000000000@example.test",
inferencePreferences: [{ provider: "anthropic", model: "claude-test" }],
turnTimeoutMs: 600000,
systemPrompt: "You are Myra.",
} as const;

function assistantStep(definition: WorkflowDefinition): StepPrimitive {
Expand All @@ -28,79 +22,23 @@ function assistantStep(definition: WorkflowDefinition): StepPrimitive {
return primitive;
}

test("the agent carries its tool factories inline, not as pins", () => {
const agent = assistantStep(buildMyraWorkflow(INPUT)).agent;
expect(agent.toolFactories.length).toBeGreaterThan(0);
// Every factory must be callable and namespaced: the sidecar reads
// `factory.id` as the tool package name for the source lineage.
for (const factory of agent.toolFactories) {
expect(typeof factory).toBe("function");
expect(factory.id).toMatch(/^@?[^/]+\/.+/);
}
expect(agent.toolPackagePins).toEqual([]);
});

test("the definition has exactly one step, so a deployment stays conversational", () => {
// A single-step deployment keeps one warm agent with durable memory
// across runs; a second step would silently trade that memory away.
// This assertion is the tripwire against that regression.
const definition = buildAssistantWorkflow(INPUT);
const definition = buildMyraWorkflow(INPUT);
expect(definition.stepOrder).toEqual([ASSISTANT_STEP_ID]);
expect(Object.keys(definition.steps)).toEqual([ASSISTANT_STEP_ID]);
});

test("the step carries an explicit per-turn timeout", () => {
const definition = buildAssistantWorkflow(INPUT);
expect(assistantStep(definition).triggers).toBe("unbounded");
expect(assistantStep(definition).timeout).toBe(INPUT.turnTimeoutMs);
});

test("the step is unbounded: it re-arms after every reply instead of completing after the first", () => {
// The platform's step primitive defaults `triggers` to 1 (batch). A
// conversation is the long-lived interactive agent that must never
// self-complete — without this, the run ends after the greeting and
// every later message is rejected as sent to a terminal run.
expect(assistantStep(buildAssistantWorkflow(INPUT)).triggers).toBe("unbounded");
});

test("the workflow is triggered by mail to the given deployment address", () => {
const definition = buildAssistantWorkflow(INPUT);
expect(definition.id).toBe(ASSISTANT_WORKFLOW_ID);
expect(definition.triggers).toEqual([{ type: "mail", to: INPUT.triggerAddress }]);
});

test("the agent carries the assistant prompt, the preferences, and inlines no tools", () => {
const agent = assistantStep(buildAssistantWorkflow(INPUT)).agent;
expect(agent.systemPrompt).toBe(ASSISTANT_SYSTEM_PROMPT);
expect(agent.inference.sources).toEqual([...INPUT.inferencePreferences]);
// Tools arrive as packages on the deploy, never inlined here: an
// inline factory is a function-valued field the asset cannot carry.
expect(agent.toolFactories).toEqual([]);
});

test("the definition survives the workflow-asset JSON round-trip", () => {
const definition = buildAssistantWorkflow(INPUT);
const revived: unknown = JSON.parse(serializeAssistantWorkflow(definition));
expect(revived).toEqual(definition);
});

test("serialization fails loud on a function-valued field, naming its path", () => {
const poisoned = {
id: ASSISTANT_WORKFLOW_ID,
triggers: [{ type: "manual" }],
stepOrder: [ASSISTANT_STEP_ID],
steps: {
assistant: {
kind: "step",
id: ASSISTANT_STEP_ID,
drainBehavior: "cancel",
agent: {
id: ASSISTANT_STEP_ID,
systemPrompt: ASSISTANT_SYSTEM_PROMPT,
toolFactories: [() => []],
capabilities: [],
inference: { sources: [] },
},
},
},
} as unknown as WorkflowDefinition;
expect(() => serializeAssistantWorkflow(poisoned)).toThrow(
/steps\.assistant\.agent\.toolFactories\[0\]/,
);
});

test("an empty trigger address is rejected", () => {
expect(() => buildAssistantWorkflow({ ...INPUT, triggerAddress: "" })).toThrow(/triggerAddress/);
});

test("a non-positive or fractional turn timeout is rejected", () => {
expect(() => buildAssistantWorkflow({ ...INPUT, turnTimeoutMs: 0 })).toThrow(/turnTimeoutMs/);
expect(() => buildAssistantWorkflow({ ...INPUT, turnTimeoutMs: 0.5 })).toThrow(/turnTimeoutMs/);
});
Loading
Loading