@xandreed/plugin-agent-loop 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xand Reed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,5 @@
1
+ # @xandreed/plugin-agent-loop
2
+
3
+ Efferent composable agent framework, 0.3.0. Bun on Linux.
4
+
5
+ Documentation: https://github.com/xandreeddev/efferent
@@ -0,0 +1,3 @@
1
+ export * from "./loop.js";
2
+ export * from "./runAgent.js";
3
+ export { agentLoopPlugin, default } from "./plugin.adapter.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./loop.js";
2
+ export * from "./runAgent.js";
3
+ export { agentLoopPlugin, default } from "./plugin.adapter.js";
package/dist/loop.d.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { LanguageModel, Prompt } from "@effect/ai";
2
+ import type { Tool, Toolkit } from "@effect/ai";
3
+ import { Effect, Option } from "effect";
4
+ import type { LoopEvent } from "@xandreed/core";
5
+ import type { AgentMessage } from "@xandreed/core";
6
+ import type { TokenUsage } from "@xandreed/core";
7
+ /**
8
+ * A mid-run fold the `compact` callback hands back: everything before
9
+ * `keepFrom` is replaced by `handoffToMessage(summary)`; `messages[keepFrom..]`
10
+ * survive verbatim. CONTRACT: `keepFrom` must index an assistant message
11
+ * (never split a tool-call from its results) — `safeKeepFrom` guarantees it;
12
+ * the loop ignores a plan that violates the contract (the run continues
13
+ * unfolded, best-effort).
14
+ */
15
+ export interface CompactionPlan {
16
+ readonly summary: string;
17
+ readonly keepFrom: number;
18
+ }
19
+ /**
20
+ * The provider-agnostic agent loop. `@effect/ai` resolves ONE step's tool
21
+ * calls (its handlers are our Effects) but does not iterate across turns —
22
+ * iteration is ours: each turn maps the message buffer to a `Prompt`, calls
23
+ * `LanguageModel.generateText`, appends the response as the new tail, and
24
+ * re-invokes until the model stops requesting tools, `maxSteps` is hit, or
25
+ * the degenerate-loop breaker fires. State is an immutable fold through
26
+ * `Effect.iterate` — the same discipline as foundry's forge loop.
27
+ */
28
+ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
29
+ readonly system: string | Prompt.Prompt;
30
+ /** Explicit opt-in: export prompt and result content on step spans.
31
+ * Hosts own consent, redaction and exporter retention. Disabled by default. */
32
+ readonly captureTraceContent?: boolean;
33
+ readonly messages: ReadonlyArray<AgentMessage>;
34
+ readonly toolkit: Toolkit.Toolkit<Tools>;
35
+ /** Run-pinned deterministic capability selection, refreshed between steps.
36
+ * A capability-expansion tool can update this selection for the next call.
37
+ * Handlers must still enforce their own principal/action policy. */
38
+ /** Host completion condition, checked after settled tools/events. Avoids a
39
+ * paid final prose call when a tool already delivered the complete result. */
40
+ readonly isComplete?: () => Effect.Effect<boolean, never, R>;
41
+ /** When true, a model stop cannot substitute for host completion. A bounded
42
+ * corrective turn asks for the missing actions; maxSteps still applies. */
43
+ readonly requireCompletion?: boolean;
44
+ readonly activeTools?: () => Effect.Effect<ReadonlyArray<keyof Tools>, never, R>;
45
+ /** A ceiling, not a target — a normal run ends when the model stops
46
+ * calling tools. Default {@link DEFAULT_MAX_STEPS}. */
47
+ readonly maxSteps?: number;
48
+ /** Concurrency for resolving one step's tool calls. Default
49
+ * {@link DEFAULT_TOOL_CONCURRENCY} — bounded so a many-call turn doesn't
50
+ * stampede the provider. */
51
+ readonly toolConcurrency?: number;
52
+ /** Tools designed to be POLLED — repeat calls with unchanged results are
53
+ * correct for these, so they never count toward the degenerate breaker. */
54
+ readonly pollableTools?: ReadonlyArray<string>;
55
+ /** The single event sink — see `LoopEvent`. */
56
+ readonly onEvent?: (event: LoopEvent) => Effect.Effect<void, never, R>;
57
+ /**
58
+ * Incremental persistence: called with each turn's appended messages
59
+ * (model responses AND synthetic correctives) the moment they land, so a
60
+ * session is restorable to its last completed turn after a crash. Returns
61
+ * the absolute store positions aligned with the input.
62
+ */
63
+ readonly onTail?: (messages: ReadonlyArray<AgentMessage>) => Effect.Effect<ReadonlyArray<number>, never, R>;
64
+ /**
65
+ * The WITHIN-RUN compaction seam, consulted at turn boundaries while the
66
+ * run continues. Receives the buffer the NEXT turn would send plus the
67
+ * just-finished turn's usage (its `inputTokens` IS the live context cost);
68
+ * `Some(plan)` rewrites the buffer to summary + kept tail. The loop stays
69
+ * pure — the callback owns thresholds, summarization, and persistence.
70
+ */
71
+ readonly compact?: (messages: ReadonlyArray<AgentMessage>, lastTurnUsage: TokenUsage) => Effect.Effect<Option.Option<CompactionPlan>, never, R>;
72
+ /**
73
+ * Route turns through `streamText`, folding the parts back into the
74
+ * settled turn shape while fanning `assistant_delta` events as tokens
75
+ * flow. Default FALSE. A stream that fails before its first part
76
+ * (scripted providers' `Stream.die` included) falls back to
77
+ * `generateText` transparently — for the REST of the run, not per turn.
78
+ */
79
+ readonly streaming?: boolean;
80
+ /**
81
+ * MID-TURN steering, consulted at turn boundaries while the run
82
+ * continues (the `compact` seam's twin): `Some(text)` appends a user
83
+ * message the NEXT model call sees — a human's course correction lands
84
+ * at the next step instead of after the whole run. Injected after any
85
+ * fold (steering must never be compacted away) and persisted via
86
+ * `onTail` like every other appended message.
87
+ */
88
+ readonly pendingInput?: () => Effect.Effect<Option.Option<string>, never, R>;
89
+ }
90
+ export declare const DEFAULT_MAX_STEPS = 100;
91
+ export declare const DEFAULT_TOOL_CONCURRENCY = 4;
92
+ export declare const DEGENERATE_REPEAT_NUDGE: string;
93
+ export declare const DEGENERATE_LOOP_STOP = "[stopped: repeated the same tool call with no progress \u2014 the run was looping]";
94
+ export type Phase = "continue" | "completed" | "step-cap" | "degenerate-loop";
95
+ /**
96
+ * The degenerate-loop breaker's fold over one turn's progress signature —
97
+ * PURE, so the nudge-at-3 / break-at-5 policy is tested without a scripted
98
+ * model. An empty signature (no tool calls, or only pollable ones) leaves
99
+ * the count untouched: a turn that did nothing is not a turn that repeated.
100
+ */
101
+ export declare const foldProgress: (state: {
102
+ readonly seen: ReadonlySet<string>;
103
+ readonly staleTurns: number;
104
+ }, sig: string) => {
105
+ readonly stale: number;
106
+ readonly seen: ReadonlySet<string>;
107
+ readonly nudge: boolean;
108
+ readonly broke: boolean;
109
+ };
110
+ /** THE decision after a turn — the only model-driven edge is `wantsMore`
111
+ * (the provider said "tool-calls" and there were some); the breaker and
112
+ * the cap are the harness's, and they outrank it in that order. */
113
+ export declare const nextPhase: (facts: {
114
+ readonly broke: boolean;
115
+ readonly wantsMore: boolean;
116
+ readonly turnIndex: number;
117
+ readonly maxSteps: number;
118
+ }) => Phase;
119
+ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never>(options: RunLoopOptions<Tools, R>) => Effect.Effect<{
120
+ finalText: string;
121
+ messages: readonly ({
122
+ readonly providerOptions?: unknown;
123
+ readonly role: "user";
124
+ readonly content: string;
125
+ } | {
126
+ readonly providerOptions?: unknown;
127
+ readonly role: "assistant";
128
+ readonly content: readonly ({
129
+ readonly type: "text";
130
+ readonly text: string;
131
+ readonly providerOptions?: unknown;
132
+ } | {
133
+ readonly type: "reasoning";
134
+ readonly text: string;
135
+ readonly providerOptions?: unknown;
136
+ } | {
137
+ readonly type: "tool-call";
138
+ readonly providerOptions?: unknown;
139
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
140
+ readonly toolName: string;
141
+ readonly input: unknown;
142
+ readonly providerExecuted?: boolean | undefined;
143
+ })[];
144
+ } | {
145
+ readonly providerOptions?: unknown;
146
+ readonly role: "tool";
147
+ readonly content: readonly {
148
+ readonly type: "tool-result";
149
+ readonly providerOptions?: unknown;
150
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
151
+ readonly toolName: string;
152
+ readonly output: unknown;
153
+ readonly isError?: boolean | undefined;
154
+ }[];
155
+ })[];
156
+ newTail: readonly ({
157
+ readonly providerOptions?: unknown;
158
+ readonly role: "user";
159
+ readonly content: string;
160
+ } | {
161
+ readonly providerOptions?: unknown;
162
+ readonly role: "assistant";
163
+ readonly content: readonly ({
164
+ readonly type: "text";
165
+ readonly text: string;
166
+ readonly providerOptions?: unknown;
167
+ } | {
168
+ readonly type: "reasoning";
169
+ readonly text: string;
170
+ readonly providerOptions?: unknown;
171
+ } | {
172
+ readonly type: "tool-call";
173
+ readonly providerOptions?: unknown;
174
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
175
+ readonly toolName: string;
176
+ readonly input: unknown;
177
+ readonly providerExecuted?: boolean | undefined;
178
+ })[];
179
+ } | {
180
+ readonly providerOptions?: unknown;
181
+ readonly role: "tool";
182
+ readonly content: readonly {
183
+ readonly type: "tool-result";
184
+ readonly providerOptions?: unknown;
185
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
186
+ readonly toolName: string;
187
+ readonly output: unknown;
188
+ readonly isError?: boolean | undefined;
189
+ }[];
190
+ })[];
191
+ outcome: "ok" | "partial";
192
+ reason: "completed" | "step-cap" | "degenerate-loop";
193
+ }, import("@effect/ai/AiError").AiError, LanguageModel.LanguageModel | Exclude<R, import("effect/Tracer").ParentSpan> | Exclude<Tool.HandlersFor<Tools>, import("effect/Tracer").ParentSpan>>;
package/dist/loop.js ADDED
@@ -0,0 +1,231 @@
1
+ import { LanguageModel, Prompt } from "@effect/ai";
2
+ import { Cause, Effect, Exit, Match, Metric, Option, Ref, Stream } from "effect";
3
+ import { foldStreamParts } from "@xandreed/core";
4
+ import { CurrentAgentStep } from "@xandreed/core";
5
+ import { CurrentEmptyResponseTolerance } from "@xandreed/core";
6
+ import { addUsage, zeroUsage } from "@xandreed/core";
7
+ import { extractModel, extractUsage, handoffToMessage, responseReasoning, responseText, responseToAgentMessages, responseToolCalls, responseToolResults, toPromptMessages, withToolCallIds, withUsageOnAssistant } from "@xandreed/core";
8
+ export const DEFAULT_MAX_STEPS = 100, DEFAULT_TOOL_CONCURRENCY = 4;
9
+ const engineRuns = Metric.counter("engine.runs", {
10
+ description: "agent runs by outcome and reason",
11
+ incremental: !0
12
+ }), engineToolCalls = Metric.counter("engine.tool_calls", {
13
+ description: "tool calls by tool name and result",
14
+ incremental: !0
15
+ }), engineCorrections = Metric.counter("engine.corrections", {
16
+ description: "self-corrections by kind (malformed recovery, degenerate nudge)",
17
+ incremental: !0
18
+ }), tagged = (metric, tags) => Object.entries(tags).reduce((m, [key, value]) => Metric.tagged(m, key, value), metric), MAX_MALFORMED = 3, REPEAT_NUDGE_AT = 3, REPEAT_BREAK_AT = 5;
19
+ export const DEGENERATE_REPEAT_NUDGE = "You've called the same tool(s) and gotten the same result several times in " + "a row \u2014 you're looping without making progress. STOP repeating that call. " + "Take a DIFFERENT concrete action toward the task; if you're genuinely stuck, say what's blocking you and stop.", DEGENERATE_LOOP_STOP = "[stopped: repeated the same tool call with no progress \u2014 the run was looping]";
20
+ const clip = (s, max) => s.length <= max ? s : `${s.slice(0, max)}\u2026`, progressSignature = (content, pollable) => {
21
+ const calls = responseToolCalls(content);
22
+ if (calls.length === 0)
23
+ return "";
24
+ if (calls.every((c) => pollable.has(c.toolName)))
25
+ return "";
26
+ return responseToolResults(content).filter((r) => !pollable.has(r.toolName)).map((r) => `${r.toolName}:${r.ok ? "ok" : "err"}:${clip(JSON.stringify(r.result ?? null), 300)}`).sort().join("|");
27
+ };
28
+ export const foldProgress = (state, sig) => {
29
+ const stale = sig === "" ? state.staleTurns : state.seen.has(sig) ? state.staleTurns + 1 : 0, seen = sig === "" || state.seen.has(sig) ? state.seen : new Set([...state.seen, sig]);
30
+ return {
31
+ stale,
32
+ seen,
33
+ nudge: sig !== "" && stale === REPEAT_NUDGE_AT,
34
+ broke: sig !== "" && stale >= REPEAT_BREAK_AT
35
+ };
36
+ }, nextPhase = (facts) => facts.broke ? "degenerate-loop" : !facts.wantsMore ? "completed" : facts.turnIndex >= facts.maxSteps ? "step-cap" : "continue", runLoop = (options) => Effect.gen(function* () {
37
+ yield* Effect.annotateCurrentSpan({ "engine.input.messages": options.messages.length });
38
+ const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS, pollable = new Set(options.pollableTools ?? []), eventContext = yield* Effect.context(), onEvent = (event) => Effect.gen(function* () {
39
+ const parent = yield* Effect.currentSpan.pipe(Effect.orDie);
40
+ yield* (options.onEvent?.(event) ?? Effect.void).pipe(Effect.withParentSpan(parent), Effect.provide(eventContext));
41
+ }), toolkit = yield* options.toolkit, step = (state) => Effect.gen(function* () {
42
+ yield* onEvent({ type: "turn_start", turnIndex: state.turnIndex });
43
+ const active = options.activeTools === void 0 ? Object.keys(toolkit.tools) : yield* options.activeTools().pipe(Effect.provide(eventContext)), selectedTools = Object.fromEntries(Object.entries(toolkit.tools).filter(([name]) => active.includes(name))), toolNames = Object.keys(selectedTools);
44
+ yield* Effect.annotateCurrentSpan({ "engine.tools.active": toolNames });
45
+ const instructions = typeof options.system === "string" ? Prompt.make([{ role: "system", content: options.system }]) : options.system, prompt = Prompt.merge(instructions, Prompt.make(toPromptMessages(state.messages)));
46
+ if (options.captureTraceContent === !0)
47
+ yield* Effect.annotateCurrentSpan({
48
+ "engine.step.input": JSON.stringify({ messages: prompt.content, activeTools: toolNames })
49
+ });
50
+ const toolSequence = yield* Ref.make(0), callOptions = {
51
+ prompt,
52
+ toolkit: {
53
+ tools: selectedTools,
54
+ handle: (name, params) => Effect.gen(function* () {
55
+ const sequence = yield* Ref.getAndUpdate(toolSequence, (value) => value + 1), toolCallId = `execution:${state.turnIndex}:${sequence}`;
56
+ yield* onEvent({ type: "tool_start", turnIndex: state.turnIndex, toolCallId, toolName: String(name), args: params });
57
+ return yield* toolkit.handle(name, params).pipe(Effect.tap((result) => onEvent({
58
+ type: "tool_end",
59
+ turnIndex: state.turnIndex,
60
+ toolCallId,
61
+ toolName: String(name),
62
+ args: params,
63
+ ok: !result.isFailure,
64
+ result: result.result
65
+ })));
66
+ })
67
+ },
68
+ concurrency: options.toolConcurrency ?? DEFAULT_TOOL_CONCURRENCY
69
+ }, settled = (streamingHealthy) => LanguageModel.generateText(callOptions).pipe(Effect.map((res) => ({
70
+ _tag: "ok",
71
+ content: res.content,
72
+ finishReason: res.finishReason,
73
+ usage: res.usage,
74
+ streamingHealthy
75
+ }))), streamed = Effect.gen(function* () {
76
+ const partSeen = yield* Ref.make(!1), folded = yield* foldStreamParts(LanguageModel.streamText(callOptions).pipe(Stream.tap(() => Ref.set(partSeen, !0))), (delta) => onEvent({
77
+ type: "assistant_delta",
78
+ turnIndex: state.turnIndex,
79
+ channel: delta.channel,
80
+ id: delta.id,
81
+ delta: delta.delta,
82
+ ...delta.toolName === void 0 ? {} : { toolName: delta.toolName }
83
+ })).pipe(Effect.map((turn) => Option.some({
84
+ _tag: "ok",
85
+ content: turn.content,
86
+ finishReason: turn.finishReason,
87
+ usage: turn.usage,
88
+ streamingHealthy: !0
89
+ })), Effect.catchAll((err) => Ref.get(partSeen).pipe(Effect.flatMap((armed) => armed ? Effect.fail(err) : Effect.succeed(Option.none())))), Effect.catchAllDefect((defect) => Ref.get(partSeen).pipe(Effect.flatMap((armed) => armed ? Effect.die(defect) : Effect.succeed(Option.none())))));
90
+ return yield* Option.match(folded, {
91
+ onSome: Effect.succeed,
92
+ onNone: () => Effect.logWarning("streaming failed before any part arrived \u2014 falling back to generateText for this run").pipe(Effect.zipRight(settled(!1)))
93
+ });
94
+ }), outcome = yield* (options.streaming === !0 && state.streamingHealthy ? streamed : settled(state.streamingHealthy)).pipe(Effect.catchAll((err) => err?._tag === "MalformedOutput" ? Effect.succeed({ _tag: "malformed", err }) : Effect.fail(err)), Effect.locally(CurrentEmptyResponseTolerance, state.toolCalls > 0));
95
+ if (outcome._tag === "malformed") {
96
+ const streak = state.malformedStreak + 1;
97
+ if (streak >= MAX_MALFORMED)
98
+ return yield* Effect.fail(outcome.err);
99
+ const desc = clip(String(outcome.err.description ?? "the response could not be parsed"), 600);
100
+ yield* Effect.logWarning(`recovering from a malformed response: ${desc}`);
101
+ yield* Metric.increment(tagged(engineCorrections, { "engine.kind": "malformed" }));
102
+ const corrective = {
103
+ role: "user",
104
+ content: `Your previous reply could not be parsed: ${desc}
105
+
106
+ This usually means you called a tool that doesn't exist or used the wrong argument shape. The only tools available are: ${toolNames.join(", ")}. Reply again using one of those tools, or plain text if you're done.`
107
+ };
108
+ yield* options.onTail?.([corrective]) ?? Effect.void;
109
+ const recoveredIndex = state.turnIndex + 1, capped = recoveredIndex >= maxSteps;
110
+ yield* capped ? Effect.logWarning("step cap reached while recovering from a malformed response") : Effect.void;
111
+ return {
112
+ ...state,
113
+ messages: [...state.messages, corrective],
114
+ newTail: [...state.newTail, corrective],
115
+ turnIndex: recoveredIndex,
116
+ malformedStreak: streak,
117
+ corrections: state.corrections + 1,
118
+ phase: capped ? "step-cap" : state.phase
119
+ };
120
+ }
121
+ const content = withToolCallIds(outcome.content, state.turnIndex), usage = extractUsage(outcome.usage, content);
122
+ yield* Effect.annotateCurrentSpan({
123
+ "engine.step.finish_reason": outcome.finishReason,
124
+ "engine.step.usage": JSON.stringify(usage),
125
+ ...options.captureTraceContent === !0 ? { "engine.step.output": JSON.stringify({ content, usage, finishReason: outcome.finishReason }) } : {}
126
+ });
127
+ const model = extractModel(content), tail = withUsageOnAssistant(responseToAgentMessages(content), usage, model), positions = yield* options.onTail?.(tail) ?? Effect.succeed([]), assistantAt = tail.findIndex((m) => m.role === "assistant"), assistantPosition = assistantAt >= 0 ? Option.fromNullable(positions[assistantAt]) : Option.none(), text = responseText(content), toolCalls = responseToolCalls(content);
128
+ yield* onEvent({
129
+ type: "assistant_message",
130
+ turnIndex: state.turnIndex,
131
+ text,
132
+ reasoning: responseReasoning(content),
133
+ toolCalls,
134
+ usage,
135
+ ...Option.match(model, { onNone: () => ({}), onSome: (id) => ({ model: id }) }),
136
+ ...Option.match(assistantPosition, {
137
+ onNone: () => ({}),
138
+ onSome: (position) => ({ position })
139
+ })
140
+ });
141
+ const toolResults = responseToolResults(content);
142
+ yield* Effect.forEach(toolResults, (tr) => Metric.increment(tagged(engineToolCalls, {
143
+ "engine.tool": tr.toolName,
144
+ "engine.ok": tr.ok ? "true" : "false"
145
+ })));
146
+ const sig = progressSignature(content, pollable), progress = foldProgress(state, sig), { stale, seen } = progress, nudge = progress.nudge ? [{ role: "user", content: DEGENERATE_REPEAT_NUDGE }] : [];
147
+ yield* nudge.length > 0 ? options.onTail?.(nudge) ?? Effect.void : Effect.void;
148
+ yield* nudge.length > 0 ? Metric.increment(tagged(engineCorrections, { "engine.kind": "degenerate-nudge" })) : Effect.void;
149
+ const finalText = text.length > 0 ? text : state.finalText, turnIndex = state.turnIndex + 1, completed = yield* options.isComplete?.() ?? Effect.succeed(!1), requireMore = !completed && options.requireCompletion === !0, completionNudge = requireMore && toolCalls.length === 0 ? [{ role: "user", content: "The host reports that required actions are incomplete. Your ordinary text is not a delivered result. Complete the requested work using the available tools, or use an explicit supported failure/clarification action if the evidence is missing." }] : [];
150
+ yield* completionNudge.length > 0 ? options.onTail?.(completionNudge) ?? Effect.void : Effect.void;
151
+ const wantsMore = !completed && (requireMore || outcome.finishReason === "tool-calls" && toolCalls.length > 0), broke = progress.broke, phase = nextPhase({ broke, wantsMore, turnIndex, maxSteps });
152
+ yield* broke ? Effect.logWarning(`breaking a degenerate tool-call loop after ${stale} stale turns`) : Effect.void;
153
+ const nextMessages = [...state.messages, ...tail, ...nudge, ...completionNudge], plan = phase === "continue" && options.compact !== void 0 ? yield* options.compact(nextMessages, usage) : Option.none(), applied = Option.filter(plan, (p) => p.keepFrom > 0 && p.keepFrom < nextMessages.length && nextMessages[p.keepFrom]?.role === "assistant");
154
+ yield* Option.match(applied, {
155
+ onNone: () => Effect.void,
156
+ onSome: (p) => onEvent({
157
+ type: "compaction",
158
+ turnIndex: state.turnIndex,
159
+ tokens: usage.inputTokens,
160
+ kept: nextMessages.length - p.keepFrom
161
+ })
162
+ });
163
+ const steered = phase === "continue" && options.pendingInput !== void 0 ? yield* options.pendingInput() : Option.none(), steerTail = Option.match(steered, {
164
+ onNone: () => [],
165
+ onSome: (text) => [{ role: "user", content: text }]
166
+ });
167
+ yield* steerTail.length > 0 ? options.onTail?.(steerTail) ?? Effect.void : Effect.void;
168
+ return {
169
+ messages: [
170
+ ...Option.match(applied, {
171
+ onNone: () => nextMessages,
172
+ onSome: (p) => [handoffToMessage(p.summary), ...nextMessages.slice(p.keepFrom)]
173
+ }),
174
+ ...steerTail
175
+ ],
176
+ newTail: [...state.newTail, ...tail, ...nudge, ...completionNudge, ...steerTail],
177
+ finalText: broke && finalText.length === 0 ? DEGENERATE_LOOP_STOP : finalText,
178
+ turnIndex,
179
+ malformedStreak: 0,
180
+ seen,
181
+ staleTurns: stale,
182
+ usage: addUsage(state.usage, usage),
183
+ phase,
184
+ toolCalls: state.toolCalls + toolCalls.length,
185
+ toolFailures: state.toolFailures + toolResults.filter((tr) => !tr.ok).length,
186
+ corrections: state.corrections + (nudge.length > 0 ? 1 : 0),
187
+ streamingHealthy: outcome.streamingHealthy
188
+ };
189
+ }).pipe(Effect.onExit((exit) => onEvent({ type: "turn_end", turnIndex: state.turnIndex, status: Exit.isSuccess(exit) ? "completed" : Cause.isInterruptedOnly(exit.cause) ? "cancelled" : "failed" })), Effect.locally(CurrentAgentStep, Option.some(state.turnIndex)), Effect.withSpan("engine.turn", {
190
+ attributes: { "engine.turn": state.turnIndex, "engine.step": state.turnIndex + 1 }
191
+ })), final = yield* Effect.iterate({
192
+ messages: options.messages,
193
+ newTail: [],
194
+ finalText: "",
195
+ turnIndex: 0,
196
+ malformedStreak: 0,
197
+ seen: new Set,
198
+ staleTurns: 0,
199
+ usage: zeroUsage,
200
+ phase: "continue",
201
+ toolCalls: 0,
202
+ toolFailures: 0,
203
+ corrections: 0,
204
+ streamingHealthy: !0
205
+ }, { while: (state) => state.phase === "continue", body: step }), { outcome, reason } = Match.value(final.phase).pipe(Match.when("completed", () => ({ outcome: "ok", reason: "completed" })), Match.when("step-cap", () => ({
206
+ outcome: "partial",
207
+ reason: "step-cap"
208
+ })), Match.when("degenerate-loop", () => ({
209
+ outcome: "partial",
210
+ reason: "degenerate-loop"
211
+ })), Match.when("continue", () => ({ outcome: "ok", reason: "completed" })), Match.exhaustive);
212
+ yield* onEvent({ type: "agent_end", outcome, reason, finalText: final.finalText });
213
+ yield* Metric.increment(tagged(engineRuns, { "engine.outcome": outcome, "engine.reason": reason }));
214
+ yield* Effect.annotateCurrentSpan({
215
+ "engine.outcome": outcome,
216
+ "engine.reason": reason,
217
+ "engine.turns": final.turnIndex,
218
+ "engine.usage.total_tokens": final.usage.totalTokens,
219
+ "engine.tool_calls": final.toolCalls,
220
+ "engine.tool_calls.failed": final.toolFailures,
221
+ "engine.corrections": final.corrections,
222
+ error: outcome !== "ok"
223
+ });
224
+ return {
225
+ finalText: final.finalText,
226
+ messages: final.messages,
227
+ newTail: final.newTail,
228
+ outcome,
229
+ reason
230
+ };
231
+ }).pipe(Effect.withSpan("engine.run"));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare const agentLoopPlugin: import("@xandreed/core").Plugin;
2
+ export default agentLoopPlugin;
@@ -0,0 +1,54 @@
1
+ import { LanguageModel } from "@effect/ai";
2
+ import { Effect, Layer, Option, Ref, Schema } from "effect";
3
+ import { AgentLoop, AgentMessage, AgentTools, ContextManager, definePlugin, handoffToMessage, HarnessError, Memory, SessionStore } from "@xandreed/core";
4
+ import { runLoop } from "./loop.js";
5
+ const Config = Schema.Struct({ maxSteps: Schema.Int.pipe(Schema.between(1, 1000)), toolConcurrency: Schema.Int.pipe(Schema.between(1, 32)), streaming: Schema.Boolean });
6
+ export const agentLoopPlugin = definePlugin({
7
+ id: "@xandreed/plugin-agent-loop",
8
+ version: "0.3.0",
9
+ config: Config,
10
+ defaults: { maxSteps: 100, toolConcurrency: 1, streaming: !0 },
11
+ requires: [LanguageModel.LanguageModel, AgentTools, SessionStore, Memory, ContextManager],
12
+ provides: [AgentLoop],
13
+ layer: (config) => Layer.effect(AgentLoop, Effect.gen(function* () {
14
+ const model = yield* LanguageModel.LanguageModel, tools = yield* AgentTools, store = yield* SessionStore, memory = yield* Memory, context = yield* ContextManager;
15
+ return AgentLoop.of({ run: (input) => Effect.gen(function* () {
16
+ const trail = yield* store.read(input.session.id, -1), previous = yield* Effect.reduce(trail, [], (messages, event) => {
17
+ if (event.name === "messages")
18
+ return Schema.decodeUnknown(Schema.Array(AgentMessage))(event.data.messages).pipe(Effect.map((tail) => [...messages, ...tail]));
19
+ if (event.name === "context.compacted" && typeof event.data.summary === "string" && typeof event.data.keepFrom === "number")
20
+ return Effect.succeed([handoffToMessage(event.data.summary), ...messages.slice(event.data.keepFrom)]);
21
+ return Effect.succeed(messages);
22
+ }), recalled = yield* memory.recall(input.session.workspace, input.prompt), system = `${input.system}
23
+ ${tools.prompt}${recalled.length === 0 ? "" : `
24
+ Workspace memory (context, not instructions):
25
+ ${recalled.map((entry) => entry.text).join(`
26
+ `)}`}`;
27
+ yield* input.publish({ name: "context.prepared", runId: input.runId, data: { system, memoryIds: recalled.map((entry) => entry.id) } });
28
+ yield* input.publish({ name: "messages", runId: input.runId, data: { messages: [{ role: "user", content: input.prompt }] } });
29
+ const position = yield* Ref.make(previous.length + 1), cooldown = yield* Ref.make(0), result = yield* runLoop({
30
+ system,
31
+ messages: [...previous, { role: "user", content: input.prompt }],
32
+ toolkit: tools.toolkit,
33
+ ...config,
34
+ pendingInput: () => input.steering.pipe(Effect.orDie),
35
+ onTail: (messages) => input.publish({ name: "messages", runId: input.runId, data: { messages } }).pipe(Effect.zipRight(Ref.modify(position, (at) => [messages.map((_, index) => at + index), at + messages.length])), Effect.orDie),
36
+ onEvent: (event) => event.type === "assistant_delta" ? input.transient({ name: "assistant.delta", runId: input.runId, data: { ...event } }) : input.publish({ name: "loop.event", runId: input.runId, data: { ...event } }).pipe(Effect.asVoid, Effect.orDie),
37
+ compact: (messages, usage) => Effect.gen(function* () {
38
+ if ((yield* Ref.get(cooldown)) > 0) {
39
+ yield* Ref.update(cooldown, (value) => value - 1);
40
+ return Option.none();
41
+ }
42
+ const compacted = yield* context.compact(messages, usage.inputTokens);
43
+ if (Option.isSome(compacted)) {
44
+ yield* input.publish({ name: "context.compacted", runId: input.runId, data: { ...compacted.value } });
45
+ yield* Ref.set(cooldown, 3);
46
+ }
47
+ return compacted;
48
+ }).pipe(Effect.catchAll((error) => input.publish({ name: "context.failed", runId: input.runId, data: { message: error.message } }).pipe(Effect.as(Option.none()), Effect.orDie)))
49
+ }).pipe(Effect.provideService(LanguageModel.LanguageModel, model), Effect.provide(tools.handlers));
50
+ return { text: result.finalText, outcome: result.outcome === "ok" ? "completed" : "partial" };
51
+ }).pipe(Effect.mapError((error) => new HarnessError({ code: "loop.failed", message: String(error) }))) });
52
+ }))
53
+ });
54
+ export default agentLoopPlugin;
@@ -0,0 +1,140 @@
1
+ import type { Prompt, Tool, Toolkit } from "@effect/ai";
2
+ import { Effect, Option } from "effect";
3
+ import type { LoopEvent } from "@xandreed/core";
4
+ import type { AgentMessage, ConversationId } from "@xandreed/core";
5
+ import { ConversationStore } from "@xandreed/core";
6
+ import type { ModelCallPolicy } from "@xandreed/core";
7
+ /**
8
+ * WITHIN-RUN compaction policy: when a finished turn's `inputTokens` exceeds
9
+ * the threshold, the buffer's older turns fold into a summary and the run
10
+ * continues on summary + the last `keepTurns` assistant turns verbatim (the
11
+ * kept tail is ALSO covered by the summary — the overlap that survives a
12
+ * boundary). `summarize` sees the whole buffer; any failure or an empty
13
+ * summary skips the fold (best-effort — the run continues unfolded).
14
+ */
15
+ export interface CompactionPolicy {
16
+ readonly thresholdTokens: number;
17
+ readonly keepTurns: number;
18
+ readonly summarize: (transcript: ReadonlyArray<AgentMessage>, previous: Option.Option<string>) => Effect.Effect<string, unknown>;
19
+ }
20
+ /**
21
+ * An agent is a system prompt + a toolkit (+ its loop bounds). The driver
22
+ * decides which config runs; the engine stays agent-agnostic.
23
+ */
24
+ export interface AgentConfig<Tools extends Record<string, Tool.Any>> {
25
+ readonly system: string | Prompt.Prompt;
26
+ readonly toolkit: Toolkit.Toolkit<Tools>;
27
+ readonly maxSteps?: number;
28
+ /** Handler concurrency within one model turn. Agents with mutating tools
29
+ * should set this to 1 unless their toolkit provides stronger scheduling. */
30
+ readonly toolConcurrency?: number;
31
+ readonly pollableTools?: ReadonlyArray<string>;
32
+ readonly compaction?: CompactionPolicy;
33
+ /** Stream turns (`assistant_delta` events while tokens flow); falls back
34
+ * to settled turns on a pre-first-part failure. Default false. */
35
+ readonly streaming?: boolean;
36
+ /** Per-agent output/reasoning policy. Dedicated agents pin this instead of
37
+ * inheriting provider-family defaults. */
38
+ readonly modelPolicy?: ModelCallPolicy;
39
+ /** Server-side prompt-cache lane override. Defaults to the conversation id
40
+ * (one lane per conversation); an agent whose system prompt is a shared
41
+ * cacheable prefix may key by ITS hash instead so prefill cache hits fire
42
+ * across conversations (probe-verified safe under concurrency on the
43
+ * codex route, 2026-07-17). */
44
+ readonly promptCacheKey?: string;
45
+ }
46
+ /**
47
+ * One user turn over a persisted conversation: append the prompt, load the
48
+ * active window (prepending the latest fold's summary when one exists), run
49
+ * the loop with incremental tail persistence, return the result. Store
50
+ * failures are defects (`orDie`) — persistence breaking mid-run is
51
+ * infrastructure death, not a condition the model can correct.
52
+ *
53
+ * With a `compaction` policy, runAgent also owns the store reconciliation
54
+ * for mid-run folds: a POSITION MIRROR tracks each buffer entry's durable
55
+ * position (None for synthetic handoffs), and a fold writes `checkpointAt`
56
+ * on the last covered position — so `listActive` for the NEXT run returns
57
+ * exactly the kept rows and folded events are never resent.
58
+ */
59
+ export declare const runAgent: <Tools extends Record<string, Tool.Any>, R = never>(config: AgentConfig<Tools>, conversationId: ConversationId, prompt: string, options?: {
60
+ readonly onEvent?: (event: LoopEvent) => Effect.Effect<void, never, R>;
61
+ /** The mid-turn steering seam — see `RunLoopOptions.pendingInput`. */
62
+ readonly pendingInput?: () => Effect.Effect<Option.Option<string>, never, R>;
63
+ /** Prompt content is redacted from traces by default. Trusted local
64
+ * drivers may opt in explicitly for debugging. */
65
+ readonly traceContent?: boolean;
66
+ }) => Effect.Effect<{
67
+ finalText: string;
68
+ messages: readonly ({
69
+ readonly providerOptions?: unknown;
70
+ readonly role: "user";
71
+ readonly content: string;
72
+ } | {
73
+ readonly providerOptions?: unknown;
74
+ readonly role: "assistant";
75
+ readonly content: readonly ({
76
+ readonly type: "text";
77
+ readonly text: string;
78
+ readonly providerOptions?: unknown;
79
+ } | {
80
+ readonly type: "reasoning";
81
+ readonly text: string;
82
+ readonly providerOptions?: unknown;
83
+ } | {
84
+ readonly type: "tool-call";
85
+ readonly providerOptions?: unknown;
86
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
87
+ readonly toolName: string;
88
+ readonly input: unknown;
89
+ readonly providerExecuted?: boolean | undefined;
90
+ })[];
91
+ } | {
92
+ readonly providerOptions?: unknown;
93
+ readonly role: "tool";
94
+ readonly content: readonly {
95
+ readonly type: "tool-result";
96
+ readonly providerOptions?: unknown;
97
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
98
+ readonly toolName: string;
99
+ readonly output: unknown;
100
+ readonly isError?: boolean | undefined;
101
+ }[];
102
+ })[];
103
+ newTail: readonly ({
104
+ readonly providerOptions?: unknown;
105
+ readonly role: "user";
106
+ readonly content: string;
107
+ } | {
108
+ readonly providerOptions?: unknown;
109
+ readonly role: "assistant";
110
+ readonly content: readonly ({
111
+ readonly type: "text";
112
+ readonly text: string;
113
+ readonly providerOptions?: unknown;
114
+ } | {
115
+ readonly type: "reasoning";
116
+ readonly text: string;
117
+ readonly providerOptions?: unknown;
118
+ } | {
119
+ readonly type: "tool-call";
120
+ readonly providerOptions?: unknown;
121
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
122
+ readonly toolName: string;
123
+ readonly input: unknown;
124
+ readonly providerExecuted?: boolean | undefined;
125
+ })[];
126
+ } | {
127
+ readonly providerOptions?: unknown;
128
+ readonly role: "tool";
129
+ readonly content: readonly {
130
+ readonly type: "tool-result";
131
+ readonly providerOptions?: unknown;
132
+ readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
133
+ readonly toolName: string;
134
+ readonly output: unknown;
135
+ readonly isError?: boolean | undefined;
136
+ }[];
137
+ })[];
138
+ outcome: "ok" | "partial";
139
+ reason: "completed" | "step-cap" | "degenerate-loop";
140
+ }, import("@effect/ai/AiError").AiError, ConversationStore | import("@effect/ai/LanguageModel").LanguageModel | Exclude<Exclude<R, import("effect/Tracer").ParentSpan>, import("effect/Tracer").ParentSpan> | Exclude<Exclude<Tool.HandlersFor<Tools>, import("effect/Tracer").ParentSpan>, import("effect/Tracer").ParentSpan>>;
@@ -0,0 +1,63 @@
1
+ import { Effect, Option, Ref } from "effect";
2
+ import { ConversationStore } from "@xandreed/core";
3
+ import { CurrentPromptCacheKey } from "@xandreed/core";
4
+ import { CurrentModelCallPolicy } from "@xandreed/core";
5
+ import { handoffToMessage, safeKeepFrom } from "@xandreed/core";
6
+ import { runLoop } from "./loop.js";
7
+ const COMPACT_COOLDOWN_TURNS = 3;
8
+ export const runAgent = (config, conversationId, prompt, options) => Effect.gen(function* () {
9
+ const store = yield* ConversationStore;
10
+ yield* store.append(conversationId, { role: "user", content: prompt }).pipe(Effect.orDie);
11
+ const fold = yield* store.latestCheckpoint(conversationId).pipe(Effect.orDie), active = yield* store.listActive(conversationId).pipe(Effect.orDie), messages = Option.match(fold, {
12
+ onNone: () => active.map((row) => row.message),
13
+ onSome: (checkpoint) => [
14
+ handoffToMessage(checkpoint.summary),
15
+ ...active.map((row) => row.message)
16
+ ]
17
+ }), activePositions = active.map((row) => Option.some(row.position)), mirrorRef = yield* Ref.make(Option.isSome(fold) ? [Option.none(), ...activePositions] : activePositions), cooldownRef = yield* Ref.make(0), onTail = (tail) => store.appendAll(conversationId, tail).pipe(Effect.tap((positions) => Ref.update(mirrorRef, (all) => [...all, ...positions.map(Option.some)])), Effect.orDie), compactWith = (policy) => (buffer, lastTurnUsage) => Effect.gen(function* () {
18
+ const cooldown = yield* Ref.get(cooldownRef);
19
+ if (cooldown > 0) {
20
+ yield* Ref.set(cooldownRef, cooldown - 1);
21
+ return Option.none();
22
+ }
23
+ if (lastTurnUsage.inputTokens <= policy.thresholdTokens)
24
+ return Option.none();
25
+ const cut = safeKeepFrom(buffer, policy.keepTurns);
26
+ if (Option.isNone(cut))
27
+ return Option.none();
28
+ const mirror = yield* Ref.get(mirrorRef);
29
+ if (mirror.length !== buffer.length)
30
+ return Option.none();
31
+ const covered = mirror.slice(0, cut.value).flatMap(Option.toArray), coveredPosition = covered[covered.length - 1], firstKept = mirror.slice(cut.value).flatMap(Option.toArray)[0];
32
+ if (coveredPosition === void 0 || firstKept !== void 0 && coveredPosition >= firstKept)
33
+ return Option.none();
34
+ const previous = yield* store.latestCheckpoint(conversationId).pipe(Effect.map(Option.map((checkpoint) => checkpoint.summary))), summary = (yield* policy.summarize(buffer, previous)).trim();
35
+ if (summary.length === 0)
36
+ return Option.none();
37
+ yield* store.checkpointAt(conversationId, summary, coveredPosition);
38
+ yield* Ref.set(mirrorRef, [Option.none(), ...mirror.slice(cut.value)]);
39
+ yield* Ref.set(cooldownRef, COMPACT_COOLDOWN_TURNS);
40
+ return Option.some({ summary, keepFrom: cut.value });
41
+ }).pipe(Effect.withSpan("engine.compact"), Effect.catchAll(() => Effect.succeed(Option.none()))), result = yield* runLoop({
42
+ system: config.system,
43
+ messages,
44
+ toolkit: config.toolkit,
45
+ ...config.maxSteps !== void 0 ? { maxSteps: config.maxSteps } : {},
46
+ ...config.toolConcurrency !== void 0 ? { toolConcurrency: config.toolConcurrency } : {},
47
+ ...config.pollableTools !== void 0 ? { pollableTools: config.pollableTools } : {},
48
+ ...options?.onEvent !== void 0 ? { onEvent: options.onEvent } : {},
49
+ ...options?.pendingInput !== void 0 ? { pendingInput: options.pendingInput } : {},
50
+ ...config.compaction !== void 0 ? { compact: compactWith(config.compaction) } : {},
51
+ ...config.streaming !== void 0 ? { streaming: config.streaming } : {},
52
+ onTail
53
+ }).pipe(Effect.locally(CurrentPromptCacheKey, Option.some(config.promptCacheKey ?? String(conversationId))));
54
+ yield* store.recordOutcome(conversationId, result.outcome, result.reason).pipe(Effect.catchAll((error) => Effect.logWarning(`the run's outcome was not recorded: ${error.message}`)));
55
+ return result;
56
+ }).pipe(Effect.withSpan("agent.run", {
57
+ attributes: {
58
+ "agent.kind": "run",
59
+ "agent.conversation_id": String(conversationId),
60
+ "agent.prompt": options?.traceContent === !0 ? prompt.slice(0, 500) : "[redacted]",
61
+ "agent.prompt_chars": prompt.length
62
+ }
63
+ }), Effect.locally(CurrentModelCallPolicy, Option.fromNullable(config.modelPolicy)));
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@xandreed/plugin-agent-loop",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@xandreed/core": "0.3.0",
14
+ "@effect/ai": "0.35.0",
15
+ "effect": "3.21.4"
16
+ },
17
+ "private": false,
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "skills",
22
+ "profiles",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "bun": ">=1.3.0"
28
+ },
29
+ "license": "MIT"
30
+ }