@xandreed/plugin-agent-loop 0.3.0 → 0.8.0-next.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/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @xandreed/plugin-agent-loop
2
2
 
3
- Efferent composable agent framework, 0.3.0. Bun on Linux.
3
+ Efferent composable agent framework, 0.8.0-next.0. Bun on Linux.
4
4
 
5
5
  Documentation: https://github.com/xandreeddev/efferent
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./loop.js";
2
2
  export * from "./runAgent.js";
3
3
  export { agentLoopPlugin, default } from "./plugin.adapter.js";
4
+ export { runSteps, STEP_LOOP, StepLoopLive, stepLoopPlugin } from "./step-loop.adapter.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./loop.js";
2
2
  export * from "./runAgent.js";
3
3
  export { agentLoopPlugin, default } from "./plugin.adapter.js";
4
+ export { runSteps, STEP_LOOP, StepLoopLive, stepLoopPlugin } from "./step-loop.adapter.js";
package/dist/loop.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { LanguageModel, Prompt } from "@effect/ai";
2
- import type { Tool, Toolkit } from "@effect/ai";
1
+ import { AiError, LanguageModel, Prompt } from "effect/ai";
2
+ import type { Tool, Toolkit } from "effect/ai";
3
3
  import { Effect, Option } from "effect";
4
4
  import type { LoopEvent } from "@xandreed/core";
5
5
  import type { AgentMessage } from "@xandreed/core";
6
6
  import type { TokenUsage } from "@xandreed/core";
7
+ import type { ModelDispatch } from "@xandreed/core";
7
8
  /**
8
9
  * A mid-run fold the `compact` callback hands back: everything before
9
10
  * `keepFrom` is replaced by `handoffToMessage(summary)`; `messages[keepFrom..]`
@@ -23,24 +24,57 @@ export interface CompactionPlan {
23
24
  * `LanguageModel.generateText`, appends the response as the new tail, and
24
25
  * re-invokes until the model stops requesting tools, `maxSteps` is hit, or
25
26
  * the degenerate-loop breaker fires. State is an immutable fold through
26
- * `Effect.iterate` — the same discipline as foundry's forge loop.
27
+ * a recursive step — the same discipline as foundry's forge loop.
27
28
  */
28
29
  export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
30
+ /** A host-validated first batch. Runs as step zero through the ordinary
31
+ * Effect AI decoder, instrumented toolkit and durable tail hooks, without
32
+ * another provider request. Provider usage belongs to the host planner. */
33
+ readonly initialStep?: ReadonlyArray<{
34
+ readonly name: keyof Tools & string;
35
+ readonly params: unknown;
36
+ }>;
37
+ /** Host selection at a real provider boundary. Never called for initialStep.
38
+ * The host may cache its choice for a run; tools and completion remain loop-owned. */
39
+ readonly prepareModel?: (input: {
40
+ readonly stepIndex: number;
41
+ readonly messages: ReadonlyArray<AgentMessage>;
42
+ readonly activeTools: ReadonlyArray<string>;
43
+ }) => Effect.Effect<{
44
+ readonly model: LanguageModel.LanguageModel;
45
+ readonly system: Prompt.Prompt;
46
+ }, AiError.AiError, R>;
47
+ /** Validate independently reconstructed durable input immediately before a provider dispatch. */
48
+ readonly beforeDispatch?: (step: number, request: ModelDispatch) => Effect.Effect<void, never, R>;
29
49
  readonly system: string | Prompt.Prompt;
50
+ /** The READ side of memory: when present, each provider step sends these
51
+ * messages instead of the loop's own buffer. Persistence still flows through
52
+ * `onTail`, so a memory plugin can rebuild every request from its log. */
53
+ readonly render?: (step: StepView) => Effect.Effect<ReadonlyArray<AgentMessage>, never, R>;
54
+ /** Per-step tool choice (e.g. force a delivery tool without removing others). */
55
+ readonly stepDirective?: (step: StepView) => Effect.Effect<{
56
+ readonly toolChoice: Option.Option<LoopToolChoice>;
57
+ }, never, R>;
58
+ /** Host wording for the loop's own corrective turns. */
59
+ readonly correctives?: {
60
+ readonly malformed?: (tools: ReadonlyArray<string>, description: string) => string;
61
+ readonly incomplete?: string;
62
+ };
30
63
  /** Explicit opt-in: export prompt and result content on step spans.
31
64
  * Hosts own consent, redaction and exporter retention. Disabled by default. */
32
65
  readonly captureTraceContent?: boolean;
33
66
  readonly messages: ReadonlyArray<AgentMessage>;
34
67
  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
68
  /** Host completion condition, checked after settled tools/events. Avoids a
39
69
  * paid final prose call when a tool already delivered the complete result. */
40
70
  readonly isComplete?: () => Effect.Effect<boolean, never, R>;
41
71
  /** When true, a model stop cannot substitute for host completion. A bounded
42
72
  * corrective turn asks for the missing actions; maxSteps still applies. */
43
73
  readonly requireCompletion?: boolean;
74
+ /** Run-pinned deterministic capability selection, refreshed between steps
75
+ * and sent in the order returned (append new tools to keep the prefix stable).
76
+ * A capability-expansion tool can update this selection for the next call.
77
+ * Handlers must still enforce their own principal/action policy. */
44
78
  readonly activeTools?: () => Effect.Effect<ReadonlyArray<keyof Tools>, never, R>;
45
79
  /** A ceiling, not a target — a normal run ends when the model stops
46
80
  * calling tools. Default {@link DEFAULT_MAX_STEPS}. */
@@ -87,6 +121,16 @@ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
87
121
  */
88
122
  readonly pendingInput?: () => Effect.Effect<Option.Option<string>, never, R>;
89
123
  }
124
+ /** What the loop knows about the step it is about to take. */
125
+ export interface StepView {
126
+ readonly stepIndex: number;
127
+ /** Active tool names, in activation order. */
128
+ readonly activeTools: ReadonlyArray<string>;
129
+ readonly lastUsage: Option.Option<TokenUsage>;
130
+ }
131
+ export type LoopToolChoice = "required" | {
132
+ readonly tool: string;
133
+ };
90
134
  export declare const DEFAULT_MAX_STEPS = 100;
