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
53 changes: 52 additions & 1 deletion src/core/agent-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
TextDelta,
Tool,
} from "./types";
import type { Command, HaltPredicate } from "../jsx/runtime";

const TEXT_DELTA_CAPACITY = 256;

Expand Down Expand Up @@ -135,6 +136,25 @@ export interface AgentCtxService {
// subscribed to `events.changes` — those must call `render` (below)
// to avoid the FRP glitch.
readonly rendered: SubscriptionRef.SubscriptionRef<ProviderContext>;
// Commands emitted by the current JSX projection. Populated each
// render pass from `Rendered.commands`. The slash-command router in
// `agent.run` reads this to dispatch `/<name> ...` inputs to a
// registered handler. Empty when no `contextFn` is configured or no
// `emitCommand` appears in the tree.
readonly commands: SubscriptionRef.SubscriptionRef<ReadonlyArray<Command>>;
// Halt-predicate registry, written by command handlers via their
// CommandRuntime. SubscriptionRef rather than plain Ref because future
// consumers (UI surfacing active goals, the halt-gate fiber in a
// follow-up PR) will subscribe to changes.
readonly haltPredicates: SubscriptionRef.SubscriptionRef<
ReadonlyMap<string, HaltPredicate>
>;
readonly registerHaltPredicate: (
name: string,
fn: HaltPredicate,
) => Effect.Effect<void>;
readonly clearHaltPredicate: (name: string) => Effect.Effect<void>;
readonly getHaltPredicates: Effect.Effect<ReadonlyMap<string, HaltPredicate>>;
// Synchronous render from primary sources (events + ambients +
// transforms + tools). Runs the shaper chain AND the terminal
// adapter, so the result is the same ProviderContext shape the
Expand Down Expand Up @@ -196,6 +216,10 @@ export const make = (
tools: [],
};
const rendered = yield* SubscriptionRef.make<ProviderContext>(emptyContext);
const commands = yield* SubscriptionRef.make<ReadonlyArray<Command>>([]);
const haltPredicates = yield* SubscriptionRef.make<
ReadonlyMap<string, HaltPredicate>
>(new Map());
// Explicit re-render trigger for extension-owned reactive state.
// Bumped by `invalidate`; merged into the render driver below.
const invalidateRef = yield* SubscriptionRef.make(0);
Expand Down Expand Up @@ -377,9 +401,10 @@ export const make = (
rendered = renderedExit.value;
} else {
yield* reportError("context", renderedExit.error);
rendered = { fragments: [], tools: [] };
rendered = { fragments: [], tools: [], commands: [] };
}
yield* reconcileContextTools(rendered.tools);
yield* SubscriptionRef.set(commands, [...rendered.commands]);
fragments = [...rendered.fragments];
} else if (renderer) {
const eventsArr = Chunk.toReadonlyArray(currentEvents);
Expand Down Expand Up @@ -455,6 +480,27 @@ export const make = (
(n) => n + 1,
);

const registerHaltPredicate = (
name: string,
fn: HaltPredicate,
): Effect.Effect<void> =>
SubscriptionRef.update(haltPredicates, (current) => {
const next = new Map(current);
next.set(name, fn);
return next;
});
const clearHaltPredicate = (name: string): Effect.Effect<void> =>
SubscriptionRef.update(haltPredicates, (current) => {
if (!current.has(name)) return current;
const next = new Map(current);
next.delete(name);
return next;
});
const getHaltPredicates: Effect.Effect<ReadonlyMap<string, HaltPredicate>> =
SubscriptionRef.get(haltPredicates).pipe(
Effect.map((m) => new Map(m) as ReadonlyMap<string, HaltPredicate>),
);

const deltasHub = yield* PubSub.sliding<TextDelta>(TEXT_DELTA_CAPACITY);
const textDeltas: Stream.Stream<TextDelta> = Stream.fromPubSub(deltasHub);
const emitTextDelta = (delta: TextDelta): Effect.Effect<void> =>
Expand All @@ -467,6 +513,11 @@ export const make = (
transforms,
errors,
rendered,
commands,
haltPredicates,
registerHaltPredicate,
clearHaltPredicate,
getHaltPredicates,
render,
addTool,
addAmbient,
Expand Down
71 changes: 68 additions & 3 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
Stream,
SubscriptionRef,
} from "effect";
import type { CommandRuntime, HaltPredicate } from "../jsx/runtime";
import { AgentCtx, type AgentErrorEntry, type Renderer } from "./agent-ctx";
import { runHaltGate } from "./halt-gate";
import { runInference } from "./inference";
import { validateProviderContext } from "./validate";
import { PendingSends } from "./pending-sends";
Expand Down Expand Up @@ -158,6 +160,10 @@ export const createAgent = (
: opts.validate ?? validateProviderContext,
});
yield* runToolExecution({ concurrency: opts.toolConcurrency ?? 8 });
// Halt-gate supervisor: runs predicates on each `assistant.halted` and
// appends a synthetic user.message that re-prompts the model when a
// goal is unmet. No-op when no predicates are registered.
yield* runHaltGate(opts.infer);

