diff --git a/bun.lock b/bun.lock index 977cc66a2..15917d83e 100644 --- a/bun.lock +++ b/bun.lock @@ -229,6 +229,21 @@ "typescript": "catalog:", }, }, + "packages/agent-runtime": { + "name": "@corbits/agent-runtime", + "version": "0.0.1", + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "packages/api-query": { "name": "@corbits/api-query", "version": "0.0.1", @@ -527,7 +542,7 @@ }, "packages/connections-tools": { "name": "@corbits/connections-tools", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@intx/agent": "workspace:*", "@intx/types": "workspace:*", @@ -1842,6 +1857,8 @@ "@corbits/agent-lifecycle": ["@corbits/agent-lifecycle@workspace:packages/agent-lifecycle"], + "@corbits/agent-runtime": ["@corbits/agent-runtime@workspace:packages/agent-runtime"], + "@corbits/api-query": ["@corbits/api-query@workspace:packages/api-query"], "@corbits/approvals": ["@corbits/approvals@workspace:packages/approvals"], diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index 3c8cd099e..bdb45ce80 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -380,3 +380,80 @@ Upstream's own diff over the same span is the reference implementation: `workflow-host-wiring.ts` at `4ed8baf4` show every one of these conversions against the same contracts, and `apps/sidecar`'s `VENDORED.md` row stays at `59f5e7b9` until workbench's fork is reconciled with them. + +### Conversion step 1: `packages/agent-runtime` + +`packages/agent-runtime` holds the definition builder every workbench +agent run deploys. `AgentRuntimeConfig` is the arktype contract for +everything that differs per run — the mailbox it answers on, its system +prompt, its resolved inference chain, its tool-package pins, its +credential bindings — and the config's `mode` selects the shape: +`buildAgentRuntimeWorkflow` returns either the folded unbounded step or +the per-turn `onTrigger` section. Because the mode lives in the config, +the deploy front keeps one parameter set and no call site ever branches +on which shape it wants; `deployCodeSourcedWorkflow` already enumerates +inert `onTrigger` bodies on every deploy, so the section shape needs +nothing extra from the API. + +#### There is no out-of-band config channel — the config IS the bytes + +The obvious design, one static published package whose entry reads a +per-run config from its environment, does not work at this pin and +cannot be made to work by the sidecar. + +The approval probe and the run child each evaluate the entry module +independently, and the child refuses to run a definition whose recomputed +wire hash differs from the approved one +(`workflow-host/src/child/verified-definition-loader.ts`). The hashed +preimage (`workflow/src/live-inert-projector.ts`) covers the trigger +address, the agent's system prompt, its `(provider, model)` pairs, its +tool-package pins, and the definition's credential bindings — every field +of the config. So a config read from anywhere outside the closure's bytes +diverges between the two evaluations and fails closed. And there is +nowhere to read it from anyway: `WorkflowDefinitionSource` has no overlay +or params on any variant, `AgentDeployWorkflow` carries no config bag +(the code-sourced route builds `HarnessConfig` with an empty +`systemPrompt`, empty `tools`, empty `grants`), `SpawnTimeEnv` has no +config field, and `WorkflowProbeRequestFrame` carries no env at all — so +even a sidecar willing to inject one could not make the probe see it. + +`renderAgentRuntimeSourceTree` is the consequence: it renders a thin +per-run package — a `package.json` plus a four-line entry module that +pins `@corbits/agent-runtime` and calls `buildAgentRuntimeWorkflow` with +the run's config as a literal. All the behaviour stays in the one +versioned package; what varies per run is a JSON literal inside the +hashed bytes. A host commits that tree into a `workflow`-kind asset and +deploys `source.kind: "asset"`, `package.format: "source"`, `commitSha` — +the only source variant whose pin is cheap enough to mint per run, since +the registry and tarball variants each need a publish. + +Two in-tree prerequisites remain for any of this to execute, both already +on the conversion table above: nothing produces `CLOSURE_PACKAGE_DIR`, and +no `WorkflowProbeExecutor` is wired on the sidecar, so every probe +currently answers `workflow.probe.error`. Both are conversion step 2. + +#### What still blocks `deployAtHead` + +A folded run pre-mints its own anchor `workflow_run` row (`mintFoldedRun`, +carrying the `principalId` its `agent_session` join needs) and it commonly +carries credential bindings (every `@corbits/mcp-tools` launch). Neither +code-sourced deploy front accepts that combination: + +| Front | Anchor row | Credential cipher | Capacity | +| ----------------------------------- | ---------- | ----------------- | ----------------------------------------------------- | +| `deployWorkflowFromSource` | INSERTs | not threaded | shared | +| `deployPreparedCodeSourcedWorkflow` | UPDATEs | threaded | exclusive allocation only (`requireAllocationRouter`) | + +`deployWorkflowFromSource` collides on the primary key of the row the +folded run already owns, and its `commonDeploy` passes no +`credentialCipher`, so a definition with bindings throws inside +`deployCodeSourcedWorkflow`. The prepared front does both correctly but +hard-requires an `allocationTarget`, and exclusive placement is dormant +in-tree. Composing the halves is not open either: `emitSourceRefDeployFrame` +and `buildInertProjectionStepSources` are module-private in `hub-sessions`. + +[Intx gap] The missing capability is a SHARED-capacity code-sourced deploy +that ADOPTS a pre-existing anchor run and threads a `credentialCipher` — +`deployPreparedCodeSourcedWorkflow` minus the allocation lock. Until it +exists upstream, `deployAtHead` cannot cut over without either forking the +front or dismantling the folded run's own anchor-row ownership. diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json new file mode 100644 index 000000000..e1a35d50b --- /dev/null +++ b/packages/agent-runtime/package.json @@ -0,0 +1,26 @@ +{ + "name": "@corbits/agent-runtime", + "private": true, + "description": "The versioned definition builder every workbench agent run deploys, plus the renderer that pins it into a per-run code-sourced workflow package", + "version": "0.0.1", + "license": "LGPL-2.1-or-later", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/agent-runtime/src/config.test.ts b/packages/agent-runtime/src/config.test.ts new file mode 100644 index 000000000..cbaad13f3 --- /dev/null +++ b/packages/agent-runtime/src/config.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { parseAgentRuntimeConfig, type AgentRuntimeConfig } from "./config"; + +const stepConfig: AgentRuntimeConfig = { + workflowId: "wf_run_a", + agentId: "run_a", + triggerAddress: "run_a@bench.example", + systemPrompt: "You are helpful.", + inferencePreferences: [{ provider: "acme", model: "acme-1" }], + toolPackagePins: [], + credentialBindings: [], + mode: { kind: "step" }, +}; + +describe("parseAgentRuntimeConfig", () => { + test("accepts a step-mode config", () => { + expect(parseAgentRuntimeConfig(stepConfig)).toEqual(stepConfig); + }); + + test("accepts a section-mode config with its turn timeout", () => { + const sectionConfig: AgentRuntimeConfig = { + ...stepConfig, + mode: { kind: "section", turnTimeoutMs: 60_000 }, + }; + expect(parseAgentRuntimeConfig(sectionConfig)).toEqual(sectionConfig); + }); + + test("rejects an empty inference chain rather than building a modelless agent", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, inferencePreferences: [] }), + ).toThrow(/invalid agent-runtime config/); + }); + + test("rejects a section mode with no turn timeout", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, mode: { kind: "section" } }), + ).toThrow(/invalid agent-runtime config/); + }); + + test("rejects an unknown mode", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, mode: { kind: "swarm" } }), + ).toThrow(/invalid agent-runtime config/); + }); + + test("rejects an empty trigger address", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, triggerAddress: "" }), + ).toThrow(/invalid agent-runtime config/); + }); +}); diff --git a/packages/agent-runtime/src/config.ts b/packages/agent-runtime/src/config.ts new file mode 100644 index 000000000..664345636 --- /dev/null +++ b/packages/agent-runtime/src/config.ts @@ -0,0 +1,84 @@ +// Everything about an agent run that its deployed definition must know: +// which mailbox it answers on, what it is told to be, which models it +// may use, which tool packages and credentials it carries, and which of +// the two shapes it takes. +// +// This config is DEPLOY-TIME data, and under the workflow.json +// retirement it has to live inside the deployed package's own bytes. +// The approval probe and the run child each evaluate the deployment's +// entry module independently and the child refuses to run a definition +// whose recomputed wire hash differs from the approved one; the hashed +// projection covers the system prompt, the trigger address, the model +// pairs, the tool package pins, and the credential bindings — every +// field here. So a config delivered out of band (an env var, a file the +// sidecar drops next to the entry) diverges between the two evaluations +// and fails closed. `./source-tree.ts` renders it into the bytes +// instead. +import { type } from "arktype"; +import { CredentialBinding } from "@intx/types"; +import { ToolPackagePin } from "@intx/types/tool-packages"; + +const InferencePreference = type({ + provider: "string > 0", + model: "string > 0", + "parameters?": "Record", +}); + +/** + * One unbounded agent step on the run's own address: the folded + * conversational shape. Every inbound mail is another turn of the same + * step, and the run never completes on its own. + */ +const StepMode = type({ + kind: "'step'", + /** + * When present, the step reads this fixed value instead of the + * triggering mail's `trigger.payload`. A run whose system prompt + * forbids acting on what it receives (the workbench host) pins a + * literal here so attachments-only mail — whose `content` is + * legitimately empty — cannot crash the step before it opens. + */ + "literalInput?": "unknown", +}); + +/** + * One long-lived `onTrigger` section on the run's own address: each + * inbound mail is one occurrence, run as its own child run with its own + * id and event log, so a reply is traceable. + */ +const SectionMode = type({ + kind: "'section'", + /** Per-occurrence timeout, enforced on the body's one step. */ + turnTimeoutMs: "number.integer > 0", +}); + +export const AgentRuntimeConfig = type({ + /** Definition id; also the base of the section body's id. */ + workflowId: "string > 0", + /** The step agent's id — the folded run's instance id. */ + agentId: "string > 0", + /** The mailbox this deployment answers on. */ + triggerAddress: "string > 0", + systemPrompt: "string", + /** Resolved catalog chain, in deploy order; the gate approves these. */ + inferencePreferences: InferencePreference.array().atLeastLength(1), + /** Tool packages the step agent carries; no inline tool factories. */ + toolPackagePins: ToolPackagePin.array(), + /** Definition-level bindings the host's per-step snapshot derives from. */ + credentialBindings: CredentialBinding.array(), + mode: StepMode.or(SectionMode), +}); +export type AgentRuntimeConfig = typeof AgentRuntimeConfig.infer; + +/** + * Parse `raw` as an `AgentRuntimeConfig`, throwing on any malformed + * shape. A deploy whose config does not parse must fail before a + * definition exists, not build a half-configured agent. + */ +export function parseAgentRuntimeConfig(raw: unknown): AgentRuntimeConfig { + const parsed = AgentRuntimeConfig(raw); + if (parsed instanceof type.errors) { + throw new Error(`invalid agent-runtime config: ${parsed.summary}`); + } + return parsed; +} diff --git a/packages/agent-runtime/src/definition.test.ts b/packages/agent-runtime/src/definition.test.ts new file mode 100644 index 000000000..be2f29202 --- /dev/null +++ b/packages/agent-runtime/src/definition.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; + +import type { AgentRuntimeConfig } from "./config"; +import { + AGENT_RUNTIME_SECTION_ID, + AGENT_RUNTIME_STEP_ID, + AGENT_RUNTIME_TURN_STEP_ID, + agentRuntimeTurnRunId, + buildAgentRuntimeWorkflow, +} from "./definition"; + +const baseConfig: AgentRuntimeConfig = { + workflowId: "wf_run_a", + agentId: "run_a", + triggerAddress: "run_a@bench.example", + systemPrompt: "You are helpful.", + inferencePreferences: [ + { provider: "acme", model: "acme-1" }, + { provider: "acme", model: "acme-2" }, + ], + toolPackagePins: [], + credentialBindings: [], + mode: { kind: "step" }, +}; + +describe("buildAgentRuntimeWorkflow — step mode", () => { + test("builds one unbounded agent step on the config's own address", () => { + const definition = buildAgentRuntimeWorkflow(baseConfig); + + expect(definition.id).toBe("wf_run_a"); + expect(definition.stepOrder).toEqual([AGENT_RUNTIME_STEP_ID]); + const stepPrimitive = definition.steps[AGENT_RUNTIME_STEP_ID]; + expect(stepPrimitive?.kind).toBe("step"); + expect(definition.triggers).toEqual([ + { type: "mail", to: "run_a@bench.example" }, + ]); + }); + + test("the step's trigger budget is unbounded, so a run never goes silent after one reply", () => { + const definition = buildAgentRuntimeWorkflow(baseConfig); + const stepPrimitive = definition.steps[AGENT_RUNTIME_STEP_ID]; + + expect(stepPrimitive).toMatchObject({ triggers: "unbounded" }); + }); + + test("carries the config's system prompt and inference chain onto the step agent", () => { + const definition = buildAgentRuntimeWorkflow(baseConfig); + + expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ + agent: { + id: "run_a", + systemPrompt: "You are helpful.", + inference: { sources: baseConfig.inferencePreferences }, + }, + }); + }); + + test("carries the config's tool package pins onto the step agent", () => { + const definition = buildAgentRuntimeWorkflow({ + ...baseConfig, + toolPackagePins: [{ name: "@corbits/mcp-tools", version: "0.0.1" }], + }); + + expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ + agent: { + toolPackagePins: [{ name: "@corbits/mcp-tools", version: "0.0.1" }], + }, + }); + }); + + test("declares the config's credential bindings on the definition itself", () => { + const credentialBindings = [ + { + package: "@corbits/mcp-tools", + handle: "mcp:notion", + provider: "notion", + locator: "tenant" as const, + }, + ]; + const definition = buildAgentRuntimeWorkflow({ + ...baseConfig, + credentialBindings, + }); + + expect(definition.credentialBindings).toEqual(credentialBindings); + }); + + test("omits credentialBindings entirely when the config declares none", () => { + expect( + buildAgentRuntimeWorkflow(baseConfig).credentialBindings, + ).toBeUndefined(); + }); + + test("pins the step's input to a literal when the config supplies one", () => { + const definition = buildAgentRuntimeWorkflow({ + ...baseConfig, + mode: { kind: "step", literalInput: "wake up" }, + }); + + expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ + input: { literal: "wake up" }, + }); + }); + + test("leaves the step reading its real trigger payload by default", () => { + const stepPrimitive = + buildAgentRuntimeWorkflow(baseConfig).steps[AGENT_RUNTIME_STEP_ID]; + + expect(stepPrimitive).not.toMatchObject({ input: { literal: undefined } }); + }); +}); + +describe("buildAgentRuntimeWorkflow — section mode", () => { + const sectionConfig: AgentRuntimeConfig = { + ...baseConfig, + mode: { kind: "section", turnTimeoutMs: 45_000 }, + }; + + test("builds an onTrigger section on the config's address, not a plain step", () => { + const definition = buildAgentRuntimeWorkflow(sectionConfig); + + expect(definition.stepOrder).toEqual([AGENT_RUNTIME_SECTION_ID]); + expect(definition.steps[AGENT_RUNTIME_SECTION_ID]).toMatchObject({ + kind: "onTrigger", + on: { type: "mail", to: "run_a@bench.example" }, + }); + }); + + test("the section's body is one agent step carrying the per-turn timeout", () => { + const section = + buildAgentRuntimeWorkflow(sectionConfig).steps[AGENT_RUNTIME_SECTION_ID]; + + expect(section).toMatchObject({ + body: { + inline: { + id: "wf_run_a_body", + steps: { + [AGENT_RUNTIME_TURN_STEP_ID]: { + kind: "step", + timeout: 45_000, + agent: { systemPrompt: "You are helpful." }, + }, + }, + }, + }, + }); + }); + + test("the mode alone selects the shape — same config fields, different definition", () => { + const asStep = buildAgentRuntimeWorkflow(baseConfig); + const asSection = buildAgentRuntimeWorkflow(sectionConfig); + + expect(asStep.steps[AGENT_RUNTIME_STEP_ID]?.kind).toBe("step"); + expect(asSection.steps[AGENT_RUNTIME_SECTION_ID]?.kind).toBe("onTrigger"); + expect(asStep.id).toBe(asSection.id); + }); +}); + +describe("agentRuntimeTurnRunId", () => { + test("matches the runtime's __ child-run scheme", () => { + expect(agentRuntimeTurnRunId(0)).toBe(`${AGENT_RUNTIME_SECTION_ID}__0`); + expect(agentRuntimeTurnRunId(7)).toBe(`${AGENT_RUNTIME_SECTION_ID}__7`); + }); + + test("rejects a non-integer or negative occurrence", () => { + expect(() => agentRuntimeTurnRunId(-1)).toThrow(); + expect(() => agentRuntimeTurnRunId(1.5)).toThrow(); + }); +}); diff --git a/packages/agent-runtime/src/definition.ts b/packages/agent-runtime/src/definition.ts new file mode 100644 index 000000000..0ccabbd7a --- /dev/null +++ b/packages/agent-runtime/src/definition.ts @@ -0,0 +1,145 @@ +// Builds the workflow definition a workbench agent run executes, from +// the run's deploy-time config alone. +// +// Two shapes, selected by `config.mode`, never by a branch in the deploy +// API: the deploy front takes one parameter set and the shape is purely +// whatever this module evaluates to. +// +// `step` is the folded conversational run — one unbounded agent step +// servicing every inbound mail as another turn. Its `triggers: +// "unbounded"` budget is the whole reason this is authored rather than +// wrapped: the platform's default budget of 1 makes a run go silent +// after its first reply. +// +// `section` is the per-turn shape (CL-6329) — an `onTrigger` section +// whose body is one agent step, so every message becomes an occurrence +// with its own child run id and event log. +// +// [Intx gap] CL-6329's `onBodyFailure: "continue"` policy — the failure +// edge that keeps a section subscribed after a failed turn — does not +// exist at the vendored pin `4ed8baf4`: `OnTriggerOpts` carries no such +// field and the inert projector's onTrigger whitelist +// (`vendor/intx/workflow/src/live-inert-projector.ts`) has no slot for +// it. Section mode is therefore authored without it here rather than +// with a workbench-local reimplementation of the primitive. When +// upstream lands the field, it is authored HERE — the projection drops +// it, so it survives only because the run child re-evaluates this +// module from the closure, and nothing may ever treat the projection as +// the executable definition. +import { buildSingleStepAgentDefinition } from "@intx/workflow-deploy"; +import { defineWorkflow, onTrigger, step } from "@intx/workflow"; +import type { WorkflowDefinition } from "@intx/workflow"; + +import type { AgentRuntimeConfig } from "./config"; + +/** The step id of the folded conversational run's one step. */ +export const AGENT_RUNTIME_STEP_ID = "default"; + +/** The section's step id in `section` mode. */ +export const AGENT_RUNTIME_SECTION_ID = "turn"; + +/** The body step inside one section occurrence — the agent that answers. */ +export const AGENT_RUNTIME_TURN_STEP_ID = "reply"; + +/** + * The child run id occurrence `occurrence` runs under. The runtime names + * an occurrence `__` (see `onTriggerBodyRef` in + * `@intx/workflow`), so the section id plus a zero-based occurrence + * index is the whole derivation. + */ +export function agentRuntimeTurnRunId(occurrence: number): string { + if (!Number.isInteger(occurrence) || occurrence < 0) { + throw new Error( + "agentRuntimeTurnRunId requires a non-negative integer occurrence", + ); + } + return `${AGENT_RUNTIME_SECTION_ID}__${String(occurrence)}`; +} + +function buildTurnAgent(config: AgentRuntimeConfig, id: string) { + return buildSingleStepAgentDefinition({ + id, + systemPrompt: config.systemPrompt, + inferencePreferences: config.inferencePreferences, + toolFactories: [], + toolPackagePins: config.toolPackagePins, + }); +} + +function buildFoldedStepWorkflow( + config: AgentRuntimeConfig, + literalInput: unknown, + hasLiteralInput: boolean, +): WorkflowDefinition { + const steps = { + [AGENT_RUNTIME_STEP_ID]: step({ + agent: buildTurnAgent(config, config.agentId), + triggers: "unbounded" as const, + ...(hasLiteralInput ? { input: { literal: literalInput } } : {}), + }), + }; + return config.credentialBindings.length > 0 + ? defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + credentialBindings: config.credentialBindings, + steps, + }) + : defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + steps, + }); +} + +function buildSectionWorkflow( + config: AgentRuntimeConfig, + turnTimeoutMs: number, +): WorkflowDefinition { + const body = defineWorkflow({ + id: `${config.workflowId}_body`, + trigger: { type: "mail", to: config.triggerAddress }, + steps: { + [AGENT_RUNTIME_TURN_STEP_ID]: step({ + agent: buildTurnAgent(config, AGENT_RUNTIME_TURN_STEP_ID), + timeout: turnTimeoutMs, + }), + }, + }); + const steps = { + [AGENT_RUNTIME_SECTION_ID]: onTrigger({ + on: { type: "mail" as const, to: config.triggerAddress }, + body, + }), + }; + return config.credentialBindings.length > 0 + ? defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + credentialBindings: config.credentialBindings, + steps, + }) + : defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + steps, + }); +} + +/** + * Build the run's definition from its deploy-time config. The config's + * `mode` selects the shape; every other field is the same per-run data + * either shape needs. + */ +export function buildAgentRuntimeWorkflow( + config: AgentRuntimeConfig, +): WorkflowDefinition { + if (config.mode.kind === "section") { + return buildSectionWorkflow(config, config.mode.turnTimeoutMs); + } + return buildFoldedStepWorkflow( + config, + config.mode.literalInput, + "literalInput" in config.mode, + ); +} diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts new file mode 100644 index 000000000..17e9e4e8b --- /dev/null +++ b/packages/agent-runtime/src/index.ts @@ -0,0 +1,15 @@ +export { AgentRuntimeConfig, parseAgentRuntimeConfig } from "./config"; +export { + AGENT_RUNTIME_SECTION_ID, + AGENT_RUNTIME_STEP_ID, + AGENT_RUNTIME_TURN_STEP_ID, + agentRuntimeTurnRunId, + buildAgentRuntimeWorkflow, +} from "./definition"; +export { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; +export { + AGENT_RUNTIME_ENTRY_PATH, + renderAgentRuntimeSourceTree, + type AgentRuntimeSourceTree, + type RenderAgentRuntimeSourceTreeInput, +} from "./source-tree"; diff --git a/packages/agent-runtime/src/pin.ts b/packages/agent-runtime/src/pin.ts new file mode 100644 index 000000000..1bfb22dac --- /dev/null +++ b/packages/agent-runtime/src/pin.ts @@ -0,0 +1,6 @@ +/** + * The package name a rendered per-run workflow tree depends on and + * imports its builder from. Declared next to the package rather than + * re-typed in the renderer's template. + */ +export const AGENT_RUNTIME_PACKAGE_NAME = "@corbits/agent-runtime"; diff --git a/packages/agent-runtime/src/source-tree.test.ts b/packages/agent-runtime/src/source-tree.test.ts new file mode 100644 index 000000000..6a13c179e --- /dev/null +++ b/packages/agent-runtime/src/source-tree.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; + +import type { AgentRuntimeConfig } from "./config"; +import { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; +import { + AGENT_RUNTIME_ENTRY_PATH, + renderAgentRuntimeSourceTree, +} from "./source-tree"; + +const config: AgentRuntimeConfig = { + workflowId: "wf_run_a", + agentId: "run_a", + triggerAddress: "run_a@bench.example", + systemPrompt: "You are helpful.", + inferencePreferences: [{ provider: "acme", model: "acme-1" }], + toolPackagePins: [], + credentialBindings: [], + mode: { kind: "step" }, +}; + +function render(overrides: Partial = {}) { + return renderAgentRuntimeSourceTree({ + packageName: "run-a-workflow", + runtimeVersion: "0.0.1", + config: { ...config, ...overrides }, + }); +} + +describe("renderAgentRuntimeSourceTree", () => { + test("declares the entry the sidecar evaluates", () => { + const pkg = JSON.parse(render()["package.json"] ?? ""); + + expect(pkg.interchange).toEqual({ workflow: AGENT_RUNTIME_ENTRY_PATH }); + expect(Object.keys(render())).toContain("workflow.js"); + }); + + test("pins the versioned runtime package as the tree's one dependency", () => { + const pkg = JSON.parse(render()["package.json"] ?? ""); + + expect(pkg.dependencies).toEqual({ [AGENT_RUNTIME_PACKAGE_NAME]: "0.0.1" }); + }); + + test("renders the config into the entry module's own bytes", () => { + const entry = render()["workflow.js"] ?? ""; + + expect(entry).toContain(`from "${AGENT_RUNTIME_PACKAGE_NAME}"`); + expect(entry).toContain("buildAgentRuntimeWorkflow("); + expect(entry).toContain('"run_a@bench.example"'); + expect(entry).toContain('"You are helpful."'); + }); + + test("a differing per-run field produces differing bytes — the hash barrier's whole premise", () => { + const a = render()["workflow.js"]; + const b = render({ systemPrompt: "You are terse." })["workflow.js"]; + + expect(a).not.toBe(b); + }); + + test("the same config renders byte-identically, so probe and run agree", () => { + expect(render()).toEqual(render()); + }); + + test("the rendered entry's config round-trips back to the config it was given", () => { + const entry = render()["workflow.js"] ?? ""; + const literal = entry.slice( + entry.indexOf("buildAgentRuntimeWorkflow(") + + "buildAgentRuntimeWorkflow(".length, + entry.lastIndexOf(");"), + ); + + expect(JSON.parse(literal)).toEqual(config); + }); + + test("renders the section mode's turn timeout into the bytes too", () => { + const entry = + render({ mode: { kind: "section", turnTimeoutMs: 45_000 } })[ + "workflow.js" + ] ?? ""; + + expect(entry).toContain('"kind": "section"'); + expect(entry).toContain('"turnTimeoutMs": 45000'); + }); + + test("refuses to render a config the run child would reject", () => { + expect(() => render({ inferencePreferences: [] })).toThrow( + /invalid agent-runtime config/, + ); + }); +}); diff --git a/packages/agent-runtime/src/source-tree.ts b/packages/agent-runtime/src/source-tree.ts new file mode 100644 index 000000000..490e895f2 --- /dev/null +++ b/packages/agent-runtime/src/source-tree.ts @@ -0,0 +1,66 @@ +// Renders the source tree a code-sourced deploy actually deploys. +// +// Under the workflow.json retirement a deployment's definition is +// whatever its own pinned code closure evaluates to, and the approved +// wire hash covers every field that differs per run. So the per-run +// config cannot ride beside the bytes — it has to BE the bytes. +// +// The tree this renders is deliberately thin: a `package.json` and a +// four-line entry module that pins `@corbits/agent-runtime` and calls +// `buildAgentRuntimeWorkflow` with the run's config as a literal. All +// the behaviour stays in this one versioned package, reviewed and +// upgraded in one place; what varies per run is a JSON literal. A host +// commits the tree into a `workflow`-kind asset and deploys it with +// `source.kind: "asset"`, `package.format: "source"`, `commitSha` — the +// only source variant whose pin is cheap enough to mint per run (the +// registry and tarball variants would each need a publish). +import { parseAgentRuntimeConfig, type AgentRuntimeConfig } from "./config"; +import { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; + +/** The entry path the rendered `package.json` declares and the sidecar evaluates. */ +export const AGENT_RUNTIME_ENTRY_PATH = "./workflow.js"; + +export interface RenderAgentRuntimeSourceTreeInput { + /** + * The rendered package's own name. It never leaves the asset, so it + * only has to be a valid package name and stable for a given run. + */ + readonly packageName: string; + /** The `@corbits/agent-runtime` range the rendered package depends on. */ + readonly runtimeVersion: string; + /** The run's deploy-time config, rendered into the entry module. */ + readonly config: AgentRuntimeConfig; +} + +/** File contents keyed by path relative to the tree root. */ +export type AgentRuntimeSourceTree = Readonly>; + +/** + * Render the per-run workflow package. The config is validated before + * it is written, so a config the run child would reject fails at the + * deploying call site instead of inside the approval probe. + */ +export function renderAgentRuntimeSourceTree( + input: RenderAgentRuntimeSourceTreeInput, +): AgentRuntimeSourceTree { + const config = parseAgentRuntimeConfig(input.config); + const packageJson = { + name: input.packageName, + version: "0.0.0", + private: true, + type: "module", + interchange: { workflow: AGENT_RUNTIME_ENTRY_PATH }, + dependencies: { [AGENT_RUNTIME_PACKAGE_NAME]: input.runtimeVersion }, + }; + const entry = [ + `import { buildAgentRuntimeWorkflow } from ${JSON.stringify(AGENT_RUNTIME_PACKAGE_NAME)};`, + "", + `export default buildAgentRuntimeWorkflow(${JSON.stringify(config, null, 2)});`, + "", + ].join("\n"); + + return { + "package.json": `${JSON.stringify(packageJson, null, 2)}\n`, + "workflow.js": entry, + }; +} diff --git a/packages/agent-runtime/tsconfig.json b/packages/agent-runtime/tsconfig.json new file mode 100644 index 000000000..e956ddd88 --- /dev/null +++ b/packages/agent-runtime/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src", "test"] +}