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 packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ A plugin is installed into its own directory, so its Effect is a separate module

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.
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 turn. Plugins are trusted in-process code — local paths and `file:` references import whatever they point at, so only load sources you trust.

## Pluggable Sandboxes

Expand Down
8 changes: 4 additions & 4 deletions packages/harness/src/effect/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ export const layer = (options: Options = {}) =>
// has none. Boot reads the user layer and the explicit override only -- a server can
// start anywhere, so it must never walk ancestors of the directory it happened to
// launch in for a `.codework/`, which would hand it a stranger's project settings.
// A project's plugins still load: lazily, at the session's first exchange, through
// A project's plugins still load: lazily, at the session's first turn, through
// `follow`.
const config = yield* Settings.load({ ...settingsOptions, home: paths.home });
/*
* Which entries this process configures from, per exchange.
* Which entries this process configures from, per turn.
*
* 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
Expand Down Expand Up @@ -122,7 +122,7 @@ export const layer = (options: Options = {}) =>
/*
* What the store already holds for a reference, and nothing else.
*
* Resolve-only by construction: `Store.resolve` never opens a socket, so an exchange
* Resolve-only by construction: `Store.resolve` never opens a socket, so a turn
* asking this question cannot block on a registry, and `plugin install` stays the only
* verb that puts bytes on disk. A reference that does not parse, or names a local
* path, is simply not filed.
Expand Down Expand Up @@ -158,7 +158,7 @@ export const layer = (options: Options = {}) =>
const resolveOnly = { ...catalogOptions, install: PluginStore.required };

// The load pass, once, at boot: a store lookup and an ESM import per module, never a
// fetch. Every later exchange runs only the config pass over what this produced.
// fetch. Every later turn runs only the config pass over what this produced.
const pool = yield* Ref.make<Pool>(
yield* load(references(config), { ...resolveOnly, declared: declaredIn(config) }),
);
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/event/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export const PluginUpdated = EventSchema.define({
file: optional(Schema.String),
error: optional(Schema.String),
/**
* The session whose exchange triggered the event, when one did -- `failed` from a
* The session whose turn triggered the event, when one did -- `failed` from a
* session's own `follow` names it. Absent on `reload` and boot, which no session owns:
* the pool is process-wide, so this is the trigger, never an attribution of ownership.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/plugin/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { writePlugin } from "./builtin/tool/write.ts";
/**
* Every plugin the harness ships, and the selection a caller who passes no `plugins` gets. Tool
* contributors come first; within the prompt domain, declaration order is run order. The search
* tools (grep, find, ls) are `optIn`, as Pi ships them off: settings turn each on with
* tools (grep, find, ls) are `optIn` and ship off: settings turn each on with
* `{ "plugin": "codework.tool.grep", "enabled": true }`.
*/
export const builtins = Object.freeze([
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/plugin/builtin/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const readDef = Tool.define({
const fail = (message: string) => Effect.fail(new ReadFailed({ message }));

/**
* Filesystem failures are the model's to see, as Pi shows them: a missing path in
* Filesystem failures are the model's to see: a missing path in
* one wording on every backend, anything else (EACCES, …) as the backend reported it.
*/
const fsFailure = (path: string) => (error: SandboxFileSystem.FileSystemError) =>
Expand Down
20 changes: 10 additions & 10 deletions packages/harness/src/plugin/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ const emptyPool: Pool = {
/**
* The **load pass**: store lookup and ESM import, never the network.
*
* Runs at boot, at `reload`, and at any exchange that finds a reference it cannot satisfy. It
* Runs at boot, at `reload`, and at any turn that finds a reference it cannot satisfy. It
* deliberately knows nothing about `enabled`, options or order -- those are data over modules that
* are already loaded, they cost a walk of a short array, and they belong to every exchange.
* are already loaded, they cost a walk of a short array, and they belong to every turn.
*
* Loading is keyed by what a reference resolves to, so naming one module twice imports it once.
*/
Expand Down Expand Up @@ -186,7 +186,7 @@ export interface Selected {
}

/**
* The **config pass**: pure data over modules that are already loaded, run at every exchange.
* The **config pass**: pure data over modules that are already loaded, run at every turn.
*
* An array entry is a **module** — a definition object, a path, a `file:` URL, or a package
* spec. It takes the position it is written at, so naming a module again moves it.
Expand Down Expand Up @@ -285,9 +285,9 @@ export const select = Effect.fn("PluginCatalog.select")(function* (references: R
});

/**
* The exchange-boundary check: has the store moved under this pool?
* The turn-boundary check: has the store moved under this pool?
*
* Every exchange already re-reads every settings layer from disk, and the config pass already
* Every turn already re-reads every settings layer from disk, and the config pass already
* walks those entries against the loaded set. Two things can be settled right there, and they are
* the same question asked twice:
*
Expand All @@ -305,15 +305,15 @@ export const select = Effect.fn("PluginCatalog.select")(function* (references: R
*
* **This is not a watcher.** Nothing polls and no fiber exists; it is the observation that we were
* already paying for the I/O that would tell us, and discarding the answer. Three bounds keep it
* safe: the store only and never the network, so an exchange cannot block on a registry;
* resolve-only, so it cannot install; and once, at the top of a snapshot, so an exchange already
* safe: the store only and never the network, so a turn cannot block on a registry;
* resolve-only, so it cannot install; and once, at the top of a snapshot, so a turn already
* running is unaffected.
*/
export const follow = Effect.fn("PluginCatalog.follow")(function* (
references: ReadonlyArray<PluginRef>,
pool: Pool,
/**
* `install` is required and must be resolve-only. Making it non-optional is how "an exchange
* `install` is required and must be resolve-only. Making it non-optional is how "a turn
* follows the store, it does not fill it" becomes a fact of the signature rather than a rule
* someone has to remember: the default installer fetches, and there is no way to reach it here.
*/
Expand All @@ -327,7 +327,7 @@ export const follow = Effect.fn("PluginCatalog.follow")(function* (
* exists and covers only it.
*
* `from` is the `.npmrc` anchor the lookup computes the store key under: the project that
* declared the reference, so an exchange answers about the same artifact `plugin install`
* declared the reference, so a turn answers about the same artifact `plugin install`
* filed rather than the one the server's own directory would name.
*/
filed: (reference: string, from: string) => Effect.Effect<Option.Option<{ readonly generation?: number }>>,
Expand Down Expand Up @@ -382,7 +382,7 @@ export const follow = Effect.fn("PluginCatalog.follow")(function* (
//
// A retained origin's source may have vanished since it loaded -- a file deleted, a store
// entry removed. It is nobody's declaration now, so it drops out of the pool with a warning
// rather than failing the exchange for plugins nothing was wrong with. A reference the
// rather than failing the turn for plugins nothing was wrong with. A reference the
// session itself still configures stays strict: the drift check above covered it, and `load`
// still fails on it.
const retained: Origin[] = [];
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/plugin/prompt/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { PromptRegistry } from "./schema.ts";
*
* A selection with no prompt plugin is a legitimate configuration -- an embedder
* passing `plugins: []`, or a user disabling `codework.prompt.default` before
* their own plugin is ready -- and failing the exchange over it buys nothing the
* their own plugin is ready -- and failing the turn over it buys nothing the
* assembled prompt would not already tell them. The real foundation lives in
* `codework.prompt.default`; this is only the floor.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/harness/src/plugin/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
*
* **A generation is immutable.** Installing never mutates a published one: it stages elsewhere and
* renames into place. Three problems that solves, and nothing simpler solves all three -- a
* running exchange holding file URLs into generation *N* is not disturbed by *N+1*; Node's ESM
* running turn holding file URLs into generation *N* is not disturbed by *N+1*; Node's ESM
* registry is keyed by URL and never evicts, so an in-place update would hand back the *old*
* module on re-import; and rollback is a `rename`, because a generation that fails validation
* never gets a marker.
Expand Down Expand Up @@ -341,7 +341,7 @@ export const add = Effect.fn("PluginStore.add")(function* <A>(
* Expire what is superseded.
*
* The two newest generations are kept regardless of age: the newest is live, and the one before it
* may still be imported by an exchange in flight. Runs at the end of an `add` that published
* may still be imported by a turn in flight. Runs at the end of an `add` that published
* something -- no timer and no sweeper, because installs are the only thing that creates garbage.
*/
export const collect = Effect.fn("PluginStore.collect")(function* (directory: string) {
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/runner/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export interface Completed {
/** The snapshot the turn ran on. */
readonly snapshot: State.Snapshot;
readonly messageId: string;
/** `toolUse` means tool results wait for a reply. */
readonly reason: "stop" | "length" | "toolUse";
readonly needsContinuation: boolean;
/**
* What the next run would promote, read without promoting it: the queued steers, or the oldest
* follow-up when there are none. As of the commit; input admitted later is not in it.
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export type Resolve = (
input: ResolutionInput,
) => Effect.Effect<Model.Info, Runner.ModelCatalogError | Runner.ModelNotFoundError | Runner.ProviderError>;

/** Resolve once before exchange setup; execution reuses this exact instance. */
/** Resolve once before turn setup; execution reuses this exact instance. */
export const resolve: Resolve = Effect.fn("LLM.resolve")(function* (input) {
const overrides = resolveOverrides(input.settings ?? {});
const declared = ModelCatalog.entry(input.models ?? [], input.provider, input.model);
Expand Down
38 changes: 12 additions & 26 deletions packages/harness/src/runner/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,8 @@ import { Runner } from "./run.ts";

export type Promotion = "steer" | "followUp" | undefined;

export interface TurnResult {
readonly needsContinuation: boolean;
/** Set with `reason` when the turn committed an assistant message. */
readonly messageId?: string;
readonly reason?: Hooks.Completed["reason"];
}
/** A turn that committed an assistant message; `undefined` when it sent no request. */
export type TurnResult = Pick<Hooks.Completed, "messageId" | "reason"> | undefined;

export interface Options {
/** Deterministic provider seam for tests. */
Expand Down Expand Up @@ -281,14 +277,14 @@ export const layer = (options: Options = {}) =>
const steers = yield* inputs.promoteSteers(sessionId, cutoff);
// The pending input this turn was started for was cancelled before promotion: with no
// earlier work to continue there is nothing to answer, so no provider request.
if (!continuing && !followUp && steers === 0) return { needsContinuation: false } satisfies TurnResult;
if (!continuing && !followUp && steers === 0) return undefined;
}

const assembled = yield* context.assemble(sessionId);
const leaf =
assembled.lastAssistant?.entryId === assembled.leafEntryId ? assembled.lastAssistant.message : undefined;
if (leaf !== undefined && (leaf.stopReason === "stop" || leaf.stopReason === "length")) {
return { needsContinuation: false } satisfies TurnResult;
return undefined;
}

// The latest attempt's publisher; a retried turn has one per provider request.
Expand Down Expand Up @@ -400,11 +396,7 @@ export const layer = (options: Options = {}) =>
committed = true;
});
if (interrupted !== undefined) return yield* Effect.failCause(interrupted);
return {
needsContinuation: terminal.reason === "toolUse",
messageId: terminal.message.messageId,
reason: terminal.reason,
} satisfies TurnResult;
return { messageId: terminal.message.messageId, reason: terminal.reason } satisfies TurnResult;
});

// `onError`, not `catchCause`: a catch handler never runs on an interrupted fiber, and
Expand Down Expand Up @@ -485,28 +477,22 @@ export const layer = (options: Options = {}) =>
// Tool results and steers continue first; a follow-up only once both are exhausted.
while (true) {
const result: TurnResult = yield* runTurn(promotion, snapshot, continuing);
const toolUse = result?.reason === "toolUse";
const completed: Hooks.Completed | undefined =
result.messageId === undefined || result.reason === undefined
result === undefined
? undefined
: {
snapshot,
messageId: result.messageId,
reason: result.reason,
needsContinuation: result.needsContinuation,
pending: yield* peekPending(input.sessionId),
};
: { ...result, snapshot, pending: yield* peekPending(input.sessionId) };
const decision = completed === undefined ? undefined : yield* hooks.finishTurn(completed);
// Queued steers and follow-ups stay for the next run.
if (decision === "end") return;

// A forced continuation after a finished reply sends nothing: the leaf guard in
// `runTurnAttempt` ends the turn unless something was appended after it.
continuing = result.needsContinuation || decision === "continue";
continuing = toolUse || decision === "continue";
const steer = yield* inputs.hasPending(input.sessionId, "steer");
// Only tool results outrank a follow-up. A forced continuation rides along with it, as in
// Pi, where the follow-up is the natural next request and the continuation its fallback.
const followUp =
!result.needsContinuation && !steer && (yield* inputs.hasPending(input.sessionId, "followUp"));
// Only tool results outrank a follow-up. A forced continuation rides along with it: the
// follow-up is the natural next request, and the continuation only its fallback.
const followUp = !toolUse && !steer && (yield* inputs.hasPending(input.sessionId, "followUp"));
if (!continuing && !steer && !followUp) return;
promotion = followUp ? "followUp" : "steer";

Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export const parse = Effect.fn("Settings.parse")(function* (path: string, source

export interface Interface {
/**
* Await fresh host files at each exchange. No cache, mutation, or reload API. Fails when a
* Await fresh host files at each turn. No cache, mutation, or reload API. Fails when a
* file exists and cannot be used, including on an edit made mid-session.
*
* The discovery root is an argument rather than a property of the layer because it is a
Expand Down
Loading
Loading