return ctx;
});
Expand Down Expand Up @@ -270,15 +276,70 @@ export const createAgentRuntime = (opts: AgentOptions): Agent => {
withCtx((ctx) => ctx.events.snapshot.pipe(Effect.map(lastResult)));

const run = (input: unknown): Promise<void> => {
const body: Effect.Effect<void, never, AgentCtx | PendingSends> = Effect.gen(function* () {
const SLASH_RE = /^\/([a-zA-Z_][\w-]*)(?:\s+([\s\S]*))?$/;
const body: Effect.Effect<
Promise<void> | null,
never,
AgentCtx | PendingSends
> = Effect.gen(function* () {
const ctx = yield* AgentCtx;
const pending = yield* PendingSends;

// Slash-command router. Only intercepts when input is a string
// matching `/<ident>(...)`. Looks up the name in the current JSX
// projection's command list; if matched, runs the handler instead
// of appending a user.message / triggering inference. Unknown
// commands fall through with a warning so the model still gets
// the literal text (lets the model explain that the slash command
// isn't registered rather than silently dropping the input).
if (typeof input === "string") {
const m = input.match(SLASH_RE);
if (m) {
const name = m[1]!;
const args = m[2] ?? "";
const cmds = yield* SubscriptionRef.get(ctx.commands);
const cmd = cmds.find((c) => c.name === name);
if (cmd) {
// Build the handler-facing CommandRuntime. Each method
// bridges out of the handler's JS-promise world back into
// Effect via runtime.runPromise. `appendUserMessage` goes
// through ctx.events.append so the log remains the single
// source of truth (no side-channel writes — principle 1
// in src/CLAUDE.md).
const cmdRuntime: CommandRuntime = {
appendUserMessage: (text: string) => {
void runtime.runPromise(
ctx.events.append({ type: "user.message", content: text }),
);
},
registerHaltPredicate: (n: string, fn: HaltPredicate) => {
void runtime.runPromise(ctx.registerHaltPredicate(n, fn));
},
clearHaltPredicate: (n: string) => {
void runtime.runPromise(ctx.clearHaltPredicate(n));
},
};
// Run the handler outside the Effect fiber — handlers are
// user JS that may return a Promise. Return the promise so
// the outer `run` awaits it before resolving.
const result = cmd.handler({ args, runtime: cmdRuntime });
return result instanceof Promise ? result : Promise.resolve();
}
// Unknown command — warn once and fall through to the normal
// user.message path.
console.warn(
`[agentjsx] received slash input "/${name}" but no command with that name is registered; passing through as user.message`,
);
}
}

const evs = yield* ctx.events.snapshot;
if (toolsInFlight(evs)) {
yield* pending.push(input);
return;
return null;
}
yield* ctx.events.append({ type: "user.message", content: input });
return null;
});
// Ensure the agent has finished building before sending — otherwise
// seeded extensions could still be installing and a user message
Expand All @@ -288,7 +349,11 @@ export const createAgentRuntime = (opts: AgentOptions): Agent => {
// resolves only after the append (or pending-sends push) has landed,
// so callers that `await send` can safely subscribe to `until`
// without a stale-replay race.
return built.then(() => runtime.runPromise(body));
return built.then(() =>
runtime.runPromise(body).then((handlerPromise) =>
handlerPromise ? handlerPromise : undefined,
),
);
};

const until = <T>(predicate: (snapshot: AgentSnapshot) => T | null): Promise<T> => {
Expand Down
143 changes: 143 additions & 0 deletions src/core/halt-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Chunk, Effect, Ref, Stream } from "effect";
import { AgentCtx } from "./agent-ctx";
import type { Event, InferFn } from "./types";

// Halt-gate supervisor. Watches the event log for "about to return
// control to the user" states, runs all registered halt predicates,
// and — if any returns `ok: false` — appends a synthetic `user.message`
// whose content concatenates the failing reasons. The existing inference
// loop sees the new user.message and triggers another turn, re-prompting
// the model toward the unmet goal.
//
// Two trigger shapes count as "about to halt":
// 1. An explicit `assistant.halted` event (e.g. from `maxSteps`).
// 2. A *natural* terminal — the last event is an `assistant.message`
// with no `tool_calls` AND no other in-flight tool calls earlier
// in the turn. This is how `/goal` matches Claude Code's Stop hook
// semantics: predicates gate every point where the agent would
// otherwise return to the user, not just forced halts.
//
// In both cases the gate dedupes by the triggering event's `seq` so each
// terminal is judged at most once even if the log churns.
//
// Source of truth: the gate writes a real `user.message` event through
// `ctx.events.append` so the log remains the single durable record
// (principle 1 in src/CLAUDE.md). No side-channel re-prompts.
//
// Concurrency: forked with `Effect.forkScoped`, so the supervisor dies
// with the enclosing agent scope. The fiber subscribes to
// `ctx.events.changes` and is the SOLE consumer that maintains
// "last seen halt seq" state — no other fiber needs that view.
export const runHaltGate = (
infer: InferFn,
): Effect.Effect<void, never, AgentCtx | import("effect/Scope").Scope> =>
Effect.gen(function* () {
const ctx = yield* AgentCtx;
// Per-seq dedupe. Once a halted seq has been judged (predicates run,
// either reprompt appended or halt left to stand), we never reprocess
// that seq — even if the log changes shape later. Set lives only
// inside this fiber; no cross-fiber sharing.
const seenRef = yield* Ref.make<ReadonlySet<number>>(new Set());

// Detect a "natural terminal": last event is `assistant.message`
// with no tool_calls, and no `tool.call.started` after the most
// recent `user.message` remains unmatched. Mirrors the inference
// loop's idle condition.
const findNaturalTerminalSeq = (
arr: ReadonlyArray<Event>,
): number | null => {
const last = arr[arr.length - 1];
if (!last || last.type !== "assistant.message") return null;
if (last.tool_calls && last.tool_calls.length > 0) return null;
// Walk back to the last user.message; any tool.call.started since
// then must have a matching tool.result.
const started = new Set<string>();
const finished = new Set<string>();
for (let i = arr.length - 1; i >= 0; i--) {
const e = arr[i]!;
if (e.type === "user.message") break;
if (e.type === "tool.call.started") started.add(e.tool_call_id);
else if (e.type === "tool.result") finished.add(e.tool_call_id);
}
for (const id of started) {
if (!finished.has(id)) return null;
}
return last.seq;
};

const step = (events: Chunk.Chunk<Event>): Effect.Effect<void> =>
Effect.gen(function* () {
const arr = Chunk.toReadonlyArray(events);
const seen = yield* Ref.get(seenRef);
// Trigger 1: most recent unjudged `assistant.halted` (iterate
// from tail so the common "just appended" case is O(1)).
let triggerSeq: number | null = null;
for (let i = arr.length - 1; i >= 0; i--) {
const e = arr[i]!;
if (e.type !== "assistant.halted") continue;
if (seen.has(e.seq)) break;
triggerSeq = e.seq;
break;
}
// Trigger 2: natural terminal (assistant.message with no pending
// tool work). Only used if no halted trigger fired.
if (triggerSeq === null) {
const naturalSeq = findNaturalTerminalSeq(arr);
if (naturalSeq !== null && !seen.has(naturalSeq)) {
triggerSeq = naturalSeq;
}
}
if (triggerSeq === null) return;
// Mark this seq judged BEFORE running predicates so a predicate
// that itself triggers more events (e.g. by calling infer) can't
// re-enter this step for the same trigger.
const haltedSeq = triggerSeq;
yield* Ref.update(seenRef, (s) => {
const next = new Set(s);
next.add(haltedSeq);
return next;
});

const predicates = yield* ctx.getHaltPredicates;
if (predicates.size === 0) return; // halt stands

const snapshot: ReadonlyArray<Event> = arr;
const entries = Array.from(predicates.entries());
// Run predicates in parallel. Predicate throws are caught and
// mapped to `{ ok: false, reason: "predicate threw: ..." }` so a
// misbehaving goal can't kill the supervisor.
const results = yield* Effect.all(
entries.map(([name, fn]) =>
Effect.tryPromise({
try: () => fn({ events: snapshot, infer }),
catch: (err) => err,
}).pipe(
Effect.match({
onFailure: (err) => ({
name,
ok: false,
reason: `predicate threw: ${
err instanceof Error ? err.message : String(err)
}`,
}),
onSuccess: (r) => ({ name, ok: r.ok, reason: r.reason }),
}),
),
),
{ concurrency: "unbounded" },
);

const failing = results.filter((r) => !r.ok);
if (failing.length === 0) return; // all goals met — halt stands

const content = failing
.map((r) => `[goal: ${r.name}] not met: ${r.reason}`)
.join("\n");
yield* ctx.events.append({ type: "user.message", content });
});

const driver = ctx.events.changes.pipe(
Stream.mapEffect((evs) => step(evs), { concurrency: 1 }),
);
yield* Effect.forkScoped(Stream.runDrain(driver));
});
10 changes: 6 additions & 4 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,15 @@ export type Fragment = {
}[keyof FragmentMap];

// Output of the JSX render walk (see `src/jsx/render.ts`). Components
// emit into one of these channels via `emitFragment` / `emitTool`. The
// runtime consumes a `Rendered` to seed `ctx.ambients` and `ctx.tools`
// at startup. Additional channels (transforms, forked effects) are
// reserved for future stages — not implemented yet.
// emit into one of these channels via `emitFragment` / `emitTool` /
// `emitCommand`. The runtime consumes a `Rendered` to seed
// `ctx.ambients`, `ctx.tools`, and slash commands at startup.
// Additional channels (transforms, forked effects) are reserved for
// future stages — not implemented yet.
export interface Rendered {
readonly fragments: ReadonlyArray<Fragment>;
readonly tools: ReadonlyArray<Tool>;
readonly commands: ReadonlyArray<import("../jsx/runtime").Command>;
}

export interface InferResponse {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
export { PendingSends, type PendingSendsService } from "./core/pending-sends";
export { runInference } from "./core/inference";
export { runToolExecution } from "./core/tool-exec";
export { runHaltGate } from "./core/halt-gate";
export {
isHalted,
lastResult,
Expand Down
Loading
Loading