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
2 changes: 1 addition & 1 deletion extras/codework-sandbox-vercel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"effect": "4.0.0-beta.107"
},
"peerDependencies": {
"@codeworksh/harness": "*",
"@codeworksh/harness": "workspace:*",
"effect": "4.0.0-beta.107"
},
"engines": {
Expand Down
6 changes: 5 additions & 1 deletion extras/codework-sandbox-vercel/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"rewriteRelativeImportExtensions": true
"rewriteRelativeImportExtensions": true,
"paths": {
"@codeworksh/harness/sandbox": ["../../packages/harness/dist/pack/sandbox.d.mts"]
},
"noEmitOnError": true
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"release:harness:dry": "pnpm --filter @codeworksh/harness run release:dry"
},
"devDependencies": {
"@codeworksh-test/codework-sandbox-vercel": "workspace:*",
"@effect/tsgo": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
Expand Down
42 changes: 41 additions & 1 deletion packages/codework/src/cli/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Runner } from "@codeworksh/harness/effect";
import { Plugin, Runner } from "@codeworksh/harness/effect";
import { SandboxProvider } from "@codeworksh/harness/sandbox";
import { Duration, Effect, Schema } from "effect";
import { writeError } from "./output.ts";
Expand Down Expand Up @@ -26,6 +26,8 @@ const isModelCatalogError = Schema.is(Runner.ModelCatalogError);
const isModelNotFoundError = Schema.is(Runner.ModelNotFoundError);
const isLLMStreamError = Schema.is(Runner.LLMStreamError);
const isSandboxProviderError = Schema.is(SandboxProvider.SandboxProviderError);
const isPluginPreparationError = Schema.is(Plugin.PreparationError);
const isPluginInstallError = Schema.is(Plugin.InstallError);

