@xandreed/plugin-agent-loop 0.3.0 → 0.8.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/loop.d.ts +61 -17
- package/dist/loop.js +53 -29
- 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 +3 -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 { 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,24 +24,57 @@ export interface CompactionPlan {
|
|
|
23
24
|
* `LanguageModel.generateText`, appends the response as the new tail, and
|
|
24
25
|
* re-invokes until the model stops requesting tools, `maxSteps` is hit, or
|
|
25
26
|
* the degenerate-loop breaker fires. State is an immutable fold through
|
|
26
|
-
*
|
|
27
|
+
* a recursive step — the same discipline as foundry's forge loop.
|
|
27
28
|
*/
|
|
28
29
|
export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
|
|
30
|
+
/** A host-validated first batch. Runs as step zero through the ordinary
|
|
31
|
+
* Effect AI decoder, instrumented toolkit and durable tail hooks, without
|
|
32
|
+
* another provider request. Provider usage belongs to the host planner. */
|
|
33
|
+
readonly initialStep?: ReadonlyArray<{
|
|
34
|
+
readonly name: keyof Tools & string;
|
|
35
|
+
readonly params: unknown;
|
|
36
|
+
}>;
|
|
37
|
+
/** Host selection at a real provider boundary. Never called for initialStep.
|
|
38
|
+
* The host may cache its choice for a run; tools and completion remain loop-owned. */
|
|
39
|
+
readonly prepareModel?: (input: {
|
|
40
|
+
readonly stepIndex: number;
|
|
41
|
+
readonly messages: ReadonlyArray<AgentMessage>;
|
|
42
|
+
readonly activeTools: ReadonlyArray<string>;
|
|
43
|
+
}) => Effect.Effect<{
|
|
44
|
+
readonly model: LanguageModel.LanguageModel;
|
|
45
|
+
readonly system: Prompt.Prompt;
|
|
46
|
+
}, AiError.AiError, R>;
|
|
47
|
+
/** Validate independently reconstructed durable input immediately before a provider dispatch. */
|
|
48
|
+
readonly beforeDispatch?: (step: number, request: ModelDispatch) => Effect.Effect<void, never, R>;
|
|
29
49
|
readonly system: string | Prompt.Prompt;
|
|
50
|
+
/** The READ side of memory: when present, each provider step sends these
|
|
51
|
+
* messages instead of the loop's own buffer. Persistence still flows through
|
|
52
|
+
* `onTail`, so a memory plugin can rebuild every request from its log. */
|
|
53
|
+
readonly render?: (step: StepView) => Effect.Effect<ReadonlyArray<AgentMessage>, never, R>;
|
|
54
|
+
/** Per-step tool choice (e.g. force a delivery tool without removing others). */
|
|
55
|
+
readonly stepDirective?: (step: StepView) => Effect.Effect<{
|
|
56
|
+
readonly toolChoice: Option.Option<LoopToolChoice>;
|
|
57
|
+
}, never, R>;
|
|
58
|
+
/** Host wording for the loop's own corrective turns. */
|
|
59
|
+
readonly correctives?: {
|
|
60
|
+
readonly malformed?: (tools: ReadonlyArray<string>, description: string) => string;
|
|
61
|
+
readonly incomplete?: string;
|
|
62
|
+
};
|
|
30
63
|
/** Explicit opt-in: export prompt and result content on step spans.
|
|
31
64
|
* Hosts own consent, redaction and exporter retention. Disabled by default. */
|
|
32
65
|
readonly captureTraceContent?: boolean;
|
|
33
66
|
readonly messages: ReadonlyArray<AgentMessage>;
|
|
34
67
|
readonly toolkit: Toolkit.Toolkit<Tools>;
|
|
35
|
-
/** Run-pinned deterministic capability selection, refreshed between steps.
|
|
36
|
-
* A capability-expansion tool can update this selection for the next call.
|
|
37
|
-
* Handlers must still enforce their own principal/action policy. */
|
|
38
68
|
/** Host completion condition, checked after settled tools/events. Avoids a
|
|
39
69
|
* paid final prose call when a tool already delivered the complete result. */
|
|
40
70
|
readonly isComplete?: () => Effect.Effect<boolean, never, R>;
|
|
41
71
|
/** When true, a model stop cannot substitute for host completion. A bounded
|
|
42
72
|
* corrective turn asks for the missing actions; maxSteps still applies. */
|
|
43
73
|
readonly requireCompletion?: boolean;
|
|
74
|
+
/** Run-pinned deterministic capability selection, refreshed between steps
|
|
75
|
+
* and sent in the order returned (append new tools to keep the prefix stable).
|
|
76
|
+
* A capability-expansion tool can update this selection for the next call.
|
|
77
|
+
* Handlers must still enforce their own principal/action policy. */
|
|
44
78
|
readonly activeTools?: () => Effect.Effect<ReadonlyArray<keyof Tools>, never, R>;
|
|
45
79
|
/** A ceiling, not a target — a normal run ends when the model stops
|
|
46
80
|
* calling tools. Default {@link DEFAULT_MAX_STEPS}. */
|
|
@@ -87,6 +121,16 @@ export interface RunLoopOptions<Tools extends Record<string, Tool.Any>, R> {
|
|
|
87
121
|
*/
|
|
88
122
|
readonly pendingInput?: () => Effect.Effect<Option.Option<string>, never, R>;
|
|
89
123
|
}
|
|
124
|
+
/** What the loop knows about the step it is about to take. */
|
|
125
|
+
export interface StepView {
|
|
126
|
+
readonly stepIndex: number;
|
|
127
|
+
/** Active tool names, in activation order. */
|
|
128
|
+
readonly activeTools: ReadonlyArray<string>;
|
|
129
|
+
readonly lastUsage: Option.Option<TokenUsage>;
|
|
130
|
+
}
|
|
131
|
+
export type LoopToolChoice = "required" | {
|
|
132
|
+
readonly tool: string;
|
|
133
|
+
};
|
|
90
134
|
export declare const DEFAULT_MAX_STEPS = 100;
|
|
91
135
|
export declare const DEFAULT_TOOL_CONCURRENCY = 4;
|
|
92
136
|
export declare const DEGENERATE_REPEAT_NUDGE: string;
|
|
@@ -119,11 +163,10 @@ export declare const nextPhase: (facts: {
|
|
|
119
163
|
export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never>(options: RunLoopOptions<Tools, R>) => Effect.Effect<{
|
|
120
164
|
finalText: string;
|
|
121
165
|
messages: readonly ({
|
|
122
|
-
readonly providerOptions?: unknown;
|
|
123
166
|
readonly role: "user";
|
|
124
167
|
readonly content: string;
|
|
125
|
-
} | {
|
|
126
168
|
readonly providerOptions?: unknown;
|
|
169
|
+
} | {
|
|
127
170
|
readonly role: "assistant";
|
|
128
171
|
readonly content: readonly ({
|
|
129
172
|
readonly type: "text";
|
|
@@ -135,30 +178,30 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
|
|
|
135
178
|
readonly providerOptions?: unknown;
|
|
136
179
|
} | {
|
|
137
180
|
readonly type: "tool-call";
|
|
138
|
-
readonly providerOptions?: unknown;
|
|
139
181
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
140
182
|
readonly toolName: string;
|
|
141
183
|
readonly input: unknown;
|
|
184
|
+
readonly providerOptions?: unknown;
|
|
142
185
|
readonly providerExecuted?: boolean | undefined;
|
|
143
186
|
})[];
|
|
144
|
-
} | {
|
|
145
187
|
readonly providerOptions?: unknown;
|
|
188
|
+
} | {
|
|
146
189
|
readonly role: "tool";
|
|
147
190
|
readonly content: readonly {
|
|
148
191
|
readonly type: "tool-result";
|
|
149
|
-
readonly providerOptions?: unknown;
|
|
150
192
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
151
193
|
readonly toolName: string;
|
|
152
194
|
readonly output: unknown;
|
|
195
|
+
readonly providerOptions?: unknown;
|
|
153
196
|
readonly isError?: boolean | undefined;
|
|
154
197
|
}[];
|
|
198
|
+
readonly providerOptions?: unknown;
|
|
155
199
|
})[];
|
|
156
200
|
newTail: readonly ({
|
|
157
|
-
readonly providerOptions?: unknown;
|
|
158
201
|
readonly role: "user";
|
|
159
202
|
readonly content: string;
|
|
160
|
-
} | {
|
|
161
203
|
readonly providerOptions?: unknown;
|
|
204
|
+
} | {
|
|
162
205
|
readonly role: "assistant";
|
|
163
206
|
readonly content: readonly ({
|
|
164
207
|
readonly type: "text";
|
|
@@ -170,24 +213,25 @@ export declare const runLoop: <Tools extends Record<string, Tool.Any>, R = never
|
|
|
170
213
|
readonly providerOptions?: unknown;
|
|
171
214
|
} | {
|
|
172
215
|
readonly type: "tool-call";
|
|
173
|
-
readonly providerOptions?: unknown;
|
|
174
216
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
175
217
|
readonly toolName: string;
|
|
176
218
|
readonly input: unknown;
|
|
219
|
+
readonly providerOptions?: unknown;
|
|
177
220
|
readonly providerExecuted?: boolean | undefined;
|
|
178
221
|
})[];
|
|
179
|
-
} | {
|
|
180
222
|
readonly providerOptions?: unknown;
|
|
223
|
+
} | {
|
|
181
224
|
readonly role: "tool";
|
|
182
225
|
readonly content: readonly {
|
|
183
226
|
readonly type: "tool-result";
|
|
184
|
-
readonly providerOptions?: unknown;
|
|
185
227
|
readonly toolCallId: string & import("effect/Brand").Brand<"ToolCallId">;
|
|
186
228
|
readonly toolName: string;
|
|
187
229
|
readonly output: unknown;
|
|
230
|
+
readonly providerOptions?: unknown;
|
|
188
231
|
readonly isError?: boolean | undefined;
|
|
189
232
|
}[];
|
|
233
|
+
readonly providerOptions?: unknown;
|
|
190
234
|
})[];
|
|
191
235
|
outcome: "ok" | "partial";
|
|
192
236
|
reason: "completed" | "step-cap" | "degenerate-loop";
|
|
193
|
-
},
|
|
237
|
+
}, AiError.AiError, LanguageModel.LanguageModel | Exclude<R, import("effect/Tracer").ParentSpan> | Exclude<Tool.HandlersFor<Tools>, import("effect/Tracer").ParentSpan>>;
|
package/dist/loop.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { LanguageModel, Prompt } from "
|
|
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
|
|
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,22 +94,35 @@ 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)))
|
|
101
|
+
});
|
|
102
|
+
}), initial = Effect.gen(function* () {
|
|
103
|
+
const provider = yield* LanguageModel.make({
|
|
104
|
+
generateText: () => Effect.succeed([...(options.initialStep ?? []).map((call, index) => ({
|
|
105
|
+
type: "tool-call",
|
|
106
|
+
id: `planned:0:${index}`,
|
|
107
|
+
name: call.name,
|
|
108
|
+
params: call.params,
|
|
109
|
+
providerExecuted: !1
|
|
110
|
+
})), { type: "finish", reason: "tool-calls", usage: { inputTokens: {}, outputTokens: {} } }]),
|
|
111
|
+
streamText: () => Stream.empty
|
|
93
112
|
});
|
|
94
|
-
|
|
113
|
+
yield* Effect.annotateCurrentSpan("engine.step.host_planned", !0);
|
|
114
|
+
return yield* settled(state.streamingHealthy).pipe(Effect.provideService(LanguageModel.LanguageModel, provider));
|
|
115
|
+
}), useStreaming = options.streaming === !0 && state.streamingHealthy, outcome = yield* (planned ? initial : useStreaming ? streamed : settled(state.streamingHealthy)).pipe(Effect.catch((err) => isMalformed(err) ? Effect.succeed({ _tag: "malformed", err }) : Effect.fail(err)), Effect.provideService(CurrentEmptyResponseTolerance, state.toolCalls > 0));
|
|
95
116
|
if (outcome._tag === "malformed") {
|
|
96
117
|
const streak = state.malformedStreak + 1;
|
|
97
118
|
if (streak >= MAX_MALFORMED)
|
|
98
119
|
return yield* Effect.fail(outcome.err);
|
|
99
|
-
const
|
|
120
|
+
const reason = outcome.err.reason, desc = clip("description" in reason ? reason.description : reason.message, 600);
|
|
100
121
|
yield* Effect.logWarning(`recovering from a malformed response: ${desc}`);
|
|
101
|
-
yield* Metric.
|
|
122
|
+
yield* Metric.update(tagged(engineCorrections, { "engine.kind": "malformed" }), 1);
|
|
102
123
|
const corrective = {
|
|
103
124
|
role: "user",
|
|
104
|
-
content: `Your previous reply could not be parsed: ${desc}
|
|
125
|
+
content: options.correctives?.malformed?.(toolNames, desc) ?? `Your previous reply could not be parsed: ${desc}
|
|
105
126
|
|
|
106
127
|
This usually means you called a tool that doesn't exist or used the wrong argument shape. The only tools available are: ${toolNames.join(", ")}. Reply again using one of those tools, or plain text if you're done.`
|
|
107
128
|
};
|
|
@@ -121,10 +142,11 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
121
142
|
const content = withToolCallIds(outcome.content, state.turnIndex), usage = extractUsage(outcome.usage, content);
|
|
122
143
|
yield* Effect.annotateCurrentSpan({
|
|
123
144
|
"engine.step.finish_reason": outcome.finishReason,
|
|
124
|
-
"engine.step.
|
|
145
|
+
"engine.step.usage_available": !planned,
|
|
146
|
+
...!planned ? { "engine.step.usage": JSON.stringify(usage) } : {},
|
|
125
147
|
...options.captureTraceContent === !0 ? { "engine.step.output": JSON.stringify({ content, usage, finishReason: outcome.finishReason }) } : {}
|
|
126
148
|
});
|
|
127
|
-
const model = extractModel(content), tail = withUsageOnAssistant(responseToAgentMessages(content), usage, model), positions = yield* options.onTail?.(tail) ?? Effect.succeed([]), assistantAt = tail.findIndex((m) => m.role === "assistant"), assistantPosition = assistantAt >= 0 ? Option.
|
|
149
|
+
const model = extractModel(content), tail = planned ? responseToAgentMessages(content) : withUsageOnAssistant(responseToAgentMessages(content), usage, model), positions = yield* options.onTail?.(tail) ?? Effect.succeed([]), assistantAt = tail.findIndex((m) => m.role === "assistant"), assistantPosition = assistantAt >= 0 ? Option.fromNullishOr(positions[assistantAt]) : Option.none(), text = responseText(content), toolCalls = responseToolCalls(content);
|
|
128
150
|
yield* onEvent({
|
|
129
151
|
type: "assistant_message",
|
|
130
152
|
turnIndex: state.turnIndex,
|
|
@@ -139,14 +161,14 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
139
161
|
})
|
|
140
162
|
});
|
|
141
163
|
const toolResults = responseToolResults(content);
|
|
142
|
-
yield* Effect.forEach(toolResults, (tr) => Metric.
|
|
164
|
+
yield* Effect.forEach(toolResults, (tr) => Metric.update(tagged(engineToolCalls, {
|
|
143
165
|
"engine.tool": tr.toolName,
|
|
144
166
|
"engine.ok": tr.ok ? "true" : "false"
|
|
145
|
-
})));
|
|
167
|
+
}), 1));
|
|
146
168
|
const sig = progressSignature(content, pollable), progress = foldProgress(state, sig), { stale, seen } = progress, nudge = progress.nudge ? [{ role: "user", content: DEGENERATE_REPEAT_NUDGE }] : [];
|
|
147
169
|
yield* nudge.length > 0 ? options.onTail?.(nudge) ?? Effect.void : Effect.void;
|
|
148
|
-
yield* nudge.length > 0 ? Metric.
|
|
149
|
-
const finalText = text.length > 0 ? text : state.finalText, turnIndex = state.turnIndex + 1, completed = yield* options.isComplete?.() ?? Effect.succeed(!1), requireMore = !completed && options.requireCompletion === !0, completionNudge = requireMore && toolCalls.length === 0 ? [{ role: "user", content: "The host reports that required actions are incomplete. Your ordinary text is not a delivered result. Complete the requested work using the available tools, or use an explicit supported failure/clarification action if the evidence is missing." }] : [];
|
|
170
|
+
yield* nudge.length > 0 ? Metric.update(tagged(engineCorrections, { "engine.kind": "degenerate-nudge" }), 1) : Effect.void;
|
|
171
|
+
const finalText = text.length > 0 ? text : state.finalText, turnIndex = state.turnIndex + 1, completed = yield* options.isComplete?.() ?? Effect.succeed(!1), requireMore = !completed && options.requireCompletion === !0, completionNudge = requireMore && toolCalls.length === 0 ? [{ role: "user", content: options.correctives?.incomplete ?? "The host reports that required actions are incomplete. Your ordinary text is not a delivered result. Complete the requested work using the available tools, or use an explicit supported failure/clarification action if the evidence is missing." }] : [];
|
|
150
172
|
yield* completionNudge.length > 0 ? options.onTail?.(completionNudge) ?? Effect.void : Effect.void;
|
|
151
173
|
const wantsMore = !completed && (requireMore || outcome.finishReason === "tool-calls" && toolCalls.length > 0), broke = progress.broke, phase = nextPhase({ broke, wantsMore, turnIndex, maxSteps });
|
|
152
174
|
yield* broke ? Effect.logWarning(`breaking a degenerate tool-call loop after ${stale} stale turns`) : Effect.void;
|
|
@@ -180,15 +202,16 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
180
202
|
seen,
|
|
181
203
|
staleTurns: stale,
|
|
182
204
|
usage: addUsage(state.usage, usage),
|
|
205
|
+
lastUsage: planned ? state.lastUsage : Option.some(usage),
|
|
183
206
|
phase,
|
|
184
207
|
toolCalls: state.toolCalls + toolCalls.length,
|
|
185
208
|
toolFailures: state.toolFailures + toolResults.filter((tr) => !tr.ok).length,
|
|
186
209
|
corrections: state.corrections + (nudge.length > 0 ? 1 : 0),
|
|
187
210
|
streamingHealthy: outcome.streamingHealthy
|
|
188
211
|
};
|
|
189
|
-
}).pipe(Effect.onExit((exit) => onEvent({ type: "turn_end", turnIndex: state.turnIndex, status: Exit.isSuccess(exit) ? "completed" : Cause.
|
|
212
|
+
}).pipe(Effect.onExit((exit) => onEvent({ type: "turn_end", turnIndex: state.turnIndex, status: Exit.isSuccess(exit) ? "completed" : Cause.hasInterruptsOnly(exit.cause) ? "cancelled" : "failed" })), Effect.provideService(CurrentAgentStep, Option.some(state.turnIndex)), Effect.withSpan("engine.turn", {
|
|
190
213
|
attributes: { "engine.turn": state.turnIndex, "engine.step": state.turnIndex + 1 }
|
|
191
|
-
})),
|
|
214
|
+
})), runSteps = (state) => state.phase === "continue" ? Effect.flatMap(step(state), runSteps) : Effect.succeed(state), initial = {
|
|
192
215
|
messages: options.messages,
|
|
193
216
|
newTail: [],
|
|
194
217
|
finalText: "",
|
|
@@ -197,20 +220,21 @@ This usually means you called a tool that doesn't exist or used the wrong argume
|
|
|
197
220
|
seen: new Set,
|
|
198
221
|
staleTurns: 0,
|
|
199
222
|
usage: zeroUsage,
|
|
223
|
+
lastUsage: Option.none(),
|
|
200
224
|
phase: "continue",
|
|
201
225
|
toolCalls: 0,
|
|
202
226
|
toolFailures: 0,
|
|
203
227
|
corrections: 0,
|
|
204
228
|
streamingHealthy: !0
|
|
205
|
-
},
|
|
229
|
+
}, final = yield* runSteps(initial), { outcome, reason } = Match.value(final.phase).pipe(Match.when("completed", () => ({ outcome: "ok", reason: "completed" })), Match.when("step-cap", () => ({
|
|
206
230
|
outcome: "partial",
|
|
207
231
|
reason: "step-cap"
|
|
208
232
|
})), Match.when("degenerate-loop", () => ({
|
|
209
233
|
outcome: "partial",
|
|
210
234
|
reason: "degenerate-loop"
|
|
211
|
-
})), Match.
|
|
235
|
+
})), Match.exhaustive);
|
|
212
236
|
yield* onEvent({ type: "agent_end", outcome, reason, finalText: final.finalText });
|
|
213
|
-
yield* Metric.
|
|
237
|
+
yield* Metric.update(tagged(engineRuns, { "engine.outcome": outcome, "engine.reason": reason }), 1);
|
|
214
238
|
yield* Effect.annotateCurrentSpan({
|
|
215
239
|
"engine.outcome": outcome,
|
|
216
240
|
"engine.reason": reason,
|
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.8.0-next.0",
|
|
9
9
|
config: Config,
|
|
10
10
|
defaults: { maxSteps: 100, toolConcurrency: 1, streaming: !0 },
|
|
11
11
|
requires: [LanguageModel.LanguageModel, AgentTools, SessionStore, Memory, ContextManager],
|
|
@@ -13,26 +13,26 @@ export const agentLoopPlugin = definePlugin({
|
|
|
13
13
|
layer: (config) => Layer.effect(AgentLoop, Effect.gen(function* () {
|
|
14
14
|
const model = yield* LanguageModel.LanguageModel, tools = yield* AgentTools, store = yield* SessionStore, memory = yield* Memory, context = yield* ContextManager;
|
|
15
15
|
return AgentLoop.of({ run: (input) => Effect.gen(function* () {
|
|
16
|
-
const trail = yield* store.read(input.session.id, -1), previous = yield* Effect.reduce(trail, [], (messages, event) => {
|
|
16
|
+
const trail = yield* store.read(input.session.id, -1), previous = yield* Effect.reduce(trail, () => [], (messages, event) => {
|
|
17
17
|
if (event.name === "messages")
|
|
18
|
-
return Schema.
|
|
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.8.0-next.0",
|
|
87
|
+
scope: "runtime",
|
|
88
|
+
config: Schema.Struct({}),
|
|
89
|
+
defaults: {},
|
|
90
|
+
provides: [StepLoop],
|
|
91
|
+
layer: () => StepLoopLive
|
|
92
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xandreed/plugin-agent-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-next.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@xandreed/core": "0.
|
|
14
|
-
"
|
|
15
|
-
"effect": "3.21.4"
|
|
13
|
+
"@xandreed/core": "0.8.0-next.0",
|
|
14
|
+
"effect": "4.0.0-rc.118"
|
|
16
15
|
},
|
|
17
16
|
"private": false,
|
|
18
17
|
"types": "./dist/index.d.ts",
|