@xandreed/plugin-agent-loop 0.4.0 → 0.11.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 +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loop.d.ts +44 -17
- package/dist/loop.js +40 -30
- package/dist/plugin.adapter.d.ts +11 -1
- package/dist/plugin.adapter.js +10 -10
- package/dist/runAgent.d.ts +16 -15
- package/dist/runAgent.js +8 -8
- package/dist/step-loop.adapter.d.ts +20 -0
- package/dist/step-loop.adapter.js +92 -0
- package/dist/step-loop.adapter.test.d.ts +1 -0
- package/package.json +4 -4
package/README.md
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/loop.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { AiError, LanguageModel, Prompt } from "
|
|
2
|
-
import type { Tool, Toolkit } from "
|
|
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,7 +24,7 @@ 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
|
-
*
|
|
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> {
|
|
29
30
|
/** A host-validated first batch. Runs as step zero through the ordinary
|
|
@@ -40,24 +41,40 @@ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
|
|
|
40
41
|
readonly messages: ReadonlyArray<AgentMessage>;
|
|
41
42
|
readonly activeTools: ReadonlyArray<string>;
|
|
42
43
|
}) => Effect.Effect<{
|
|
43
|
-
readonly model: LanguageModel.
|
|
44
|
+
readonly model: LanguageModel.LanguageModel;
|
|
44
45
|
readonly system: Prompt.Prompt;
|
|
45
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>;
|
|
46
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
|
+
};
|
|
47
63
|
/** Explicit opt-in: export prompt and result content on step spans.
|
|
48
64
|
* Hosts own consent, redaction and exporter retention. Disabled by default. */
|
|
49
65
|
readonly captureTraceContent?: boolean;
|
|
50
66
|
readonly messages: ReadonlyArray<AgentMessage>;
|
|
51
67
|
readonly toolkit: Toolkit.Toolkit<Tools>;
|
|
52
|
-
/** Run-pinned deterministic capability selection, refreshed between steps.
|
|
53
|
-
* A capability-expansion tool can update this selection for the next call.
|
|
54
|
-
* Handlers must still enforce their own principal/action policy. */
|
|
55
68
|
/** Host completion condition, checked after settled tools/events. Avoids a
|
|
56
69
|
* paid final prose call when a tool already delivered the complete result. */
|
|
57
70
|
readonly isComplete?: () => Effect.Effect<boolean, never, R>;
|
|
58
71
|
/** When true, a model stop cannot substitute for host completion. A bounded
|
|
59
72
|
* corrective turn asks for the missing actions; maxSteps still applies. */
|
|
60
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. */
|
|
61
78
|
readonly activeTools?: () => Effect.Effect<ReadonlyArray<keyof Tools>, never, R>;
|
|
62
79
|
/** A ceiling, not a target — a normal run ends when the model stops
|
|
63
80
|
* calling tools. Default {@link DEFAULT_MAX_STEPS}. */
|
|
@@ -104,6 +121,16 @@ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
|
|
|
104
121
|
*/
|
|
105
122
|
readonly pendingInput?: () => Effect.Effect<Option.Option<string>, never, R>;
|
|
106
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
|
+
};
|
|
107
134
|
export declare const DEFAULT_MAX_STEPS = 100;
|
|
108
135
|
export declare const DEFAULT_TOOL_CONCURRENCY = 4;
|
|
109
136
|
export declare const DEGENERATE_REPEAT_NUDGE: string;
|
|
@@ -136,11 +163,10 @@ export declare const nextPhase: (facts: {
|
|
|
136
163
|
export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never>(options: RunLoopOptions<Tools, R>) => Effect.Effect<{
|
|
137
164
|
finalText: string;
|
|
138
165
|
messages: readonly ({
|
|
139
|
-
readonly providerOptions?: unknown;
|
|
140
166
|
readonly role: "user";
|
|
141
167
|
readonly content: string;
|
|
142
|
-
} | {
|
|
143
168
|
readonly providerOptions?: unknown;
|
|
169
|
+
} | {
|
|
144
170
|
readonly role: "assistant";
|
|
145
171
|
readonly content: readonly ({
|
|
146
172
|
readonly type: "text";
|
|
@@ -152,30 +178,30 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
|
|
|
152
178
|
readonly providerOptions?: unknown;
|
|
153
179
|
} | {
|
|
154
180
|
readonly type: "tool-call";
|
|
155
|
-
readonly providerOptions?: unknown;
|
|
156
181
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
157
182
|
readonly toolName: string;
|
|
158
183
|
readonly input: unknown;
|
|
184
|
+
readonly providerOptions?: unknown;
|
|
159
185
|
readonly providerExecuted?: boolean | undefined;
|
|
160
186
|
})[];
|
|
161
|
-
} | {
|
|
162
187
|
readonly providerOptions?: unknown;
|
|
188
|
+
} | {
|
|
163
189
|
readonly role: "tool";
|
|
164
190
|
readonly content: readonly {
|
|
165
191
|
readonly type: "tool-result";
|
|
166
|
-
readonly providerOptions?: unknown;
|
|
167
192
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
168
193
|
readonly toolName: string;
|
|
169
194
|
readonly output: unknown;
|
|
195
|
+
readonly providerOptions?: unknown;
|
|
170
196
|
readonly isError?: boolean | undefined;
|
|
171
197
|
}[];
|
|
198
|
+
readonly providerOptions?: unknown;
|
|
172
199
|
})[];
|
|
173
200
|
newTail: readonly ({
|
|
174
|
-
readonly providerOptions?: unknown;
|
|
175
201
|
readonly role: "user";
|
|
176
202
|
readonly content: string;
|
|
177
|
-
} | {
|
|
178
203
|
readonly providerOptions?: unknown;
|
|
204
|
+
} | {
|
|
179
205
|
readonly role: "assistant";
|
|
180
206
|
readonly content: readonly ({
|
|
181
207
|
readonly type: "text";
|
|
@@ -187,23 +213,24 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
|
|
|
187
213
|
readonly providerOptions?: unknown;
|
|
188
214
|
} | {
|
|
189
215
|
readonly type: "tool-call";
|
|
190
|
-
readonly providerOptions?: unknown;
|
|
191
216
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
192
217
|
readonly toolName: string;
|
|
193
218
|
readonly input: unknown;
|
|
219
|
+
readonly providerOptions?: unknown;
|
|
194
220
|
readonly providerExecuted?: boolean | undefined;
|
|
195
221
|
})[];
|
|
196
|
-
} | {
|
|
197
222
|
readonly providerOptions?: unknown;
|
|
223
|
+
} | {
|
|
198
224
|
readonly role: "tool";
|
|
199
225
|
readonly content: readonly {
|
|
200
226
|
readonly type: "tool-result";
|
|
201
|
-
readonly providerOptions?: unknown;
|
|
202
227
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
203
228
|
readonly toolName: string;
|
|
204
229
|
readonly output: unknown;
|
|
230
|
+
readonly providerOptions?: unknown;
|
|
205
231
|
readonly isError?: boolean | undefined;
|
|
206
232
|
}[];
|
|
233
|
+
readonly providerOptions?: unknown;
|
|
207
234
|
})[];
|
|
208
235
|
outcome: "ok" | "partial";
|
|
209
236
|
reason: "completed" | "step-cap" | "degenerate-loop";
|
package/dist/loop.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AiError, LanguageModel, Prompt } from "
|
|
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) =>
|
|
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.
|
|
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 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, prompt = Prompt.
|
|
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
|
-
|
|
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
|
-
|
|
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,12 +94,12 @@ export const foldProgress = (state, sig) => {
|
|
|
86
94
|
finishReason: turn.finishReason,
|
|
87
95
|
usage: turn.usage,
|
|
88
96
|
streamingHealthy: !0
|
|
89
|
-
})), Effect.
|
|
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.
|
|
100
|
+
onNone: () => Effect.logWarning("streaming failed before any part arrived \u2014 falling back to generateText for this run").pipe(Effect.andThen(settled(!1)))
|
|
93
101
|
});
|
|
94
|
-
}),
|
|
102
|
+
}), initial = Effect.gen(function* () {
|
|
95
103
|
const provider = yield* LanguageModel.make({
|
|
96
104
|
generateText: () => Effect.succeed([...(options.initialStep ?? []).map((call, index) => ({
|
|
97
105
|
type: "tool-call",
|
|
@@ -99,22 +107,22 @@ export const foldProgress = (state, sig) => {
|
|
|
99
107
|
name: call.name,
|
|
100
108
|
params: call.params,
|
|
101
109
|
providerExecuted: !1
|
|
102
|
-
})), { type: "finish", reason: "tool-calls", usage: { inputTokens:
|
|
110
|
+
})), { type: "finish", reason: "tool-calls", usage: { inputTokens: {}, outputTokens: {} } }]),
|
|
103
111
|
streamText: () => Stream.empty
|
|
104
112
|
});
|
|
105
113
|
yield* Effect.annotateCurrentSpan("engine.step.host_planned", !0);
|
|
106
114
|
return yield* settled(state.streamingHealthy).pipe(Effect.provideService(LanguageModel.LanguageModel, provider));
|
|
107
|
-
}), useStreaming = options.streaming === !0 && state.streamingHealthy, outcome = yield* (planned ? initial : useStreaming ? streamed : settled(state.streamingHealthy)).pipe(Effect.
|
|
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));
|
|
108
116
|
if (outcome._tag === "malformed") {
|
|
109
117
|
const streak = state.malformedStreak + 1;
|
|
110
118
|
if (streak >= MAX_MALFORMED)
|
|
111
119
|
return yield* Effect.fail(outcome.err);
|
|
112
|
-
const
|
|
120
|
+
const reason = outcome.err.reason, desc = clip("description" in reason ? reason.description : reason.message, 600);
|
|
113
121
|
yield* Effect.logWarning(`recovering from a malformed response: ${desc}`);
|
|
114
|
-
yield* Metric.
|
|
122
|
+
yield* Metric.update(tagged(engineCorrections, { "engine.kind": "malformed" }), 1);
|
|
115
123
|
const corrective = {
|
|
116
124
|
role: "user",
|
|
117
|
-
content: `Your previous reply could not be parsed: ${desc}
|
|
125
|
+
content: options.correctives?.malformed?.(toolNames, desc) ?? `Your previous reply could not be parsed: ${desc}
|
|
118
126
|
|
|
119
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.`
|
|
120
128
|
};
|
|
@@ -138,7 +146,7 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
138
146
|
...!planned ? { "engine.step.usage": JSON.stringify(usage) } : {},
|
|
139
147
|
...options.captureTraceContent === !0 ? { "engine.step.output": JSON.stringify({ content, usage, finishReason: outcome.finishReason }) } : {}
|
|
140
148
|
});
|
|
141
|
-
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.
|
|
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);
|
|
142
150
|
yield* onEvent({
|
|
143
151
|
type: "assistant_message",
|
|
144
152
|
turnIndex: state.turnIndex,
|
|
@@ -153,14 +161,14 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
153
161
|
})
|
|
154
162
|
});
|
|
155
163
|
const toolResults = responseToolResults(content);
|
|
156
|
-
yield* Effect.forEach(toolResults, (tr) => Metric.
|
|
164
|
+
yield* Effect.forEach(toolResults, (tr) => Metric.update(tagged(engineToolCalls, {
|
|
157
165
|
"engine.tool": tr.toolName,
|
|
158
166
|
"engine.ok": tr.ok ? "true" : "false"
|
|
159
|
-
})));
|
|
167
|
+
}), 1));
|
|
160
168
|
const sig = progressSignature(content, pollable), progress = foldProgress(state, sig), { stale, seen } = progress, nudge = progress.nudge ? [{ role: "user", content: DEGENERATE_REPEAT_NUDGE }] : [];
|
|
161
169
|
yield* nudge.length > 0 ? options.onTail?.(nudge) ?? Effect.void : Effect.void;
|
|
162
|
-
yield* nudge.length > 0 ? Metric.
|
|
163
|
-
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." }] : [];
|
|
164
172
|
yield* completionNudge.length > 0 ? options.onTail?.(completionNudge) ?? Effect.void : Effect.void;
|
|
165
173
|
const wantsMore = !completed && (requireMore || outcome.finishReason === "tool-calls" && toolCalls.length > 0), broke = progress.broke, phase = nextPhase({ broke, wantsMore, turnIndex, maxSteps });
|
|
166
174
|
yield* broke ? Effect.logWarning(`breaking a degenerate tool-call loop after ${stale} stale turns`) : Effect.void;
|
|
@@ -194,15 +202,16 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
194
202
|
seen,
|
|
195
203
|
staleTurns: stale,
|
|
196
204
|
usage: addUsage(state.usage, usage),
|
|
205
|
+
lastUsage: planned ? state.lastUsage : Option.some(usage),
|
|
197
206
|
phase,
|
|
198
207
|
toolCalls: state.toolCalls + toolCalls.length,
|
|
199
208
|
toolFailures: state.toolFailures + toolResults.filter((tr) => !tr.ok).length,
|
|
200
209
|
corrections: state.corrections + (nudge.length > 0 ? 1 : 0),
|
|
201
210
|
streamingHealthy: outcome.streamingHealthy
|
|
202
211
|
};
|
|
203
|
-
}).pipe(Effect.onExit((exit) => onEvent({ type: "turn_end", turnIndex: state.turnIndex, status: Exit.isSuccess(exit) ? "completed" : Cause.
|
|
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", {
|
|
204
213
|
attributes: { "engine.turn": state.turnIndex, "engine.step": state.turnIndex + 1 }
|
|
205
|
-
})),
|
|
214
|
+
})), runSteps = (state) => state.phase === "continue" ? Effect.flatMap(step(state), runSteps) : Effect.succeed(state), initial = {
|
|
206
215
|
messages: options.messages,
|
|
207
216
|
newTail: [],
|
|
208
217
|
finalText: "",
|
|
@@ -211,20 +220,21 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
211
220
|
seen: new Set,
|
|
212
221
|
staleTurns: 0,
|
|
213
222
|
usage: zeroUsage,
|
|
223
|
+
lastUsage: Option.none(),
|
|
214
224
|
phase: "continue",
|
|
215
225
|
toolCalls: 0,
|
|
216
226
|
toolFailures: 0,
|
|
217
227
|
corrections: 0,
|
|
218
228
|
streamingHealthy: !0
|
|
219
|
-
},
|
|
229
|
+
}, final = yield* runSteps(initial), { outcome, reason } = Match.value(final.phase).pipe(Match.when("completed", () => ({ outcome: "ok", reason: "completed" })), Match.when("step-cap", () => ({
|
|
220
230
|
outcome: "partial",
|
|
221
231
|
reason: "step-cap"
|
|
222
232
|
})), Match.when("degenerate-loop", () => ({
|
|
223
233
|
outcome: "partial",
|
|
224
234
|
reason: "degenerate-loop"
|
|
225
|
-
})), Match.
|
|
235
|
+
})), Match.exhaustive);
|
|
226
236
|
yield* onEvent({ type: "agent_end", outcome, reason, finalText: final.finalText });
|
|
227
|
-
yield* Metric.
|
|
237
|
+
yield* Metric.update(tagged(engineRuns, { "engine.outcome": outcome, "engine.reason": reason }), 1);
|
|
228
238
|
yield* Effect.annotateCurrentSpan({
|
|
229
239
|
"engine.outcome": outcome,
|
|
230
240
|
"engine.reason": reason,
|
package/dist/plugin.adapter.d.ts
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
|
-
|
|
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;
|
package/dist/plugin.adapter.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { LanguageModel } from "
|
|
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.
|
|
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.
|
|
8
|
+
version: "0.11.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) }))) });
|
package/dist/runAgent.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { Prompt, Tool, Toolkit } from "
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
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("
|
|
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,
|
|
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:
|
|
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.
|
|
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.
|
|
54
|
-
yield* store.recordOutcome(conversationId, result.outcome, result.reason).pipe(Effect.
|
|
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.
|
|
61
|
-
"agent.
|
|
60
|
+
"agent.userMessage": options?.traceContent === !0 ? userMessage.text.slice(0, 500) : "[redacted]",
|
|
61
|
+
"agent.userMessage_chars": userMessage.text.length
|
|
62
62
|
}
|
|
63
|
-
}), Effect.
|
|
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.11.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
|
+
"version": "0.11.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.
|
|
14
|
-
"
|
|
15
|
-
"effect": "3.21.4"
|
|
13
|
+
"@xandreed/core": "0.11.0",
|
|
14
|
+
"effect": "4.0.0-rc.118"
|
|
16
15
|
},
|
|
17
16
|
"private": false,
|
|
18
17
|
"types": "./dist/index.d.ts",
|
|
@@ -24,6 +23,7 @@
|
|
|
24
23
|
"LICENSE"
|
|
25
24
|
],
|
|
26
25
|
"engines": {
|
|
26
|
+
"node": ">=24",
|
|
27
27
|
"bun": ">=1.3.0"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT"
|