const providerCategory = (reason: Runner.ProviderFailureReason): string => {
switch (reason._tag) {
Expand Down Expand Up @@ -107,6 +109,25 @@ const unknownMessage = (error: unknown): string => {
return "the command failed for an unknown reason";
};

/**
* What the reader can do about a reference that failed to prepare. A plugin list is
* usually hand-written in a settings file, so the phase is worth translating.
*/
const pluginHint = (phase: Plugin.PreparationError["phase"]): string => {
switch (phase) {
case "source":
return "check the spelling; a path entry starts with `./`, `../`, `~/`, or `/`, and anything else is a package";
case "install":
return "check the package name and version, and that the registry is reachable";
case "import":
return "the module failed to load; import it directly to see its own error";
case "definition":
return "a plugin module must default-export one object with a `setup` and a `vendor.domain.name` id";
case "resolve":
return "the selection enables an ID that no entry defines; check for a typo or a missing source";
}
};

/** Render typed SDK errors for humans without exposing Effect causes or provider payloads. */
export const renderError = (error: unknown): string => {
if (isInvalidInputError(error)) {
Expand All @@ -130,6 +151,25 @@ export const renderError = (error: unknown): string => {
].join("\n") + "\n"
);
}
if (isPluginPreparationError(error)) {
return (
[
`error[plugin]: failed to prepare plugin "${error.reference}"`,
`phase: ${error.phase}`,
...(error.id === undefined ? [] : [`id: ${error.id}`]),
`detail: ${unknownMessage(error.cause)}`,
`hint: ${pluginHint(error.phase)}`,
].join("\n") + "\n"
);
}
if (isPluginInstallError(error)) {
return (
[
`error[plugin_install]: ${unknownMessage(error.cause)}`,
"hint: check the package name and version, and that the registry is reachable",
].join("\n") + "\n"
);
}
if (isProviderError(error)) {
const hint = providerHint(error);
return (
Expand Down
2 changes: 1 addition & 1 deletion packages/codework/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe("codework CLI", () => {
expect(result.status).toBe(0);
expect(result.stdout).toContain("--sandbox string");
expect(result.stdout).toContain("default: local");
expect(result.stdout).toContain("Registered sandbox driver");
expect(result.stdout).toContain("Sandbox driver for a new session");
expect(result.stdout).toContain("--sandbox-provider-id string");
});

Expand Down
44 changes: 43 additions & 1 deletion packages/codework/test/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* @effect-diagnostics cryptoRandomUUID:off -- fixtures only need distinct message IDs. */
import { Message } from "@codeworksh/aikit";
import { describe, expect, it } from "vite-plus/test";
import { Runner } from "@codeworksh/harness/effect";
import { Plugin, Runner } from "@codeworksh/harness/effect";
import { SandboxProvider } from "@codeworksh/harness/sandbox";
import { renderError } from "../src/cli/error.ts";
import { addUsage, emptyUsage, header, usage } from "../src/cli/output.ts";
Expand Down Expand Up @@ -104,6 +104,48 @@ describe("CLI output", () => {
expect(output).not.toContain("Runner.ProviderError");
});

it("renders a plugin preparation failure with its reference and a phase hint", () => {
const output = renderError(
new Plugin.PreparationError({
phase: "source",
index: 3,
reference: "codework-acme-plugn",
cause: new Error("Unsupported plugin package source: codework-acme-plugn"),
}),
);

expect(output).toContain('error[plugin]: failed to prepare plugin "codework-acme-plugn"');
expect(output).toContain("phase: source");
expect(output).toContain("detail: Unsupported plugin package source: codework-acme-plugn");
expect(output).toContain("hint: check the spelling;");
// The bare tag is what a settings typo used to print on its own.
expect(output).not.toContain("PluginPreparationError");
});

it("names the unknown ID when a selection enables one nothing defines", () => {
const output = renderError(
new Plugin.PreparationError({
phase: "resolve",
index: 2,
reference: "acme.tool.missing",
id: "acme.tool.missing",
cause: new Error("Unknown plugin ID: acme.tool.missing"),
}),
);

expect(output).toContain("id: acme.tool.missing");
expect(output).toContain("hint: the selection enables an ID that no entry defines");
});

it("renders a plugin install failure without the tag", () => {
const output = renderError(
new Plugin.InstallError({ cause: new Error("pnpm installation failed with exit code 1") }),
);

expect(output).toContain("error[plugin_install]: pnpm installation failed with exit code 1");
expect(output).not.toContain("PluginInstallError");
});

it("renders the sanitized sandbox provider failure", () => {
const output = renderError(
new SandboxProvider.SandboxProviderError({
Expand Down
67 changes: 67 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,73 @@ The initial public surface is the Effect SDK at `@codeworksh/harness/effect`.
- **Model Flexibility:** select any provider and model available in Aikit's generated catalog, including its supported thinking levels.
- **Pluggable Sandboxes:** run the same workflow against the host machine, a virtual filesystem, or a remote sandbox.

## Plugins

Pass an ordered plugin list when constructing the harness. Each plugin has a required ID and contributes tools or a prompt during setup. Setup runs once per exchange, after model resolution; its tools, hooks, and prompt remain pinned through tool continuations.

```ts
import { Effect, Schema } from "effect";
import { Harness, Plugin, Tool } from "@codeworksh/harness/effect";

const echo = Plugin.define({
id: "acme.tool.echo",
setup(ctx) {
ctx.plugin.tools.add(
Tool.register(
Tool.make({
name: "echo",
description: "Echo a message",
parameters: Schema.Struct({ text: Schema.String }),
success: Schema.String,
handler: ({ text }) => Effect.succeed(text),
}),
),
{
beforeToolCall(call) {
// Arguments have already been decoded. Return { block: true, reason: "..." }
// to skip this handler and its after hook.
},
afterToolCall({ terminal }) {
// Completed/error results can be patched through content, details, isError.
// Aborted results are observation-only; keep cancellation cleanup short.
},
},
);
},
});

const runtime = Harness.layer({
plugins: ["codework.tool.bash", echo, "codework.prompt.default"],
});
```

Hooks belong to the tool registration. Sequential or parallel scheduling, selected with `Session.create({ tools: { execution: "parallel" } })`, covers the entire hook/handler pipeline. After runs for a started, interrupted tool if it has not already started, with a one-second cooperative cleanup grace period. The kernel owns result settlement.

`ctx.plugin.tools.update(name, patch)` rewrites a registration's model-facing prose without replacing the tool or its hooks. A read sees only earlier contributions, so a plugin patching `promptSnippet` or `promptGuidelines` must run _before_ the prompt plugin that indexes them — after it, the patch still reaches the wire description but no longer the system prompt.

Prompt plugins use `ctx.plugin.prompt.get()` and `set(string)`. Each `set` replaces the entire prompt, including with an empty string. Place a prompt plugin after the tools or prompt contributors it needs. Contributions close after setup; plugins receive event publication but no subscription or background lifecycle.

Omitting `plugins` selects Bash then the default prompt, followed by the host settings' `plugins` block. An explicit array replaces all of that, and an empty one runs nothing — which is not a usable harness: `freeze` requires a system prompt, so a selection without a prompt plugin fails every exchange with `SnapshotError("no prompt plugin set a system prompt")`. Every working selection ends with a prompt plugin, whether `codework.prompt.default` or your own. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/[email protected]`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position.

Settings entries are strings only, and they extend the built-in selection instead of standing in for it, so naming one plugin cannot silently drop Bash or the prompt:

```jsonc
// codework.json, ~/.codework/settings.json, or a --user-config-dir
{ "plugins": ["codework-acme-plugin", "@acme/[email protected]", "./plugins/local.ts", "!codework.tool.bash"] }
```

The array replaces across settings layers rather than concatenating, so the highest-priority file that names `plugins` owns the whole list. A leading `~` expands to the home directory. A `./` or `../` path resolves against the directory of the file that declared it — next to `codework.json`, inside `.codework/`, or beside `~/.codework/settings.json` — so one entry means one file in every project. IDs, `!id` disables, `file:` URLs, and package specs are taken as written.

Entries append after the built-ins, so a tool plugin added here registers after `codework.prompt.default` rendered its index — the usual ordering rule, not a special case for settings. Its tool reaches the provider with its own description but is absent from the system prompt's list. Re-list the prompt plugin to move it, since the last occurrence of an ID owns its position:

```jsonc
{ "plugins": ["./plugins/read.ts", "codework.prompt.default"] }
```

Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. The installer inherits stderr, so a first install prints pnpm's own progress and errors to the terminal — and a `plugins` entry in a settings file means that can happen during `Harness.layer` construction, before any session exists. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. The selection is read once per `Harness.layer`, so an edited `plugins` block applies at the next construction; hot reload and daemon lifecycles are not implemented.

Failures are attributed: a bad reference, unreadable module, or malformed plugin fails `Harness.layer` construction with `PluginPreparationError`, which carries the failing phase (`source`, `install`, `import`, or `definition`) and the index of the offending reference. A failing package install reports `PluginInstallError`; a plugin's `setup` failure becomes `Plugin.SetupError` with the plugin id, surfacing as a `SnapshotError` for that exchange. Plugins are trusted in-process code — local paths and `file:` references import whatever they point at, so only load sources you trust.

## Pluggable Sandboxes

Harness uses a driver-based sandbox architecture. Drivers share a common lifecycle and I/O surface, keeping provider details out of session and agent-loop code.
Expand Down
6 changes: 4 additions & 2 deletions packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,25 +74,27 @@
"test": "vp test",
"test:sandbox:package": "node test/scripts/codework-sandbox-vercel-smoke.mjs",
"test:sandbox:package:e2e": "node test/scripts/codework-sandbox-vercel-smoke.mjs --e2e",
"test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts",
"test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts test/tools.registry.remote.e2e.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@codeworksh/aikit": "workspace:*",
"@effect/platform-node": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/sql-sqlite-node": "4.0.0-beta.107",
"@platformatic/vfs": "^0.4.0",
"effect": "4.0.0-beta.107",
"just-bash": "^2.14.5",
"npm-package-arg": "^14.0.0",
"tslib": "^2.8.1",
"typebox": "^1.3.10",
"uuid": "^14.0.1",
"uuidv7": "^1.2.1"
},
"devDependencies": {
"@codeworksh-test/codework-sandbox-vercel": "workspace:*",
"@daytona/sdk": "^0.187.0",
"@types/node": "^25.9.5",
"@types/npm-package-arg": "^6.1.4",
"@vercel/sandbox": "^2.9.2",
"bumpp": "^12.2.0",
"dedent": "^1.7.2"
Expand Down
5 changes: 3 additions & 2 deletions packages/harness/src/effect.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export { Harness } from "./effect/harness.ts";
export { Sandbox } from "./effect/sandbox.ts";
export { Session } from "./effect/session.ts";
export { Tools } from "./effect/tools.ts";
export { EventList } from "./event/list.ts";
export { EventSchema } from "./event/schema.ts";
export { Runner } from "./runner/run.ts";
export * as Tool from "./tools/tool.ts";
export * as Tool from "./tool/tool.ts";

export { Settings } from "./settings/settings.ts";

export * as Plugin from "./plugin/index.ts";
37 changes: 31 additions & 6 deletions packages/harness/src/effect/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Control } from "../control.ts";
import { Database } from "../db/db.ts";
import { Event } from "../event/event.ts";
import { Global } from "../global.ts";
import { prepare, type PluginRef } from "../plugin/catalog.ts";
import { builtins, defaultRefs } from "../plugin/internal.ts";
import { RunnerExecute } from "../runner/execute.ts";
import { LLM } from "../runner/llm.ts";
import { Loop } from "../runner/loop.ts";
Expand All @@ -19,6 +21,12 @@ import { Settings } from "../settings/settings.ts";
import { State } from "../state/state.ts";

export interface Options {
/**
* The complete selection, replacing both the built-ins and whatever `settings.plugins`
* asks for -- an embedder that passes this owns the plugin set, and an empty array runs
* none. Omit it to get the built-ins plus the settings block.
*/
readonly plugins?: ReadonlyArray<PluginRef>;
readonly database?: string;
readonly home?: string;
/** user provided directory containing the highest-priority config. */
Expand All @@ -31,10 +39,29 @@ export const layer = (options: Options = {}) =>
Layer.unwrap(
Effect.gen(function* () {
const paths = yield* Global.resolve(options.home === undefined ? {} : { home: options.home });
const configuredDatabase = options.database ?? (yield* Database.locationConfig);
// The single sanctioned `process.cwd()` in the harness. Everything downstream
// takes the host directory as a required parameter, so no module can quietly
// fall back to the OS process's directory when it meant a session's mount.
const hostCwd = process.cwd();
const global = Global.layerWith(paths);
const settingsOptions = {
cwd: hostCwd,
...(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }),
};
// Settings are read once here because the plugin selection has to be prepared before
// any layer that depends on it; every later read goes through `Settings.Service`.
const config = yield* Settings.load({ ...settingsOptions, home: paths.home });
// Settings entries extend the built-in selection rather than standing in for it, so
// naming a plugin cannot silently drop Bash or the default prompt. A built-in is turned
// off the same way as any other plugin, with a `!codework.tool.bash` entry.
const plugins = yield* prepare(options.plugins ?? [...defaultRefs, ...config.plugins], {
builtins,
cache: paths.cache,
hostCwd,
});
const configuredDatabase = options.database ?? (yield* Database.locationConfig);
const database = Database.layer(Database.resolveDatabaseLocation(configuredDatabase, paths.data));
const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? []);
const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? [], { hostCwd });
const drivers = SandboxDriverRegistry.layer(
SandboxDriver.withSource(MemorySandboxDriver.make().driver, "core"),
SandboxDriver.withSource(SqldbSandboxDriver.make().driver, "core"),
Expand All @@ -45,10 +72,8 @@ export const layer = (options: Options = {}) =>

return Control.layer.pipe(
Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))),
Layer.provideMerge(State.layer()),
Layer.provideMerge(
Settings.layer(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }),
),
Layer.provideMerge(State.layer({}, plugins)),
Layer.provideMerge(Settings.layer(settingsOptions)),
Layer.provideMerge(SessionRuntime.layer),
Layer.provideMerge(sandboxes),
Layer.provideMerge(Context.layer),
Expand Down
Loading