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
20 changes: 10 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/acp/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/acp",
"version": "0.10.1",
"version": "0.10.2",
"license": "MIT",
"exports": {
".": "./mod.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/acp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/acp",
"version": "0.10.1",
"version": "0.10.2",
"description": "ACPX agent provider for executable.md documents: drives coding agents over the Agent Client Protocol.",
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/cli",
"version": "0.10.1",
"version": "0.10.2",
"license": "MIT",
"exports": "./src/deno.ts",
"imports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/cli",
"version": "0.10.1",
"version": "0.10.2",
"description": "The xmd command-line interface for executable.md.",
"bin": {
"xmd": "./src/node.ts"
Expand Down
42 changes: 38 additions & 4 deletions packages/cli/src/authorship-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
* network capability either. It decides what to write; it writes nothing.
*/

import { ensure, Err, Ok, scoped, until } from "effection";
import type { Operation, Result } from "effection";
import { ensure, Err, Ok, scoped, until, useScope } from "effection";
import type { Operation, Result, Scope } from "effection";
import { createHash } from "node:crypto";
import { mkdir, readdir, rmdir } from "node:fs/promises";
import { homedir } from "node:os";
Expand Down Expand Up @@ -143,6 +143,12 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation<R
);
}

// Taken before the profile's own scope, because putting this build's adapter
// on disk is host work and the profile refuses a command to everything inside
// it. The refusals are installed on the scope below, so this one still answers
// with the capabilities the entrypoint gave this invocation.
const host = yield* useScope();

