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