@xandreed/plugin-agent-loop 0.12.0 → 0.14.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.12.0. Node 24 or Bun.
3
+ Efferent composable agent framework, 0.14.0. Node 24 or Bun.
4
4
 
5
5
  Documentation: https://github.com/xandreeddev/efferent
package/dist/index.d.ts CHANGED
@@ -1,4 +1,2 @@
1
1
  export * from "./loop.js";
2
- export * from "./runAgent.js";
3
- export { agentLoopPlugin, default } from "./plugin.adapter.js";
4
- export { runSteps, STEP_LOOP, StepLoopLive, stepLoopPlugin } from "./step-loop.adapter.js";
2
+ export { runSteps, STEP_LOOP, StepLoopLive, stepLoopPlugin, stepLoopPlugin as default } from "./step-loop.adapter.js";
package/dist/index.js CHANGED
@@ -1,4 +1,2 @@
1
1
  export * from "./loop.js";
2
- export * from "./runAgent.js";
3
- export { agentLoopPlugin, default } from "./plugin.adapter.js";
4
- export { runSteps, STEP_LOOP, StepLoopLive, stepLoopPlugin } from "./step-loop.adapter.js";
2
+ export { runSteps, STEP_LOOP, StepLoopLive, stepLoopPlugin, stepLoopPlugin as default } from "./step-loop.adapter.js";
package/dist/loop.d.ts CHANGED
@@ -46,6 +46,13 @@ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
46
46
  }, AiError.AiError, R>;
47
47
  /** Validate independently reconstructed durable input immediately before a provider dispatch. */
48
48
  readonly beforeDispatch?: (step: number, request: ModelDispatch) => Effect.Effect<void, never, R>;
49
+ /**
50
+ * A provider refused a step's request over an image (an `InvalidRequestError`
51
+ * that names one; the argument is what it said). True: the images are
52
+ * dealt with (memory omits them), and the loop renders the step again and
53
+ * sends it once more; false, or absent: the step fails with the refusal.
54
+ */
55
+ readonly onMediaRejected?: (reason: string) => Effect.Effect<boolean, never, R>;
49
56
  readonly system: string | Prompt.Prompt;
50
57
  /** The READ side of memory: when present, each provider step sends these
51
58
  * messages instead of the loop's own buffer. Persistence still flows through
@@ -60,8 +67,11 @@ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
60
67
  readonly malformed?: (tools: ReadonlyArray<string>, description: string) => string;
61
68
  readonly incomplete?: string;
62
69
  };
63
- /** Explicit opt-in: export prompt and result content on step spans.
64
- * Hosts own consent, redaction and exporter retention. Disabled by default. */
70
+ /** Explicit opt-in: export prompt and result content on step spans
71
+ * (`engine.step.input`, `engine.step.output`), and to the model calls
72
+ * through `CurrentTraceContent`, which the loop provides for the run.
73
+ * Hosts own consent, redaction and exporter retention. Defaults to
74
+ * `CurrentTraceContent` where the run starts: off unless provided. */
65
75
  readonly captureTraceContent?: boolean;
66
76
  readonly messages: ReadonlyArray<AgentMessage>;
67
77
  readonly toolkit: Toolkit.Toolkit<Tools>;
@@ -108,7 +118,8 @@ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
108
118
  * settled turn shape while fanning `assistant_delta` events as tokens
109
119
  * flow. Default FALSE. A stream that fails before its first part