return yield* scoped(function* (): Operation<Result<string>> {
// First, and before anything is built: this session's directory is claimed,
// established and proven empty, or the command stops here. Nothing has been
Expand All @@ -158,7 +164,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation<R
yield* refuseDocumentCapabilities();
yield* profile.installElicitation();

const acpx = createAcpxProvider(authorshipCeiling(profile, workdir));
const acpx = createAcpxProvider(authorshipCeiling(profile, workdir, host));
yield* registerAgentProvider("acpx", acpx);
const options = {
defaultAgent: profile.stack.defaultAgent,
Expand Down Expand Up @@ -262,16 +268,28 @@ function validator(profile: AuthorshipProfile): IdentityComponent {
* the approved Plan. A ceiling built without them resolved Codex and Claude
* through ACPX's published pins and could reach neither (#672).
*
* Putting one on disk runs `npm install`, which is the one thing this profile
* refuses to everything inside it. So preparation runs in `host` β€” the scope
* this command was called in, which the refusals below were never installed on.
* The distinction is the whole of it: the document decides what to write and may
* run nothing, while the host installs the adapter it was always going to launch.
*
* Exported for the suite that pins exactly that: what a provider is built from
* is not observable through a provider, and a case that could only watch a turn
* fail would be reading a live agent's machine rather than this host's decision.
*/
export function authorshipCeiling(
profile: AuthorshipProfile,
workdir: string,
host: Scope,
): AcpxProviderDependencies {
const assembly = hostAcpDependencies(profile.stack);
const prepare = assembly.prepareAgent;
return {
...hostAcpDependencies(profile.stack),
...assembly,
...(prepare === undefined
? {}
: { prepareAgent: (agentName: string) => inScope(host, () => prepare(agentName)) }),
...profile.acp,
// deno-lint-ignore require-yield
*agentCwd() {
Expand All @@ -283,6 +301,22 @@ export function authorshipCeiling(
};
}

/**
* Run one operation in a scope this one is nested inside, and wait for it there.
*
* The wait is what makes it this operation's: a task created in an outer scope
* outlives the caller by construction, so the halt is registered before the wait
* and an ended command takes the work with it rather than leaving an install
* running under a conversation that is over.
*/
function* inScope<T>(scope: Scope, operation: () => Operation<T>): Operation<T> {
return yield* scoped(function* () {
const task = scope.run(operation);
yield* ensure(() => task.halt());
return yield* task;
});
}

/**
* What the assistant session is told once, before it is asked anything.
*
Expand Down
89 changes: 87 additions & 2 deletions packages/cli/tests/agent-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import { exists } from "@effectionx/fs";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { API } from "@executablemd/runtime";
import { API, exec } from "@executablemd/runtime";
import { createEmbeddedAdapters } from "@executablemd/acp/embedded-adapters";
import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters";
import { useScope } from "effection";
import type { Operation } from "effection";

import {
Expand All @@ -33,6 +34,9 @@ import {
import type { AgentStack } from "../src/agent-stack.ts";
import { authorshipCeiling } from "../src/authorship-profile.ts";
import type { AuthorshipProfile, CandidateAssessment } from "../src/authorship-profile.ts";
import { runPlan } from "../src/plan.ts";
import { scanPlanArgs } from "../src/plan-args.ts";
import { AGENT, createPlanHarness, useWorkingDirectory } from "./support/plan-harness.ts";

/** The two agents this build carries a patched snapshot for. */
const EMBEDDED = ["codex", "claude"] as const;
Expand All @@ -46,6 +50,32 @@ function stackWith(adapters: EmbeddedAdapters): AgentStack {
return { provider: "acpx", defaultAgent: "codex", permissionMode: "deny-all", adapters };
}

/** A Plan the profile's validator accepts and the command writes out. */
const PLAN = ['<File path="drafted.txt">the draft ran</File>', ""].join("\n");

const REQUEST = "write a greeting";

/**
* Adapters that carry the scripted agent and install it the way the real ones
* do: by running a command.
*
* The bytes are not the point β€” the capability is. A real snapshot install runs
* `npm install` in a private directory, and this stands in for it so a case can
* observe which scope that command was reached from.
*/
function installingAdapters(prepared: string[]): EmbeddedAdapters {
return {
providers: [AGENT],
identity: () => `test-embedded:${AGENT}`,
executablePath: () => join(tmpdir(), "never-written", "index.js"),
command: () => `${AGENT}-cmd`,
*materialize(provider: string): Operation<void> {
prepared.push(provider);
yield* exec({ command: ["npm", "install"] });
},
};
}

/** The profile `xmd plan` builds its ceiling from, with nothing else supplied. */
function profileWith(stack: AgentStack): AuthorshipProfile {
return {
Expand Down Expand Up @@ -105,7 +135,7 @@ describe("Tier AE β€” embedded adapters on the run and plan paths", () => {
const root = adapterRoot();
const adapters = createEmbeddedAdapters(root);
const stack = stackWith(adapters);
const ceiling = authorshipCeiling(profileWith(stack), join(root, "workdir"));
const ceiling = authorshipCeiling(profileWith(stack), join(root, "workdir"), yield* useScope());
const registry = ceiling.agentRegistry;
if (registry === undefined) {
throw new Error("the plan path handed its provider no agent registry");
Expand Down Expand Up @@ -136,6 +166,61 @@ describe("Tier AE β€” embedded adapters on the run and plan paths", () => {
expect(yield* exists(root)).toBe(false);
});

it("AE6: the plan profile prepares its adapter through the host, not the document", function* () {
yield* useWorkingDirectory(function* (dir, authorshipRoot) {
// The command an install runs, answered here rather than spawned. What the
// case is about is which capability the preparation reaches, and a real
// `npm install` would answer that question with a subprocess.
//
// At `min`, so it is the weakest thing in the chain: the profile's own
// refusal is installed at the default strength and still wins wherever it
// applies. A recorder that outranked it would answer for the refused call
// too, and the case would pass against the defect.
const commands: string[][] = [];
yield* API.Process.around(
{
// deno-lint-ignore require-yield
*exec([options]) {
commands.push([...options.command]);
return { exitCode: 0, stdout: "", stderr: "" };
},
},
{ at: "min" },
);

const prepared: string[] = [];
const harness = createPlanHarness({ authorshipRoot });
harness.fake.script({ reply: PLAN });
harness.script({ decision: "Approve" });

const argv = ["plan", REQUEST];
const code = yield* runPlan(
{
argv,
scan: scanPlanArgs(argv),
include: [dir],
output: join(dir, "plan.md"),
run: false,
stack: { ...stackWith(installingAdapters(prepared)), defaultAgent: AGENT },
},
harness.deps,
);

// The profile refuses a command to everything inside it, and putting this
// build's adapter on disk runs one. Preparation therefore happens in the
// scope the command was called in β€” the defect that made a real
// `xmd plan` end with "asked for a command, which the authorship profile
// grants to nothing" before any turn.
// Once per agent resolution β€” the document resolves one several times, and
// preparing an agent already prepared is defined to be harmless.
expect(prepared.length).toBeGreaterThan(0);
expect([...new Set(prepared)]).toEqual([AGENT]);
expect(commands).toEqual(prepared.map(() => ["npm", "install"]));
expect(code).toBe(0);
expect(yield* exists(join(dir, "plan.md"))).toBe(true);
});
});

it("AE5: a settled stack carries this host's own adapter root", function* () {
yield* API.Env.around({
// deno-lint-ignore require-yield
Expand Down
2 changes: 1 addition & 1 deletion packages/code-review-agent/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/code-review-agent",
"version": "0.10.1",
"version": "0.10.2",
"exports": {
".": "./mod.ts"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/code-review-agent/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/code-review-agent",
"version": "0.10.1",
"version": "0.10.2",
"description": "Parsers that turn git diff and Oxlint output into typed structures for executable.md reviews.",
"type": "module",
"exports": "./mod.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/core",
"version": "0.10.1",
"version": "0.10.2",
"exports": {
".": "./mod.ts",
"./host": "./host.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/core",
"version": "0.10.1",
"version": "0.10.2",
"description": "Core engine that evaluates executable.md documents.",
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/durable-streams/deno.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@executablemd/durable-streams",
"version": "0.10.1",
"version": "0.10.2",
"exports": "./mod.ts"
}
2 changes: 1 addition & 1 deletion packages/durable-streams/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/durable-streams",
"version": "0.10.1",
"version": "0.10.2",
"description": "Durable, replayable event streams for executable.md.",
"type": "module",
"exports": "./mod.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/runtime",
"version": "0.10.1",
"version": "0.10.2",
"exports": {
".": "./mod.ts",
"./files": "./files.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@executablemd/runtime",
"version": "0.10.1",
"version": "0.10.2",
"description": "Runtime host APIs for executable.md documents.",
"type": "module",
"exports": {
Expand Down
Loading
Loading