91
135
  export declare const DEFAULT_TOOL_CONCURRENCY = 4;
92
136
  export declare const DEGENERATE_REPEAT_NUDGE: string;
@@ -119,11 +163,10 @@ export declare const nextPhase: (facts: {
119
163
  export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never>(options: RunLoopOptions<Tools, R>) => Effect.Effect<{
120
164
  finalText: string;
121
165
  messages: readonly ({
122
- readonly providerOptions?: unknown;
123
166
  readonly role: "user";
124
167
  readonly content: string;
125
- } | {
126
168
  readonly providerOptions?: unknown;
169
+ } | {
127
170
  readonly role: "assistant";
128
171
  readonly content: readonly ({
129
172
  readonly type: "text";
@@ -135,30 +178,30 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
135
178
  readonly providerOptions?: unknown;
136
179
  } | {
137
180
  readonly type: "tool-call";
138
- readonly providerOptions?: unknown;
139
181
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
140
182
  readonly toolName: string;
141
183
  readonly input: unknown;
184
+ readonly providerOptions?: unknown;
142
185
  readonly providerExecuted?: boolean | undefined;
143
186
  })[];
144
- } | {
145
187
  readonly providerOptions?: unknown;
188
+ } | {
146
189
  readonly role: "tool";
147
190
  readonly content: readonly {
148
191
  readonly type: "tool-result";
149
- readonly providerOptions?: unknown;
150
192
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
151
193
  readonly toolName: string;
152
194
  readonly output: unknown;
195
+ readonly providerOptions?: unknown;
153
196
  readonly isError?: boolean | undefined;
154
197
  }[];
198
+ readonly providerOptions?: unknown;
155
199
  })[];
156
200
  newTail: readonly ({
157
- readonly providerOptions?: unknown;
158
201
  readonly role: "user";
159
202
  readonly content: string;
160
- } | {
161
203
  readonly providerOptions?: unknown;
204
+ } | {
162
205
  readonly role: "assistant";
163
206
  readonly content: readonly ({
164
207
  readonly type: "text";
@@ -170,24 +213,25 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
170
213
  readonly providerOptions?: unknown;
171
214
  } | {
172
215
  readonly type: "tool-call";
173
- readonly providerOptions?: unknown;
174
216
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
175
217
  readonly toolName: string;
176
218
  readonly input: unknown;
219
+ readonly providerOptions?: unknown;
177
220
  readonly providerExecuted?: boolean | undefined;
178
221
  })[];
179
- } | {
180
222
  readonly providerOptions?: unknown;
223
+ } | {
181
224
  readonly role: "tool";
182
225
  readonly content: readonly {
183
226
  readonly type: "tool-result";
184
- readonly providerOptions?: unknown;
185
227
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
186
228
  readonly toolName: string;
187
229
  readonly output: unknown;
230
+ readonly providerOptions?: unknown;
188
231
  readonly isError?: boolean | undefined;
189
232
  }[];
233
+ readonly providerOptions?: unknown;
190
234
  })[];
191
235
  outcome: "ok" | "partial";
192
236
  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>>;
237
+ }, AiError.AiError, LanguageModel.LanguageModel | Exclude<R, import("effect/Tracer").ParentSpan> | Exclude<Tool.HandlersFor<Tools>, import("effect/Tracer").ParentSpan>>;
package/dist/loop.js CHANGED
@@ -1,6 +1,6 @@
1
- import { LanguageModel, Prompt } from "@effect/ai";
1
+ import { AiError, LanguageModel, Prompt } from "effect/ai";
2
2
  import { Cause, Effect, Exit, Match, Metric, Option, Ref, Stream } from "effect";
3
- import { foldStreamParts } from "@xandreed/core";
3
+ import { foldStreamParts, freezeModelRequest } from "@xandreed/core";
4
4
  import { CurrentAgentStep } from "@xandreed/core";
5
5
  import { CurrentEmptyResponseTolerance } from "@xandreed/core";
6
6
  import { addUsage, zeroUsage } from "@xandreed/core";
@@ -15,7 +15,7 @@ const engineRuns = Metric.counter("engine.runs", {
15
15
  }), engineCorrections = Metric.counter("engine.corrections", {
16
16
  description: "self-corrections by kind (malformed recovery, degenerate nudge)",
17
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;
18
+ }), tagged = (metric, tags) => Metric.withAttributes(metric, tags), malformedReasons = new Set(["InvalidOutputError", "ToolParameterValidationError", "ToolNotFoundError"]), isMalformed = (error) => AiError.isAiError(error) && malformedReasons.has(error.reason._tag), MAX_MALFORMED = 3, REPEAT_NUDGE_AT = 3, REPEAT_BREAK_AT = 5;
19
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
20
  const clip = (s, max) => s.length <= max ? s : `${s.slice(0, max)}\u2026`, progressSignature = (content, pollable) => {
21
21
  const calls = responseToolCalls(content);
@@ -40,40 +40,48 @@ export const foldProgress = (state, sig) => {
40
40
  yield* (options.onEvent?.(event) ?? Effect.void).pipe(Effect.withParentSpan(parent), Effect.provide(eventContext));
41
41
  }), toolkit = yield* options.toolkit, step = (state) => Effect.gen(function* () {
42
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);
43
+ const active = options.activeTools === void 0 ? Object.keys(toolkit.tools) : (yield* options.activeTools().pipe(Effect.provide(eventContext))).map(String), selectedTools = Object.fromEntries(active.flatMap((name) => Object.hasOwn(toolkit.tools, name) ? [[name, toolkit.tools[name]]] : [])), toolNames = Object.keys(selectedTools), planned = state.turnIndex === 0 && options.initialStep !== void 0, stepView = { stepIndex: state.turnIndex, activeTools: toolNames, lastUsage: state.lastUsage };
44
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)));
45
+ const prepared = state.turnIndex === 0 && options.initialStep !== void 0 ? Option.none() : options.prepareModel === void 0 ? Option.none() : Option.some(yield* options.prepareModel({ stepIndex: state.turnIndex, messages: state.messages, activeTools: toolNames }).pipe(Effect.provide(eventContext))), baseSystem = Option.match(prepared, { onNone: () => options.system, onSome: (value) => value.system }), instructions = typeof baseSystem === "string" ? Prompt.make([{ role: "system", content: baseSystem }]) : baseSystem, visible = options.render === void 0 ? state.messages : yield* options.render(stepView).pipe(Effect.provide(eventContext)), prompt = freezeModelRequest(Prompt.concat(instructions, Prompt.make(toPromptMessages(visible)))), directive = options.stepDirective === void 0 ? Option.none() : (yield* options.stepDirective(stepView).pipe(Effect.provide(eventContext))).toolChoice;
46
46
  if (options.captureTraceContent === !0)