110
120
  * (scripted providers' `Stream.die` included) falls back to
111
- * `generateText` transparently — for the REST of the run, not per turn.
121
+ * `generateText` — for the REST of the run, not per turn — and emits one
122
+ * `stream_fallback` event with the stream's error.
112
123
  */
113
124
  readonly streaming?: boolean;
114
125
  /**
@@ -164,7 +175,19 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
164
175
  finalText: string;
165
176
  messages: readonly ({
166
177
  readonly role: "user";
167
- readonly content: string;
178
+ readonly content: string | readonly ({
179
+ readonly type: "media";
180
+ readonly mediaType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
181
+ readonly data: string;
182
+ readonly fingerprint: string & import("effect/Brand").Brand<"MediaFingerprint">;
183
+ readonly bytes: number;
184
+ readonly name?: string;
185
+ readonly alt?: string;
186
+ } | {
187
+ readonly type: "text";
188
+ readonly text: string;
189
+ readonly providerOptions?: unknown;
190
+ })[];
168
191
  readonly providerOptions?: unknown;
169
192
  } | {
170
193
  readonly role: "assistant";
@@ -178,9 +201,9 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
178
201
  readonly providerOptions?: unknown;
179
202
  } | {
180
203
  readonly type: "tool-call";
204
+ readonly input: unknown;
181
205
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
182
206
  readonly toolName: string;
183
- readonly input: unknown;
184
207
  readonly providerOptions?: unknown;
185
208
  readonly providerExecuted?: boolean | undefined;
186
209
  })[];
@@ -189,9 +212,18 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
189
212
  readonly role: "tool";
190
213
  readonly content: readonly {
191
214
  readonly type: "tool-result";
215
+ readonly output: unknown;
192
216
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
193
217
  readonly toolName: string;
194
- readonly output: unknown;
218
+ readonly media?: readonly {
219
+ readonly type: "media";
220
+ readonly mediaType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
221
+ readonly data: string;
222
+ readonly fingerprint: string & import("effect/Brand").Brand<"MediaFingerprint">;
223
+ readonly bytes: number;
224
+ readonly name?: string;
225
+ readonly alt?: string;
226
+ }[];
195
227
  readonly providerOptions?: unknown;
196
228
  readonly isError?: boolean | undefined;
197
229
  }[];
@@ -199,7 +231,19 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
199
231
  })[];
200
232
  newTail: readonly ({
201
233
  readonly role: "user";
202
- readonly content: string;
234
+ readonly content: string | readonly ({
235
+ readonly type: "media";
236
+ readonly mediaType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
237
+ readonly data: string;
238
+ readonly fingerprint: string & import("effect/Brand").Brand<"MediaFingerprint">;
239
+ readonly bytes: number;
240
+ readonly name?: string;
241
+ readonly alt?: string;
242
+ } | {
243
+ readonly type: "text";
244
+ readonly text: string;
245
+ readonly providerOptions?: unknown;
246
+ })[];
203
247
  readonly providerOptions?: unknown;
204
248
  } | {
205
249
  readonly role: "assistant";
@@ -213,9 +257,9 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
213
257
  readonly providerOptions?: unknown;
214
258
  } | {
215
259
  readonly type: "tool-call";
260
+ readonly input: unknown;
216
261
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
217
262
  readonly toolName: string;
218
- readonly input: unknown;
219
263
  readonly providerOptions?: unknown;
220
264
  readonly providerExecuted?: boolean | undefined;
221
265
  })[];
@@ -224,9 +268,18 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
224
268
  readonly role: "tool";
225
269
  readonly content: readonly {
226
270
  readonly type: "tool-result";
271
+ readonly output: unknown;
227
272
  readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
228
273
  readonly toolName: string;
229
- readonly output: unknown;
274
+ readonly media?: readonly {
275
+ readonly type: "media";
276
+ readonly mediaType: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
277
+ readonly data: string;
278
+ readonly fingerprint: string & import("effect/Brand").Brand<"MediaFingerprint">;
279
+ readonly bytes: number;
280
+ readonly name?: string;
281
+ readonly alt?: string;
282
+ }[];
230
283
  readonly providerOptions?: unknown;
231
284
  readonly isError?: boolean | undefined;
232
285
  }[];
@@ -234,4 +287,12 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
234
287
  })[];
235
288
  outcome: "ok" | "partial";
236
289
  reason: "completed" | "step-cap" | "degenerate-loop";
290
+ usage: {
291
+ readonly inputTokens: number;
292
+ readonly outputTokens: number;
293
+ readonly totalTokens: number;
294
+ readonly cacheReadTokens: number;
295
+ readonly cacheWriteTokens: number;
296
+ readonly reasoningTokens: number;
297
+ };
237
298
  }, AiError.AiError, LanguageModel.LanguageModel | Exclude<R, import("effect/Tracer").ParentSpan> | Exclude<Tool.HandlersFor<Tools>, import("effect/Tracer").ParentSpan>>;
package/dist/loop.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { AiError, LanguageModel, Prompt } from "effect/ai";
2
- import { Cause, Effect, Exit, Match, Metric, Option, Ref, Stream } from "effect";
3
- import { foldStreamParts, freezeModelRequest } from "@xandreed/core";
2
+ import { Cause, Effect, Exit, Formatter, Match, Metric, Option, Ref, Result, Stream } from "effect";
3
+ import { CurrentTraceContent, foldStreamParts, freezeModelRequest, replayableContext } from "@xandreed/core";
4
4
  import { CurrentAgentStep } from "@xandreed/core";
5
5
  import { CurrentEmptyResponseTolerance } from "@xandreed/core";
6
- import { addUsage, zeroUsage } from "@xandreed/core";
6
+ import { addUsage, elideMediaData, isMediaValue, zeroUsage } from "@xandreed/core";
7
7
  import { extractModel, extractUsage, handoffToMessage, responseReasoning, responseText, responseToAgentMessages, responseToolCalls, responseToolResults, toPromptMessages, withToolCallIds, withUsageOnAssistant } from "@xandreed/core";
8
8
  export const DEFAULT_MAX_STEPS = 100, DEFAULT_TOOL_CONCURRENCY = 4;
9
9
  const engineRuns = Metric.counter("engine.runs", {
@@ -13,11 +13,24 @@ const engineRuns = Metric.counter("engine.runs", {
13
13
  description: "tool calls by tool name and result",
14
14
  incremental: !0
15
15
  }), engineCorrections = Metric.counter("engine.corrections", {
16
- description: "self-corrections by kind (malformed recovery, degenerate nudge)",
16
+ description: "self-corrections by kind (malformed recovery, degenerate nudge, stream fallback, image refused and resent)",
17
17
  incremental: !0
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;
18
+ }), tagged = (metric, tags) => Metric.withAttributes(metric, tags), stepUsageAttributes = (usage) => ({
19
+ "gen_ai.usage.input_tokens": usage.inputTokens,
20
+ "gen_ai.usage.output_tokens": usage.outputTokens,
21
+ "gen_ai.usage.cache_read.input_tokens": usage.cacheReadTokens,
22
+ "gen_ai.usage.cache_creation.input_tokens": usage.cacheWriteTokens,
23
+ "gen_ai.usage.reasoning.output_tokens": usage.reasoningTokens
24
+ }), runUsageAttributes = (usage) => ({
25
+ "engine.usage.input_tokens": usage.inputTokens,
26
+ "engine.usage.output_tokens": usage.outputTokens,
27
+ "engine.usage.cache_read_tokens": usage.cacheReadTokens,
28
+ "engine.usage.cache_write_tokens": usage.cacheWriteTokens,
29
+ "engine.usage.reasoning_tokens": usage.reasoningTokens,
30
+ "engine.usage.total_tokens": usage.totalTokens
31
+ }), malformedReasons = new Set(["InvalidOutputError", "ToolParameterValidationError", "ToolNotFoundError"]), isMalformed = (error) => AiError.isAiError(error) && malformedReasons.has(error.reason._tag), isImageRejection = (error) => AiError.isAiError(error) && error.reason._tag === "InvalidRequestError" && /image/i.test(error.reason.message), MAX_MALFORMED = 3, REPEAT_NUDGE_AT = 3, REPEAT_BREAK_AT = 5;
19
32
  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) => {
33
+ const clip = (s, max) => s.length <= max ? s : `${s.slice(0, max)}\u2026`, FALLBACK_MESSAGE_CHARS = 300, failureText = (failure) => typeof failure === "string" ? failure : Formatter.format(failure), progressSignature = (content, pollable) => {
21
34
  const calls = responseToolCalls(content);
22
35
  if (calls.length === 0)
23
36
  return "";
@@ -33,86 +46,93 @@ export const foldProgress = (state, sig) => {
33
46
  nudge: sig !== "" && stale === REPEAT_NUDGE_AT,
34
47
  broke: sig !== "" && stale >= REPEAT_BREAK_AT
35
48
  };
36
- }, nextPhase = (facts) => facts.broke ? "degenerate-loop" : !facts.wantsMore ? "completed" : facts.turnIndex >= facts.maxSteps ? "step-cap" : "continue", runLoop = (options) => Effect.gen(function* () {
49
+ }, nextPhase = (facts) => facts.broke ? "degenerate-loop" : !facts.wantsMore ? "completed" : facts.turnIndex >= facts.maxSteps ? "step-cap" : "continue";
50
+ const tracedJson = (value) => JSON.stringify(value, (_key, field) => isMediaValue(field) ? elideMediaData(field) : typeof field === "object" && field !== null && ("type" in field) && field.type === "file" && ("data" in field) && typeof field.data === "string" ? { ...field, data: `[${field.data.length} base64 characters]` } : field);
51
+ export const runLoop = (options) => Effect.gen(function* () {
37
52
  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* () {
53
+ const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS, pollable = new Set(options.pollableTools ?? []), captureTraceContent = options.captureTraceContent ?? (yield* Effect.service(CurrentTraceContent)), eventContext = replayableContext(yield* Effect.context()), onEvent = (event) => (options.onEvent?.(event) ?? Effect.void).pipe(Effect.provide(eventContext)), toolkit = yield* options.toolkit, step = (state) => Effect.gen(function* () {
42
54
  yield* onEvent({ type: "turn_start", turnIndex: state.turnIndex });
43
55
  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
56
  yield* Effect.annotateCurrentSpan({ "engine.tools.active": toolNames });
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
- 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 = Object.freeze({
51
- prompt,
52
- toolkit: {
53
- tools: selectedTools,
54
- handle: (name, params, providerCallId, parseOptions) => 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, 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
- 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
- encoded: result.encodedResult
66
- })));
67
- })
68
- },
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) => ({
77
- _tag: "ok",
78
- content: res.content,
79
- finishReason: res.finishReason,
80
- usage: res.usage,
81
- streamingHealthy
82
- }))), streamed = Effect.gen(function* () {
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({
85
- type: "assistant_delta",
86
- turnIndex: state.turnIndex,
87
- channel: delta.channel,
88
- id: delta.id,
89
- delta: delta.delta,
90
- ...delta.toolName === void 0 ? {} : { toolName: delta.toolName }
91
- })).pipe(Effect.map((turn) => Option.some({
57
+ 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, directive = options.stepDirective === void 0 ? Option.none() : (yield* options.stepDirective(stepView).pipe(Effect.provide(eventContext))).toolChoice, partSeen = yield* Ref.make(!1), attempt = Effect.gen(function* () {
58
+ const visible = options.render === void 0 ? state.messages : yield* options.render(stepView).pipe(Effect.provide(eventContext)), prompt = freezeModelRequest(Prompt.concat(instructions, Prompt.make(toPromptMessages(visible))));
59
+ if (captureTraceContent)
60
+ yield* Effect.annotateCurrentSpan({
61
+ "engine.step.input": tracedJson({ messages: prompt.content, activeTools: toolNames })
62
+ });
63
+ const toolSequence = yield* Ref.make(0), callOptions = Object.freeze({
64
+ prompt,
65
+ toolkit: {
66
+ tools: selectedTools,
67
+ handle: (name, params, providerCallId, parseOptions) => Effect.gen(function* () {
68
+ const sequence = yield* Ref.getAndUpdate(toolSequence, (value) => value + 1), toolCallId = `execution:${state.turnIndex}:${sequence}`;
69
+ yield* onEvent({ type: "tool_start", turnIndex: state.turnIndex, toolCallId, toolName: String(name), args: params });
70
+ 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({
71
+ type: "tool_end",
72
+ turnIndex: state.turnIndex,
73
+ toolCallId,
74
+ toolName: String(name),
75
+ args: params,
76
+ ok: !result.isFailure,
77
+ result: result.result,
78
+ encoded: result.encodedResult
79
+ })));
80
+ })
81
+ },
82
+ concurrency: options.toolConcurrency ?? DEFAULT_TOOL_CONCURRENCY,
83
+ ...Option.match(directive, { onNone: () => ({}), onSome: (toolChoice) => ({ toolChoice }) })
84
+ }), dispatchModel = Option.isSome(prepared) ? prepared.value.model : yield* LanguageModel.LanguageModel, beforeDispatch = options.beforeDispatch === void 0 ? Effect.void : options.beforeDispatch(state.turnIndex, {
85
+ prompt,
86
+ tools: Object.values(selectedTools),
87
+ toolChoice: Option.getOrElse(directive, () => "auto"),
88
+ model: dispatchModel
89
+ }).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) => ({
92
90
  _tag: "ok",
93
- content: turn.content,
94
- finishReason: turn.finishReason,
95
- usage: turn.usage,
96
- streamingHealthy: !0
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())))));
98
- return yield* Option.match(folded, {
99
- onSome: Effect.succeed,
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
112
- });
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));
91
+ content: res.content,
92
+ finishReason: res.finishReason,
93
+ usage: res.usage,
94
+ streamingHealthy
95
+ }))), streamed = Effect.gen(function* () {
96
+ yield* beforeDispatch;
97
+ const folded = yield* foldStreamParts(Stream.unwrap(Effect.sync(() => dispatchModel.streamText(callOptions))).pipe(Stream.tap(() => Ref.set(partSeen, !0))), (delta) => onEvent({
98
+ type: "assistant_delta",
99
+ turnIndex: state.turnIndex,
100
+ channel: delta.channel,
101
+ id: delta.id,
102
+ delta: delta.delta,
103
+ ...delta.toolName === void 0 ? {} : { toolName: delta.toolName }
104
+ })).pipe(Effect.map((turn) => Result.succeed({
105
+ _tag: "ok",
106
+ content: turn.content,
107
+ finishReason: turn.finishReason,
108
+ usage: turn.usage,
109
+ streamingHealthy: !0
110
+ })), Effect.catch((err) => Ref.get(partSeen).pipe(Effect.flatMap((armed) => armed || isImageRejection(err) ? Effect.fail(err) : Effect.succeed(Result.fail(failureText(err)))))), Effect.catchDefect((defect) => Ref.get(partSeen).pipe(Effect.flatMap((armed) => armed ? Effect.die(defect) : Effect.succeed(Result.fail(failureText(defect)))))));
111
+ return yield* Result.match(folded, {
112
+ onSuccess: Effect.succeed,
113
+ onFailure: (failure) => Effect.logWarning("streaming failed before any part arrived \u2014 falling back to generateText for this run").pipe(Effect.andThen(onEvent({ type: "stream_fallback", turnIndex: state.turnIndex, message: failure.slice(0, FALLBACK_MESSAGE_CHARS) })), Effect.andThen(Metric.update(tagged(engineCorrections, { "engine.kind": "stream-fallback" }), 1)), Effect.andThen(settled(!1)))
114
+ });
115
+ }), initial = Effect.gen(function* () {
116
+ const provider = yield* LanguageModel.make({
117
+ generateText: () => Effect.succeed([...(options.initialStep ?? []).map((call, index) => ({
118
+ type: "tool-call",
119
+ id: `planned:0:${index}`,
120
+ name: call.name,
121
+ params: call.params,
122
+ providerExecuted: !1
123
+ })), { type: "finish", reason: "tool-calls", usage: { inputTokens: {}, outputTokens: {} } }]),
124
+ streamText: () => Stream.empty
125
+ });
126
+ yield* Effect.annotateCurrentSpan("engine.step.host_planned", !0);
127
+ return yield* settled(state.streamingHealthy).pipe(Effect.provideService(LanguageModel.LanguageModel, provider));
128
+ }), useStreaming = options.streaming === !0 && state.streamingHealthy;
129
+ return yield* planned ? initial : useStreaming ? streamed : settled(state.streamingHealthy);
130
+ }), outcome = yield* attempt.pipe(Effect.catch((err) => Effect.gen(function* () {
131
+ const onMediaRejected = options.onMediaRejected, armed = yield* Ref.get(partSeen);
132
+ if (planned || armed || onMediaRejected === void 0 || !isImageRejection(err))
133
+ return yield* Effect.fail(err);
134
+ return yield* onMediaRejected(clip(err.reason.message, 600)).pipe(Effect.provide(eventContext), Effect.flatMap((retry) => retry ? Effect.logWarning(`the provider refused an image; sending the step again with its images as labels: ${clip(err.reason.message, 300)}`).pipe(Effect.andThen(Metric.update(tagged(engineCorrections, { "engine.kind": "media-rejected" }), 1)), Effect.andThen(attempt)) : Effect.fail(err)));
135
+ })), Effect.catch((err) => isMalformed(err) ? Effect.succeed({ _tag: "malformed", err }) : Effect.fail(err)), Effect.provideService(CurrentEmptyResponseTolerance, state.toolCalls > 0));
116
136
  if (outcome._tag === "malformed") {
117
137
  const streak = state.malformedStreak + 1;
118
138
  if (streak >= MAX_MALFORMED)
@@ -143,8 +163,8 @@ This usually means you called a tool that doesn't exist or used the wrong argume
143
163
  yield* Effect.annotateCurrentSpan({
144
164
  "engine.step.finish_reason": outcome.finishReason,
145
165
  "engine.step.usage_available": !planned,
146
- ...!planned ? { "engine.step.usage": JSON.stringify(usage) } : {},
147
- ...options.captureTraceContent === !0 ? { "engine.step.output": JSON.stringify({ content, usage, finishReason: outcome.finishReason }) } : {}
166
+ ...!planned ? { "engine.step.usage": JSON.stringify(usage), ...stepUsageAttributes(usage) } : {},
167
+ ...captureTraceContent ? { "engine.step.output": tracedJson({ content, usage, finishReason: outcome.finishReason }) } : {}
148
168
  });
149
169
  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);
150
170
  yield* onEvent({
@@ -226,7 +246,7 @@ This usually means you called a tool that doesn't exist or used the wrong argume
226
246
  toolFailures: 0,
227
247
  corrections: 0,
228
248
  streamingHealthy: !0
229
- }, final = yield* runSteps(initial), { outcome, reason } = Match.value(final.phase).pipe(Match.when("completed", () => ({ outcome: "ok", reason: "completed" })), Match.when("step-cap", () => ({
249
+ }, final = yield* runSteps(initial).pipe(Effect.provideService(CurrentTraceContent, captureTraceContent)), { outcome, reason } = Match.value(final.phase).pipe(Match.when("completed", () => ({ outcome: "ok", reason: "completed" })), Match.when("step-cap", () => ({
230
250
  outcome: "partial",
231
251
  reason: "step-cap"
232
252
  })), Match.when("degenerate-loop", () => ({
@@ -239,7 +259,7 @@ This usually means you called a tool that doesn't exist or used the wrong argume
239
259
  "engine.outcome": outcome,
240
260
  "engine.reason": reason,
241
261
  "engine.turns": final.turnIndex,
242
- "engine.usage.total_tokens": final.usage.totalTokens,
262
+ ...runUsageAttributes(final.usage),
243
263
  "engine.tool_calls": final.toolCalls,
244
264
  "engine.tool_calls.failed": final.toolFailures,
245
265
  "engine.corrections": final.corrections,
@@ -250,6 +270,7 @@ This usually means you called a tool that doesn't exist or used the wrong argume
250
270
  messages: final.messages,
251
271
  newTail: final.newTail,
252
272
  outcome,
253
- reason
273
+ reason,
274
+ usage: final.usage
254
275
  };
255
276
  }).pipe(Effect.withSpan("engine.run"));
@@ -12,7 +12,12 @@ export declare const STEP_LOOP: {
12
12
  * evaluates the host's completion (`completion.evaluated`). A verdict that
13
13
  * awaits tasks joins them and is evaluated once more. The plan for a step is
14
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.
15
+ * host-planned first batch runs without a provider call or a plan. A stream
16
+ * that fails before its first part publishes `stream.fallback` once, and the
17
+ * run goes on without streaming. The run's `captureTraceContent` is
18
+ * `CurrentTraceContent` for its model calls. A request a provider refuses
19
+ * over an image goes to `request.onMediaRejected`; when it records an
20
+ * `OmitMedia`, the step is planned again and sent once more.
16
21
  */
17
22
  export declare const runSteps: (request: StepRequest) => Effect.Effect<RunResult, HarnessError>;
18
23
  export declare const StepLoopLive: Layer.Layer<StepLoop, never, never>;
@@ -40,6 +40,8 @@ export const runSteps = (request) => Effect.gen(function* () {
40
40
  usage: event.usage
41
41
  });
42
42
  }).pipe(Effect.orDie);
