Skip to content
Open
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: 20 additions & 0 deletions bun.lock

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

1 change: 1 addition & 0 deletions packages/hosts/eve/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @executor-js/host-eve
105 changes: 105 additions & 0 deletions packages/hosts/eve/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# @executor-js/host-eve

Expose Executor's tool catalog to a [Vercel **eve**](https://vercel.com/eve) agent.

eve agents discover one typed tool per file under `agent/tools/*.ts`. Executor's
catalog is large by design (discover-by-intent, not one tool per API), so rather
than generating hundreds of tool files, this host mirrors the Executor MCP host:
it surfaces Executor's **codemode** surface as two tools the model drives directly.

- **`execute`** runs TypeScript against Executor's sandboxed tools runtime
(`tools.search(...)`, `tools.describe.tool(...)`, `tools.github.issues.list(...)`).
- **`resume`** answers an auth / approval / form pause raised mid-execution, using
the `executionId` the paused `execute` result returned.

This package never imports `eve` at runtime. The factory returns plain objects
shaped to satisfy eve's `defineTool`, so eve stays a peer of your agent project,
not a dependency of this host.

## Usage

Build the engine once and share it across both tools (a paused execution lives in
that engine instance's memory, so `execute` and `resume` must come from the same
engine).

```ts
// agent/executor.ts
import { createExecutionEngine } from "@executor-js/execution/promise";
import { createExecutorEveTools } from "@executor-js/host-eve";

// `executor` (an Executor client) and `codeExecutor` (a sandbox runtime, e.g.
// the QuickJS code executor) are wired the same way the CLI / local app do it.
import { executor, codeExecutor } from "./runtime.ts";

const engine = createExecutionEngine({ executor, codeExecutor });

// Top-level await: the `execute` description (workflow + configured namespaces)
// is read from the engine once and baked in before eve compiles the manifest.
export const executorTools = await createExecutorEveTools({ engine });
```

```ts
// agent/tools/execute.ts
import { defineTool } from "eve/tools";
import { executorTools } from "../executor.ts";

export default defineTool(executorTools.execute);
```

```ts
// agent/tools/resume.ts
import { defineTool } from "eve/tools";
import { executorTools } from "../executor.ts";

export default defineTool(executorTools.resume);
```

The model now writes Executor codemode in `execute`, and when a call needs OAuth
or an approval it gets back an `executionId` and calls `resume`.

## Config

`createExecutorEveTools(config)` accepts either:

- `{ engine }`: a pre-built `@executor-js/execution/promise` engine (recommended;
lets you share one engine and its trace context), or
- `{ executor, codeExecutor }`: the pieces, and the factory builds the engine.

Plus optional:

- `description`: override the `execute` tool description. When omitted, the
dynamic description is read from `engine.getDescription()` and baked in.
- `onDefect(error, correlationId)`: called when a tool body throws an unexpected
defect. Defaults to `console.error`. The model only ever sees an opaque
`Internal tool error [id]`; the cause is logged out-of-band so it can't leak
internal context through the tool result.

## Return shape

Every tool resolves to an `ExecutorToolEnvelope`:

```ts
{
status: string;
text: string;
data: Record<string, unknown>;
}
```

`text` is the model-facing render; `data` is the full structured payload kept for
eve Agent Runs and `outputSchema` consumers. `toModelOutput` projects the
envelope down to `{ type: "text", value: text }` so the model reads only `text`.

## Approval

Executor's own pause/resume **is** the human-in-the-loop mechanism: a sensitive
call pauses mid-execution and `resume` continues it. If you also want a pre-call
gate on the whole `execute` tool, add eve's native approval in your tool file:

```ts
import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { executorTools } from "../executor.ts";

export default defineTool({ ...executorTools.execute, needsApproval: always() });
```
33 changes: 33 additions & 0 deletions packages/hosts/eve/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@executor-js/host-eve",
"version": "0.1.0",
"private": true,
"description": "Expose Executor's tool catalog to a Vercel eve agent as codemode execute/resume tools.",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@executor-js/codemode-core": "workspace:*",
"@executor-js/execution": "workspace:*",
"effect": "catalog:",
"zod": "4.3.6"
},
"devDependencies": {
"@effect/vitest": "catalog:",
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@types/node": "catalog:",
"bun-types": "catalog:",
"vitest": "catalog:"
}
}
85 changes: 85 additions & 0 deletions packages/hosts/eve/src/index.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import { createExecutor, definePlugin } from "@executor-js/sdk";
import { makeTestConfig } from "@executor-js/sdk/testing";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
import { createExecutionEngine } from "@executor-js/execution";
import { toPromiseExecutionEngine } from "@executor-js/execution/promise";

import { createExecutorEveTools } from "./index";

// ---------------------------------------------------------------------------
// Integration: drive the eve tools against the REAL execution stack (real
// QuickJS sandbox + real Executor), not a stubbed engine. Proves the adapter
// actually runs model-authored TypeScript and surfaces real results/errors.
//
// `createExecutor` requires a Scope, so these run as `it.effect`: the executor
// is acquired in the test scope and the adapter's Promise API is bridged back
// with `Effect.promise`.
// ---------------------------------------------------------------------------

const codeExecutor = makeQuickJsExecutor();

const emptyPlugin = definePlugin(() => ({
id: "empty-eve-test" as const,
storage: () => ({}),
staticSources: () => [],
}));

const buildTools = Effect.gen(function* () {
const executor = yield* createExecutor(makeTestConfig({ plugins: [emptyPlugin()] as const }));
const engine = toPromiseExecutionEngine(createExecutionEngine({ executor, codeExecutor }));
return yield* Effect.promise(() => createExecutorEveTools({ engine }));
});

describe("integration: real engine + QuickJS sandbox", () => {
it.effect("evaluates real TypeScript and returns the result", () =>
Effect.gen(function* () {
const tools = yield* buildTools;
const out = yield* Effect.promise(() => tools.execute.execute({ code: "return 1 + 1" }));

expect(out.status).toBe("completed");
expect(out.data.result).toBe(2);
expect(out.text).toContain("2");
}),
);

it.effect("injects the Executor tools runtime into the sandbox", () =>
Effect.gen(function* () {
const tools = yield* buildTools;
// `tools` is the runtime surface the model drives; proving it exists in
// the sandbox confirms the adapter wired the real engine, not bare JS.
const out = yield* Effect.promise(() =>
tools.execute.execute({ code: "return typeof tools.search" }),
);

expect(out.status).toBe("completed");
expect(out.data.result).toBe("function");
}),
);

it.effect("surfaces a real runtime error as an error envelope (never throws)", () =>
Effect.gen(function* () {
const tools = yield* buildTools;
const out = yield* Effect.promise(() =>
tools.execute.execute({ code: "return missingReference" }),
);

expect(out.status).toBe("error");
expect(out.text.toLowerCase()).toContain("error");
}),
);

it.effect("resume against the real engine reports an unknown execution", () =>
Effect.gen(function* () {
const tools = yield* buildTools;
const out = yield* Effect.promise(() =>
tools.resume.execute({ executionId: "exec_nope", action: "accept", content: "{}" }),
);

expect(out.status).toBe("execution_not_found");
expect(out.data).toMatchObject({ executionId: "exec_nope", recovery: "re_execute" });
}),
);
});
Loading