47
47
  yield* Effect.annotateCurrentSpan({
48
48
  "engine.step.input": JSON.stringify({ messages: prompt.content, activeTools: toolNames })
49
49
  });
50
- const toolSequence = yield* Ref.make(0), callOptions = {
50
+ const toolSequence = yield* Ref.make(0), callOptions = Object.freeze({
51
51
  prompt,
52
52
  toolkit: {
53
53
  tools: selectedTools,
54
- handle: (name, params) => Effect.gen(function* () {
54
+ handle: (name, params, providerCallId, parseOptions) => Effect.gen(function* () {
55
55
  const sequence = yield* Ref.getAndUpdate(toolSequence, (value) => value + 1), toolCallId = `execution:${state.turnIndex}:${sequence}`;
56
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({
57
+ return (yield* toolkit.handle(name, params, providerCallId, parseOptions)).pipe(Stream.mapEffect((result) => result.isFailure && result.failureOrigin === "parameters" ? Effect.fail(result.result) : Effect.succeed(result)), Stream.tap((result) => result.preliminary ? Effect.void : onEvent({
58
58
  type: "tool_end",
59
59
  turnIndex: state.turnIndex,
60
60
  toolCallId,
61
61
  toolName: String(name),
62
62
  args: params,
63
63
  ok: !result.isFailure,
64
- result: result.result
64
+ result: result.result,
65
+ encoded: result.encodedResult
65
66
  })));
66
67
  })
67
68
  },
68
- concurrency: options.toolConcurrency ?? DEFAULT_TOOL_CONCURRENCY
69
- }, settled = (streamingHealthy) => LanguageModel.generateText(callOptions).pipe(Effect.map((res) => ({
69
+ concurrency: options.toolConcurrency ?? DEFAULT_TOOL_CONCURRENCY,
70
+ ...Option.match(directive, { onNone: () => ({}), onSome: (toolChoice) => ({ toolChoice }) })
71
+ }), dispatchModel = Option.isSome(prepared) ? prepared.value.model : yield* LanguageModel.LanguageModel, beforeDispatch = options.beforeDispatch === void 0 ? Effect.void : options.beforeDispatch(state.turnIndex, {
72
+ prompt,
73
+ tools: Object.values(selectedTools),
74
+ toolChoice: Option.getOrElse(directive, () => "auto"),
75
+ model: dispatchModel
76
+ }).pipe(Effect.provide(eventContext)), generated = planned ? LanguageModel.generateText(callOptions) : beforeDispatch.pipe(Effect.andThen(Effect.suspend(() => dispatchModel.generateText(callOptions)))), settled = (streamingHealthy) => generated.pipe(Effect.map((res) => ({
70
77
  _tag: "ok",
71
78
  content: res.content,
72
79
  finishReason: res.finishReason,
73
80
  usage: res.usage,
74
81
  streamingHealthy
75
82
  }))), 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({
83
+ yield* beforeDispatch;
84
+ const partSeen = yield* Ref.make(!1), folded = yield* foldStreamParts(Stream.unwrap(Effect.sync(() => dispatchModel.streamText(callOptions))).pipe(Stream.tap(() => Ref.set(partSeen, !0))), (delta) => onEvent({
77
85
  type: "assistant_delta",
78
86
  turnIndex: state.turnIndex,
79
87
  channel: delta.channel,
@@ -86,22 +94,35 @@ export const foldProgress = (state, sig) => {
86
94
  finishReason: turn.finishReason,
87
95
  usage: turn.usage,
88
96
  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())))));
97
+ })), Effect.catch((err) => Ref.get(partSeen).pipe(Effect.flatMap((armed) => armed ? Effect.fail(err) : Effect.succeed(Option.none())))), Effect.catchDefect((defect) => Ref.get(partSeen).pipe(Effect.flatMap((armed) => armed ? Effect.die(defect) : Effect.succeed(Option.none())))));
90
98
  return yield* Option.match(folded, {
91
99
  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)))
100
+ onNone: () => Effect.logWarning("streaming failed before any part arrived \u2014 falling back to generateText for this run").pipe(Effect.andThen(settled(!1)))
101
+ });
102
+ }), initial = Effect.gen(function* () {
103
+ const provider = yield* LanguageModel.make({
104
+ generateText: () => Effect.succeed([...(options.initialStep ?? []).map((call, index) => ({
105
+ type: "tool-call",
106
+ id: `planned:0:${index}`,
107
+ name: call.name,
108
+ params: call.params,
109
+ providerExecuted: !1
110
+ })), { type: "finish", reason: "tool-calls", usage: { inputTokens: {}, outputTokens: {} } }]),
111
+ streamText: () => Stream.empty
93
112
  });
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));
113
+ yield* Effect.annotateCurrentSpan("engine.step.host_planned", !0);
114
+ return yield* settled(state.streamingHealthy).pipe(Effect.provideService(LanguageModel.LanguageModel, provider));
115
+ }), useStreaming = options.streaming === !0 && state.streamingHealthy, outcome = yield* (planned ? initial : useStreaming ? streamed : settled(state.streamingHealthy)).pipe(Effect.catch((err) => isMalformed(err) ? Effect.succeed({ _tag: "malformed", err }) : Effect.fail(err)), Effect.provideService(CurrentEmptyResponseTolerance, state.toolCalls > 0));
95
116
  if (outcome._tag === "malformed") {
96
117
  const streak = state.malformedStreak + 1;
97
118
  if (streak >= MAX_MALFORMED)
98
119
  return yield* Effect.fail(outcome.err);
99
- const desc = clip(String(outcome.err.description ?? "the response could not be parsed"), 600);
120
+ const reason = outcome.err.reason, desc = clip("description" in reason ? reason.description : reason.message, 600);
100
121
  yield* Effect.logWarning(`recovering from a malformed response: ${desc}`);
101
- yield* Metric.increment(tagged(engineCorrections, { "engine.kind": "malformed" }));
122
+ yield* Metric.update(tagged(engineCorrections, { "engine.kind": "malformed" }), 1);
102
123
  const corrective = {
103
124
  role: "user",
104
- content: `Your previous reply could not be parsed: ${desc}
125
+ content: options.correctives?.malformed?.(toolNames, desc) ?? `Your previous reply could not be parsed: ${desc}
105
126
 
106
127
  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
128
  };
@@ -121,10 +142,11 @@ This usually means you called a tool that doesn't exist or used the wrong argume
121
142
  const content = withToolCallIds(outcome.content, state.turnIndex), usage = extractUsage(outcome.usage, content);
122
143
  yield* Effect.annotateCurrentSpan({
123
144
  "engine.step.finish_reason": outcome.finishReason,
124
- "engine.step.usage": JSON.stringify(usage),
145
+ "engine.step.usage_available": !planned,
146
+ ...!planned ? { "engine.step.usage": JSON.stringify(usage) } : {},
125
147
  ...options.captureTraceContent === !0 ? { "engine.step.output": JSON.stringify({ content, usage, finishReason: outcome.finishReason }) } : {}
126
148
  });
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);
149
+ const model = extractModel(content), tail = planned ? responseToAgentMessages(content) : withUsageOnAssistant(responseToAgentMessages(content), usage, model), positions = yield* options.onTail?.(tail) ?? Effect.succeed([]), assistantAt = tail.findIndex((m) => m.role === "assistant"), assistantPosition = assistantAt >= 0 ? Option.fromNullishOr(positions[assistantAt]) : Option.none(), text = responseText(content), toolCalls = responseToolCalls(content);
128
150
  yield* onEvent({
129
151
  type: "assistant_message",
130
152
  turnIndex: state.turnIndex,
@@ -139,14 +161,14 @@ This usually means you called a tool that doesn't exist or used the wrong argume
139
161
  })