43
+ if (event.type === "stream_fallback")
44
+ return request.events.publish({ _tag: "stream.fallback", step: event.turnIndex, message: event.message }).pipe(Effect.orDie);
43
45
  if (event.type === "turn_end") {
44
46
  const ending = endStep(event.turnIndex, event.status);
45
47
  return event.status === "completed" ? ending.pipe(Effect.orDie) : ending.pipe(Effect.catchCause(() => Effect.void));
@@ -53,6 +55,7 @@ export const runSteps = (request) => Effect.gen(function* () {
53
55
  toolConcurrency: request.limits.toolConcurrency,
54
56
  streaming: request.limits.streaming,
55
57
  requireCompletion: request.limits.requireCompletion,
58
+ captureTraceContent: request.captureTraceContent,
56
59
  pollableTools: request.tools.pollable,
57
60
  ...Option.match(request.correctives, { onNone: () => ({}), onSome: (correctives) => ({ correctives }) }),
58
61
  ...Option.match(request.initial, { onNone: () => ({}), onSome: (batch) => ({ initialStep: batch.calls }) }),
@@ -66,6 +69,7 @@ export const runSteps = (request) => Effect.gen(function* () {
66
69
  render: (view) => isPlanned(view.stepIndex) ? Effect.succeed([]) : planFor(view).pipe(Effect.map((plan) => plan.messages), Effect.orDie),
67
70
  stepDirective: (view) => isPlanned(view.stepIndex) ? Effect.succeed({ toolChoice: Option.none() }) : planFor(view).pipe(Effect.map((plan) => ({ toolChoice: plan.toolChoice })), Effect.orDie),
68
71
  beforeDispatch: (step, actual) => request.dispatch(step, actual).pipe(Effect.orDie),
72
+ onMediaRejected: (reason) => request.onMediaRejected(reason).pipe(Effect.tap((retry) => retry ? Ref.set(cached, Option.none()) : Effect.void), Effect.orDie),
69
73
  isComplete,
70
74
  pendingInput: () => request.steering.pipe(Effect.orDie),
71
75
  onTail: (messages) => Effect.gen(function* () {
@@ -79,11 +83,12 @@ export const runSteps = (request) => Effect.gen(function* () {
79
83
  outcome: result.outcome === "ok" ? "completed" : "partial",
80
84
  reason: result.reason,
81
85
  text: result.finalText,
82
- steps: yield* Ref.get(steps)
86
+ steps: yield* Ref.get(steps),
87
+ usage: result.usage
83
88
  };
84
89
  }), StepLoopLive = Layer.succeed(StepLoop, StepLoop.of({ ...STEP_LOOP, run: runSteps })), stepLoopPlugin = definePlugin({
85
90
  id: "@xandreed/plugin-agent-loop/steps",
86
- version: "0.12.0",
91
+ version: "0.14.0",
87
92
  scope: "runtime",
88
93
  config: Schema.Struct({}),
89
94
  defaults: {},
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@xandreed/plugin-agent-loop",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
+ "engines": {
5
+ "node": ">=24",
6
+ "bun": ">=1.3.0"
7
+ },
4
8
  "type": "module",
5
9
  "main": "./dist/index.js",
6
10
  "exports": {
@@ -10,8 +14,8 @@
10
14
  }
11
15
  },
12
16
  "dependencies": {
13
- "@xandreed/core": "0.12.0",
14
- "effect": "4.0.0-rc.118"
17
+ "@xandreed/core": "0.14.0",
18
+ "effect": "4.0.7"
15
19
  },
16
20
  "private": false,
17
21
  "types": "./dist/index.d.ts",
@@ -22,9 +26,5 @@
22
26
  "README.md",
23
27
  "LICENSE"
24
28
  ],
25
- "engines": {
26
- "node": ">=24",
27
- "bun": ">=1.3.0"
28
- },
29
29
  "license": "MIT"
30
30
  }
@@ -1,12 +0,0 @@
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>;
12
- export default agentLoopPlugin;
@@ -1,54 +0,0 @@
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.check(Schema.isBetween({ minimum: 1, maximum: 1000 }))), toolConcurrency: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 1, maximum: 32 }))), streaming: Schema.Boolean });
6
- export const agentLoopPlugin = definePlugin({
7
- id: "@xandreed/plugin-agent-loop",
8
- version: "0.12.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.decodeUnknownEffect(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.userMessage.text), 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.userMessage.text }] } });
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.userMessage.text }],
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.andThen(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.catch((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;
@@ -1,141 +0,0 @@
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
- import type { UserMessage } from "@xandreed/core";
8
- /**
9
- * WITHIN-RUN compaction policy: when a finished turn's `inputTokens` exceeds
10
- * the threshold, the buffer's older turns fold into a summary and the run
11
- * continues on summary + the last `keepTurns` assistant turns verbatim (the
12
- * kept tail is ALSO covered by the summary — the overlap that survives a
13
- * boundary). `summarize` sees the whole buffer; any failure or an empty
14
- * summary skips the fold (best-effort — the run continues unfolded).
15
- */
16
- export interface CompactionPolicy {
17
- readonly thresholdTokens: number;
18
- readonly keepTurns: number;
19
- readonly summarize: (transcript: ReadonlyArray<AgentMessage>, previous: Option.Option<string>) => Effect.Effect<string, unknown>;
20
- }
21
- /**
22
- * An agent is a system prompt + a toolkit (+ its loop bounds). The driver
23
- * decides which config runs; the engine stays agent-agnostic.
24
- */
25
- export interface AgentConfig<Tools extends Record<string, Tool.Any>> {
26
- readonly system: string | Prompt.Prompt;
27
- readonly toolkit: Toolkit.Toolkit<Tools>;
28
- readonly maxSteps?: number;
29
- /** Handler concurrency within one model turn. Agents with mutating tools
30
- * should set this to 1 unless their toolkit provides stronger scheduling. */
31
- readonly toolConcurrency?: number;
32
- readonly pollableTools?: ReadonlyArray<string>;
33
- readonly compaction?: CompactionPolicy;
34
- /** Stream turns (`assistant_delta` events while tokens flow); falls back
35
- * to settled turns on a pre-first-part failure. Default false. */
36
- readonly streaming?: boolean;
37
- /** Per-agent output/reasoning policy. Dedicated agents pin this instead of
38
- * inheriting provider-family defaults. */
39
- readonly modelPolicy?: ModelCallPolicy;
40
- /** Server-side prompt-cache lane override. Defaults to the conversation id
41
- * (one lane per conversation); an agent whose system prompt is a shared
42
- * cacheable prefix may key by ITS hash instead so prefill cache hits fire
43
- * across conversations (probe-verified safe under concurrency on the
44
- * codex route, 2026-07-17). */
45
- readonly promptCacheKey?: string;
46
- }
47
- /**
48
- * One user turn over a persisted conversation: append the user message, load the
49
- * active window (prepending the latest fold's summary when one exists), run
50
- * the loop with incremental tail persistence, return the result. Store
51
- * failures are defects (`orDie`) — persistence breaking mid-run is
52
- * infrastructure death, not a condition the model can correct.
53
- *
54
- * With a `compaction` policy, runAgent also owns the store reconciliation
55
- * for mid-run folds: a POSITION MIRROR tracks each buffer entry's durable
56
- * position (None for synthetic handoffs), and a fold writes `checkpointAt`
57
- * on the last covered position — so `listActive` for the NEXT run returns
58
- * exactly the kept rows and folded events are never resent.
59
- */
60
- export declare const runAgent: <Tools extends Record<string, Tool.Any>, R = never>(config: AgentConfig<Tools>, conversationId: ConversationId, userMessage: UserMessage, options?: {
61
- readonly onEvent?: (event: LoopEvent) => Effect.Effect<void, never, R>;
62
- /** The mid-turn steering seam — see `RunLoopOptions.pendingInput`. */
63
- readonly pendingInput?: () => Effect.Effect<Option.Option<string>, never, R>;
64
- /** The user message is redacted from traces by default. Trusted local
65
- * drivers may opt in explicitly for debugging. */
66
- readonly traceContent?: boolean;
67
- }) => Effect.Effect<{
68
- finalText: string;
69
- messages: readonly ({
70
- readonly role: "user";
71
- readonly content: string;
72
- readonly providerOptions?: unknown;
73
- } | {
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 toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
86
- readonly toolName: string;
87
- readonly input: unknown;
88
- readonly providerOptions?: unknown;
89
- readonly providerExecuted?: boolean | undefined;
90
- })[];
91
- readonly providerOptions?: unknown;
92
- } | {
93
- readonly role: "tool";
94
- readonly content: readonly {
95
- readonly type: "tool-result";
96
- readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
97
- readonly toolName: string;
98
- readonly output: unknown;
99
- readonly providerOptions?: unknown;
100
- readonly isError?: boolean | undefined;
101
- }[];
102
- readonly providerOptions?: unknown;
103
- })[];
104
- newTail: readonly ({
105
- readonly role: "user";
106
- readonly content: string;
107
- readonly providerOptions?: unknown;
108
- } | {
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 toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
121
- readonly toolName: string;
122
- readonly input: unknown;
123
- readonly providerOptions?: unknown;
124
- readonly providerExecuted?: boolean | undefined;
125
- })[];
126
- readonly providerOptions?: unknown;
127
- } | {
128
- readonly role: "tool";
129
- readonly content: readonly {
130
- readonly type: "tool-result";
131
- readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
132
- readonly toolName: string;
133
- readonly output: unknown;
134
- readonly providerOptions?: unknown;
135
- readonly isError?: boolean | undefined;
136
- }[];
137
- readonly providerOptions?: unknown;
138
- })[];
139
- outcome: "ok" | "partial";
140
- reason: "completed" | "step-cap" | "degenerate-loop";
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 DELETED
@@ -1,63 +0,0 @@
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, userMessage, options) => Effect.gen(function* () {
9
- const store = yield* ConversationStore;
10
- yield* store.append(conversationId, { role: "user", content: userMessage.text }).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.catch(() => 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.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
- return result;
56
- }).pipe(Effect.withSpan("agent.run", {
57
- attributes: {
58
- "agent.kind": "run",
59
- "agent.conversation_id": String(conversationId),
60
- "agent.userMessage": options?.traceContent === !0 ? userMessage.text.slice(0, 500) : "[redacted]",
61
- "agent.userMessage_chars": userMessage.text.length
62
- }
63
- }), Effect.provideService(CurrentModelCallPolicy, Option.fromNullishOr(config.modelPolicy)));
@@ -1 +0,0 @@
1
- export {};