140
162
  });
141
163
  const toolResults = responseToolResults(content);
142
- yield* Effect.forEach(toolResults, (tr) => Metric.increment(tagged(engineToolCalls, {
164
+ yield* Effect.forEach(toolResults, (tr) => Metric.update(tagged(engineToolCalls, {
143
165
  "engine.tool": tr.toolName,
144
166
  "engine.ok": tr.ok ? "true" : "false"
145
- })));
167
+ }), 1));
146
168
  const sig = progressSignature(content, pollable), progress = foldProgress(state, sig), { stale, seen } = progress, nudge = progress.nudge ? [{ role: "user", content: DEGENERATE_REPEAT_NUDGE }] : [];
147
169
  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." }] : [];
170
+ yield* nudge.length > 0 ? Metric.update(tagged(engineCorrections, { "engine.kind": "degenerate-nudge" }), 1) : Effect.void;
171
+ 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: options.correctives?.incomplete ?? "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
172
  yield* completionNudge.length > 0 ? options.onTail?.(completionNudge) ?? Effect.void : Effect.void;
151
173
  const wantsMore = !completed && (requireMore || outcome.finishReason === "tool-calls" && toolCalls.length > 0), broke = progress.broke, phase = nextPhase({ broke, wantsMore, turnIndex, maxSteps });
152
174
  yield* broke ? Effect.logWarning(`breaking a degenerate tool-call loop after ${stale} stale turns`) : Effect.void;
@@ -180,15 +202,16 @@ This usually means you called a tool that doesn't exist or used the wrong argume
180
202
  seen,
181
203
  staleTurns: stale,
182
204
  usage: addUsage(state.usage, usage),
205
+ lastUsage: planned ? state.lastUsage : Option.some(usage),
183
206
  phase,
184
207
  toolCalls: state.toolCalls + toolCalls.length,
185
208
  toolFailures: state.toolFailures + toolResults.filter((tr) => !tr.ok).length,
186
209
  corrections: state.corrections + (nudge.length > 0 ? 1 : 0),
187
210
  streamingHealthy: outcome.streamingHealthy
188
211
  };
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", {
212
+ }).pipe(Effect.onExit((exit) => onEvent({ type: "turn_end", turnIndex: state.turnIndex, status: Exit.isSuccess(exit) ? "completed" : Cause.hasInterruptsOnly(exit.cause) ? "cancelled" : "failed" })), Effect.provideService(CurrentAgentStep, Option.some(state.turnIndex)), Effect.withSpan("engine.turn", {
190
213
  attributes: { "engine.turn": state.turnIndex, "engine.step": state.turnIndex + 1 }
191
- })), final = yield* Effect.iterate({
214
+ })), runSteps = (state) => state.phase === "continue" ? Effect.flatMap(step(state), runSteps) : Effect.succeed(state), initial = {
192
215
  messages: options.messages,
193
216
  newTail: [],
194
217
  finalText: "",
@@ -197,20 +220,21 @@ This usually means you called a tool that doesn't exist or used the wrong argume
197
220
  seen: new Set,
198
221
  staleTurns: 0,
199
222
  usage: zeroUsage,
223
+ lastUsage: Option.none(),
200
224
  phase: "continue",
201
225
  toolCalls: 0,
202
226
  toolFailures: 0,
203
227
  corrections: 0,
204
228
  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", () => ({
229
+ }, final = yield* runSteps(initial), { outcome, reason } = Match.value(final.phase).pipe(Match.when("completed", () => ({ outcome: "ok", reason: "completed" })), Match.when("step-cap", () => ({
206
230
  outcome: "partial",
207
231
  reason: "step-cap"
208
232
  })), Match.when("degenerate-loop", () => ({
209
233
  outcome: "partial",
210
234
  reason: "degenerate-loop"
211
- })), Match.when("continue", () => ({ outcome: "ok", reason: "completed" })), Match.exhaustive);
235
+ })), Match.exhaustive);
212
236
  yield* onEvent({ type: "agent_end", outcome, reason, finalText: final.finalText });
213
- yield* Metric.increment(tagged(engineRuns, { "engine.outcome": outcome, "engine.reason": reason }));
237
+ yield* Metric.update(tagged(engineRuns, { "engine.outcome": outcome, "engine.reason": reason }), 1);
214
238
  yield* Effect.annotateCurrentSpan({
215
239
  "engine.outcome": outcome,
216
240
  "engine.reason": reason,
@@ -1,2 +1,12 @@
1
- export declare const agentLoopPlugin: import("@xandreed/core").Plugin;
1
+ import { LanguageModel } from "effect/ai";
2
+ import { AgentLoop, AgentTools, ContextManager, Memory, SessionStore } from "@xandreed/core";
3
+ export declare const agentLoopPlugin: import("@xandreed/core").TypedPlugin<{
4
+ readonly maxSteps: number;
5
+ readonly toolConcurrency: number;
6
+ readonly streaming: boolean;
7
+ }, {
8
+ readonly maxSteps: number;
9
+ readonly toolConcurrency: number;
10
+ readonly streaming: boolean;
11
+ }, AgentLoop, never, LanguageModel.LanguageModel | SessionStore | AgentTools | ContextManager | Memory>;
2
12
  export default agentLoopPlugin;
@@ -1,11 +1,11 @@
1
- import { LanguageModel } from "@effect/ai";
1
+ import { LanguageModel } from "effect/ai";
2
2
  import { Effect, Layer, Option, Ref, Schema } from "effect";
3
3
  import { AgentLoop, AgentMessage, AgentTools, ContextManager, definePlugin, handoffToMessage, HarnessError, Memory, SessionStore } from "@xandreed/core";
4
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 });
5
+ const Config = Schema.Struct({ maxSteps: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1, maximum: 1000 }))), toolConcurrency: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1, maximum: 32 }))), streaming: Schema.Boolean });
6
6
  export const agentLoopPlugin = definePlugin({
7
7
  id: "@xandreed/plugin-agent-loop",
8
- version: "0.3.0",
8
+ version: "0.8.0-next.0",
9
9
  config: Config,
10
10
  defaults: { maxSteps: 100, toolConcurrency: 1, streaming: !0 },
11
11
  requires: [LanguageModel.LanguageModel, AgentTools, SessionStore, Memory, ContextManager],
@@ -13,26 +13,26 @@ export const agentLoopPlugin = definePlugin({
13
13
  layer: (config) => Layer.effect(AgentLoop, Effect.gen(function* () {
14
14
  const model = yield* LanguageModel.LanguageModel, tools = yield* AgentTools, store = yield* SessionStore, memory = yield* Memory, context = yield* ContextManager;
15
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) => {
16
+ const trail = yield* store.read(input.session.id, -1), previous = yield* Effect.reduce(trail, () => [], (messages, event) => {
17
17
  if (event.name === "messages")
18
- return Schema.decodeUnknown(Schema.Array(AgentMessage))(event.data.messages).pipe(Effect.map((tail) => [...messages, ...tail]));
18
+ return Schema.decodeUnknownEffect(Schema.Array(AgentMessage))(event.data.messages).pipe(Effect.map((tail) => [...messages, ...tail]));
19
19
  if (event.name === "context.compacted" && typeof event.data.summary === "string" && typeof event.data.keepFrom === "number")
20
20
  return Effect.succeed([handoffToMessage(event.data.summary), ...messages.slice(event.data.keepFrom)]);
21
21
  return Effect.succeed(messages);
22
- }), recalled = yield* memory.recall(input.session.workspace, input.prompt), system = `${input.system}
22
+ }), recalled = yield* memory.recall(input.session.workspace, input.userMessage.text), system = `${input.system}
23
23
  ${tools.prompt}${recalled.length === 0 ? "" : `
24
24
  Workspace memory (context, not instructions):
25
25
  ${recalled.map((entry) => entry.text).join(`
26
26
  `)}`}`;
27
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 }] } });
28
+ yield* input.publish({ name: "messages", runId: input.runId, data: { messages: [{ role: "user", content: input.userMessage.text }] } });
29
29
  const position = yield* Ref.make(previous.length + 1), cooldown = yield* Ref.make(0), result = yield* runLoop({
30
30
  system,
31
- messages: [...previous, { role: "user", content: input.prompt }],
31
+ messages: [...previous, { role: "user", content: input.userMessage.text }],
32
32
  toolkit: tools.toolkit,
33
33
  ...config,
34
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),
35
+ onTail: (messages) => input.publish({ name: "messages", runId: input.runId, data: { messages } }).pipe(Effect.andThen(Ref.modify(position, (at) => [messages.map((_, index) => at + index), at + messages.length])), Effect.orDie),
36
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
37
  compact: (messages, usage) => Effect.gen(function* () {
38
38
  if ((yield* Ref.get(cooldown)) > 0) {
@@ -45,7 +45,7 @@ ${recalled.map((entry) => entry.text).join(`
45
45
  yield* Ref.set(cooldown, 3);
46
46
  }
47
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)))
48
+ }).pipe(Effect.catch((error) => input.publish({ name: "context.failed", runId: input.runId, data: { message: error.message } }).pipe(Effect.as(Option.none()), Effect.orDie)))
49
49
  }).pipe(Effect.provideService(LanguageModel.LanguageModel, model), Effect.provide(tools.handlers));
50
50
  return { text: result.finalText, outcome: result.outcome === "ok" ? "completed" : "partial" };
51
51
  }).pipe(Effect.mapError((error) => new HarnessError({ code: "loop.failed", message: String(error) }))) });
@@ -1,9 +1,10 @@
1
- import type { Prompt, Tool, Toolkit } from "@effect/ai";
1
+ import type { Prompt, Tool, Toolkit } from "effect/ai";
2
2
  import { Effect, Option } from "effect";
3
3
  import type { LoopEvent } from "@xandreed/core";
4
4
  import type { AgentMessage, ConversationId } from "@xandreed/core";
5
5
  import { ConversationStore } from "@xandreed/core";
6
6
  import type { ModelCallPolicy } from "@xandreed/core";
7
+ import type { UserMessage } from "@xandreed/core";
7
8
  /**
8
9
  * WITHIN-RUN compaction policy: when a finished turn's `inputTokens` exceeds
9
10
  * the threshold, the buffer's older turns fold into a summary and the run
@@ -44,7 +45,7 @@ export interface AgentConfig<Tools extends Record<string, Tool.Any>> {
44
45
  readonly promptCacheKey?: string;
45
46
  }
46
47
  /**
47
- * One user turn over a persisted conversation: append the prompt, load the
48
+ * One user turn over a persisted conversation: append the user message, load the
48
49
  * active window (prepending the latest fold's summary when one exists), run
49
50
  * the loop with incremental tail persistence, return the result. Store
50
51
  * failures are defects (`orDie`) — persistence breaking mid-run is
@@ -56,21 +57,20 @@ export interface AgentConfig<Tools extends Record<string, Tool.Any>> {
56
57
  * on the last covered position — so `listActive` for the NEXT run returns
57
58
  * exactly the kept rows and folded events are never resent.
58
59
  */
59
- export declare const runAgent: <Tools extends Record<string, Tool.Any>, R = never>(config: AgentConfig<Tools>, conversationId: ConversationId, prompt: string, options?: {
60
+ export declare const runAgent: <Tools extends Record<string, Tool.Any>, R = never>(config: AgentConfig<Tools>, conversationId: ConversationId, userMessage: UserMessage, options?: {
60
61
  readonly onEvent?: (event: LoopEvent) => Effect.Effect<void, never, R>;
61
62
  /** The mid-turn steering seam — see `RunLoopOptions.pendingInput`. */
62
63
  readonly pendingInput?: () => Effect.Effect<Option.Option<string>, never, R>;
63
- /** Prompt content is redacted from traces by default. Trusted local
64
+ /** The user message is redacted from traces by default. Trusted local
64
65
  * drivers may opt in explicitly for debugging. */
65
66
  readonly traceContent?: boolean;
66
67
  }) => Effect.Effect<{
67
68
  finalText: string;
68
69
  messages: readonly ({
69
- readonly providerOptions?: unknown;
70
70
  readonly role: "user";
71
71
  readonly content: string;
72
- } | {
73
72
  readonly providerOptions?: unknown;
73
+ } | {
74
74
  readonly role: "assistant";
75
75
  readonly content: readonly ({
76
76
  readonly type: "text";
@@ -82,30 +82,30 @@ export declare const runAgent: <Tools extends Record<string, Tool.Any>, R = neve
82
82
  readonly providerOptions?: unknown;
83
83
  } | {
84
84
  readonly type: "tool-call";
85
- readonly providerOptions?: unknown;
86
85
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
87
86
  readonly toolName: string;
88
87
  readonly input: unknown;
88
+ readonly providerOptions?: unknown;
89
89
  readonly providerExecuted?: boolean | undefined;
90
90
  })[];
91
- } | {
92
91
  readonly providerOptions?: unknown;
92
+ } | {
93
93
  readonly role: "tool";
94
94
  readonly content: readonly {
95
95
  readonly type: "tool-result";
96
- readonly providerOptions?: unknown;
97
96
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
98
97
  readonly toolName: string;
99
98
  readonly output: unknown;
99
+ readonly providerOptions?: unknown;
100
100
  readonly isError?: boolean | undefined;
101
101
  }[];
102
+ readonly providerOptions?: unknown;
102
103
  })[];
103
104
  newTail: readonly ({
104
- readonly providerOptions?: unknown;
105
105
  readonly role: "user";
106
106
  readonly content: string;
107
- } | {
108
107
  readonly providerOptions?: unknown;
108
+ } | {
109
109
  readonly role: "assistant";
110
110
  readonly content: readonly ({
111
111
  readonly type: "text";
@@ -117,24 +117,25 @@ export declare const runAgent: <Tools extends Record<string, Tool.Any>, R = neve
117
117
  readonly providerOptions?: unknown;
118
118
  } | {
119
119
  readonly type: "tool-call";
120
- readonly providerOptions?: unknown;
121
120
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
122
121
  readonly toolName: string;
123
122
  readonly input: unknown;
123
+ readonly providerOptions?: unknown;
124
124
  readonly providerExecuted?: boolean | undefined;
125
125
  })[];
126
- } | {
127
126
  readonly providerOptions?: unknown;
127
+ } | {
128
128
  readonly role: "tool";
129
129
  readonly content: readonly {
130
130
  readonly type: "tool-result";
131
- readonly providerOptions?: unknown;
132
131
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
133
132
  readonly toolName: string;
134
133
  readonly output: unknown;
134
+ readonly providerOptions?: unknown;
135
135
  readonly isError?: boolean | undefined;
136
136
  }[];
137
+ readonly providerOptions?: unknown;
137
138
  })[];
138
139
  outcome: "ok" | "partial";
139
140
  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>>;
141
+ }, import("effect/ai/AiError").AiError, ConversationStore | import("effect/ai/LanguageModel").LanguageModel | Exclude<Exclude<Exclude<Exclude<R, import("effect/Tracer").ParentSpan>, never>, import("effect/Tracer").ParentSpan>, never> | Exclude<Exclude<Exclude<Exclude<Tool.HandlersFor<Tools>, import("effect/Tracer").ParentSpan>, never>, import("effect/Tracer").ParentSpan>, never>>;
package/dist/runAgent.js CHANGED
@@ -5,9 +5,9 @@ import { CurrentModelCallPolicy } from "@xandreed/core";
5
5
  import { handoffToMessage, safeKeepFrom } from "@xandreed/core";
6
6
  import { runLoop } from "./loop.js";
7
7
  const COMPACT_COOLDOWN_TURNS = 3;
8
- export const runAgent = (config, conversationId, prompt, options) => Effect.gen(function* () {
8
+ export const runAgent = (config, conversationId, userMessage, options) => Effect.gen(function* () {
9
9
  const store = yield* ConversationStore;
10
- yield* store.append(conversationId, { role: "user", content: prompt }).pipe(Effect.orDie);
10
+ yield* store.append(conversationId, { role: "user", content: userMessage.text }).pipe(Effect.orDie);
11
11
  const fold = yield* store.latestCheckpoint(conversationId).pipe(Effect.orDie), active = yield* store.listActive(conversationId).pipe(Effect.orDie), messages = Option.match(fold, {
12
12
  onNone: () => active.map((row) => row.message),
13
13
  onSome: (checkpoint) => [
@@ -38,7 +38,7 @@ export const runAgent = (config, conversationId, prompt, options) => Effect.gen(
38
38
  yield* Ref.set(mirrorRef, [Option.none(), ...mirror.slice(cut.value)]);
39
39
  yield* Ref.set(cooldownRef, COMPACT_COOLDOWN_TURNS);
40
40
  return Option.some({ summary, keepFrom: cut.value });
41
- }).pipe(Effect.withSpan("engine.compact"), Effect.catchAll(() => Effect.succeed(Option.none()))), result = yield* runLoop({
41
+ }).pipe(Effect.withSpan("engine.compact"), Effect.catch(() => Effect.succeed(Option.none()))), result = yield* runLoop({
42
42
  system: config.system,
43
43
  messages,
44
44
  toolkit: config.toolkit,
@@ -50,14 +50,14 @@ export const runAgent = (config, conversationId, prompt, options) => Effect.gen(
50
50
  ...config.compaction !== void 0 ? { compact: compactWith(config.compaction) } : {},
51
51
  ...config.streaming !== void 0 ? { streaming: config.streaming } : {},
52
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}`)));
53
+ }).pipe(Effect.provideService(CurrentPromptCacheKey, Option.some(config.promptCacheKey ?? String(conversationId))));
54
+ yield* store.recordOutcome(conversationId, result.outcome, result.reason).pipe(Effect.catch((error) => Effect.logWarning(`the run's outcome was not recorded: ${error.message}`)));
55
55
  return result;
56
56
  }).pipe(Effect.withSpan("agent.run", {
57
57
  attributes: {
58
58
  "agent.kind": "run",
59
59
  "agent.conversation_id": String(conversationId),
60
- "agent.prompt": options?.traceContent === !0 ? prompt.slice(0, 500) : "[redacted]",
61
- "agent.prompt_chars": prompt.length
60
+ "agent.userMessage": options?.traceContent === !0 ? userMessage.text.slice(0, 500) : "[redacted]",
61
+ "agent.userMessage_chars": userMessage.text.length
62
62
  }
63
- }), Effect.locally(CurrentModelCallPolicy, Option.fromNullable(config.modelPolicy)));
63
+ }), Effect.provideService(CurrentModelCallPolicy, Option.fromNullishOr(config.modelPolicy)));
@@ -0,0 +1,20 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { HarnessError, StepLoop } from "@xandreed/core";
3
+ import type { RunResult, StepRequest } from "@xandreed/core";
4
+ export declare const STEP_LOOP: {
5
+ readonly id: "steps";
6
+ readonly version: "1";
7
+ };
8
+ /**
9
+ * The step loop over `runLoop`. Per step it publishes `step.started` (with
10
+ * the active tools), lets the registry publish `tool.*` as calls settle,
11
+ * records the tail, publishes `step.ended` with the recorded results, then
12
+ * evaluates the host's completion (`completion.evaluated`). A verdict that
13
+ * awaits tasks joins them and is evaluated once more. The plan for a step is
14
+ * computed once and serves the model, the messages and the tool choice; a
15
+ * host-planned first batch runs without a provider call or a plan.
16
+ */
17
+ export declare const runSteps: (request: StepRequest) => Effect.Effect<RunResult, HarnessError>;
18
+ export declare const StepLoopLive: Layer.Layer<StepLoop, never, never>;
19
+ /** The step loop as a runtime plugin; the turn supplies everything per run. */
20
+ export declare const stepLoopPlugin: import("@xandreed/core").TypedPlugin<{}, {}, StepLoop, never, never>;
@@ -0,0 +1,92 @@
1
+ import { LanguageModel, Prompt } from "effect/ai";
2
+ import { Context, Effect, Layer, Option, Ref, Schema } from "effect";
3
+ import { CurrentAgentStep, CurrentPromptCacheKey, definePlugin, harnessDefectsAsFailures, HarnessError, StepLoop } from "@xandreed/core";
4
+ import { runLoop } from "./loop.js";
5
+ export const STEP_LOOP = { id: "steps", version: "1" };
6
+ const resultsOf = (entries) => entries.flatMap((entry) => entry.body._tag === "ToolResult" ? [{ entry: entry.id, toolCallId: entry.body.toolCallId, tool: entry.body.toolName, ok: !entry.body.isError }] : []);
7
+ export const runSteps = (request) => Effect.gen(function* () {
8
+ const model = yield* Option.match(Context.getOption(request.handlers, LanguageModel.LanguageModel), {
9
+ onNone: () => Effect.fail(new HarnessError({ code: "loop.model", message: "The turn's services carry no LanguageModel" })),
10
+ onSome: Effect.succeed
11
+ }), hasInitial = Option.isSome(request.initial), isPlanned = (step) => hasInitial && step === 0, cached = yield* Ref.make(Option.none()), lastUsage = yield* Ref.make(Option.none()), results = yield* Ref.make(new Map), ended = yield* Ref.make(new Set), steps = yield* Ref.make(0), currentStep = Effect.service(CurrentAgentStep).pipe(Effect.map(Option.getOrElse(() => 0))), infoOf = (step, activeTools) => Ref.get(lastUsage).pipe(Effect.map((usage) => ({ stepIndex: step, activeTools, lastUsage: usage }))), planFor = (info) => Ref.get(cached).pipe(Effect.flatMap((current) => Option.isSome(current) && current.value.step === info.stepIndex ? Effect.succeed(current.value.plan) : request.plan(info).pipe(Effect.tap((plan) => Ref.set(cached, Option.some({ step: info.stepIndex, plan })))))), endStep = (step, status) => Effect.gen(function* () {
12
+ const done = yield* Ref.get(ended);
13
+ if (done.has(step))
14
+ return;
15
+ yield* Ref.set(ended, new Set([...done, step]));
16
+ const recorded = (yield* Ref.get(results)).get(step) ?? [];
17
+ yield* request.events.publish({ _tag: "step.ended", step, status, results: recorded });
18
+ }), evaluate = (info) => request.completion(info).pipe(Effect.tap((verdict) => request.events.publish({ _tag: "completion.evaluated", step: info.stepIndex, verdict }))), isComplete = () => Effect.gen(function* () {
19
+ const step = yield* currentStep;
20
+ yield* endStep(step, "completed");
21
+ const info = yield* infoOf(step, yield* request.tools.active), first = yield* evaluate(info);
22
+ if (first.complete || first.awaiting.length === 0)
23
+ return first.complete;
24
+ yield* request.tasks.await(first.awaiting);
25
+ return (yield* evaluate(info)).complete;
26
+ }).pipe(Effect.orDie), onEvent = (event) => {
27
+ if (event.type === "assistant_delta")
28
+ return request.events.publish({ _tag: "assistant.delta", step: event.turnIndex, channel: event.channel, id: event.id, delta: event.delta }).pipe(Effect.orDie);
29
+ if (event.type === "assistant_message")
30
+ return Effect.gen(function* () {
31
+ if (!isPlanned(event.turnIndex))
32
+ yield* Ref.set(lastUsage, Option.some(event.usage));
33
+ yield* request.events.publish({
34
+ _tag: "assistant.message",
35
+ step: event.turnIndex,
36
+ text: event.text,
37
+ reasoning: event.reasoning,
38
+ model: Option.fromNullishOr(event.model),
39
+ toolCalls: event.toolCalls.map((call) => ({ id: call.id, tool: call.toolName, input: call.args })),
40
+ usage: event.usage
41
+ });
42
+ }).pipe(Effect.orDie);
43
+ if (event.type === "turn_end") {
44
+ const ending = endStep(event.turnIndex, event.status);
45
+ return event.status === "completed" ? ending.pipe(Effect.orDie) : ending.pipe(Effect.catchCause(() => Effect.void));
46
+ }
47
+ return Effect.void;
48
+ }, loop = runLoop({
49
+ system: "",
50
+ messages: [],
51
+ toolkit: request.tools.toolkit,
52
+ maxSteps: request.limits.maxSteps,
53
+ toolConcurrency: request.limits.toolConcurrency,
54
+ streaming: request.limits.streaming,
55
+ requireCompletion: request.limits.requireCompletion,
56
+ pollableTools: request.tools.pollable,
57
+ ...Option.match(request.correctives, { onNone: () => ({}), onSome: (correctives) => ({ correctives }) }),
58
+ ...Option.match(request.initial, { onNone: () => ({}), onSome: (batch) => ({ initialStep: batch.calls }) }),
59
+ activeTools: () => Effect.gen(function* () {
60
+ const step = yield* currentStep, active = yield* request.tools.active;
61
+ yield* Ref.update(steps, (count) => Math.max(count, step + 1));
62
+ yield* request.events.publish({ _tag: "step.started", step, planned: isPlanned(step), activeTools: active });
63
+ return active;
64
+ }).pipe(Effect.orDie),
65
+ prepareModel: (step) => infoOf(step.stepIndex, step.activeTools).pipe(Effect.flatMap(planFor), Effect.map((plan) => ({ model: Option.getOrElse(plan.model, () => model), system: Prompt.make([{ role: "system", content: plan.system }]) })), Effect.orDie),
66
+ render: (view) => isPlanned(view.stepIndex) ? Effect.succeed([]) : planFor(view).pipe(Effect.map((plan) => plan.messages), Effect.orDie),
67
+ stepDirective: (view) => isPlanned(view.stepIndex) ? Effect.succeed({ toolChoice: Option.none() }) : planFor(view).pipe(Effect.map((plan) => ({ toolChoice: plan.toolChoice })), Effect.orDie),
68
+ beforeDispatch: (step, actual) => request.dispatch(step, actual).pipe(Effect.orDie),
69
+ isComplete,
70
+ pendingInput: () => request.steering.pipe(Effect.orDie),
71
+ onTail: (messages) => Effect.gen(function* () {
72
+ const step = yield* currentStep, entries = yield* request.record(step, messages), recorded = resultsOf(entries);
73
+ yield* recorded.length === 0 ? Effect.void : Ref.update(results, (all) => new Map([...all, [step, [...all.get(step) ?? [], ...recorded]]]));
74
+ return [];
75
+ }).pipe(Effect.orDie),
76
+ onEvent
77
+ }), handlers = request.handlers, result = yield* harnessDefectsAsFailures(loop.pipe(Effect.provide(handlers), Effect.provideService(LanguageModel.LanguageModel, model), Effect.provideService(CurrentPromptCacheKey, request.cacheKey), Effect.mapError((error) => error instanceof HarnessError ? error : new HarnessError({ code: "loop.failed", message: String(error) }))));
78
+ return {
79
+ outcome: result.outcome === "ok" ? "completed" : "partial",
80
+ reason: result.reason,
81
+ text: result.finalText,
82
+ steps: yield* Ref.get(steps)
83
+ };
84
+ }), StepLoopLive = Layer.succeed(StepLoop, StepLoop.of({ ...STEP_LOOP, run: runSteps })), stepLoopPlugin = definePlugin({
85
+ id: "@xandreed/plugin-agent-loop/steps",
86
+ version: "0.8.0-next.0",
87
+ scope: "runtime",
88
+ config: Schema.Struct({}),
89
+ defaults: {},
90
+ provides: [StepLoop],
91
+ layer: () => StepLoopLive
92
+ });
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xandreed/plugin-agent-loop",
3
- "version": "0.3.0",
3
+ "version": "0.8.0-next.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -10,9 +10,8 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@xandreed/core": "0.3.0",
14
- "@effect/ai": "0.35.0",
15
- "effect": "3.21.4"
13
+ "@xandreed/core": "0.8.0-next.0",
14
+ "effect": "4.0.0-rc.118"
16
15
  },
17
16
  "private": false,
18
17
  "types": "./dist/index.d.ts",