@xandreed/sdk 0.4.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/agent.adapter.d.ts +58 -0
- package/dist/agent.adapter.js +94 -0
- package/dist/agent.adapter.test.d.ts +1 -0
- package/dist/agent.backends.test.d.ts +1 -0
- package/dist/agent.golden.test.d.ts +190 -0
- package/dist/approval.adapter.d.ts +2 -1
- package/dist/approval.adapter.js +1 -1
- package/dist/domain-session.adapter.d.ts +6 -1
- package/dist/domain-session.adapter.js +29 -8
- package/dist/harness.d.ts +1 -1
- package/dist/harness.js +94 -49
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/memory.conformance.test.d.ts +1 -0
- package/dist/plugin-stack.test.d.ts +1 -0
- package/dist/testing.port.d.ts +16 -0
- package/dist/testing.port.js +7 -0
- package/dist/turn-composition.test.d.ts +1 -0
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
|
+
import { HarnessError } from "@xandreed/core";
|
|
4
|
+
import type { Capability, LoopLimits, Plugin, Turn, TurnInput, TurnOutcome, TurnServices } from "@xandreed/core";
|
|
5
|
+
/** One plugin instance of an agent: the definition, its options and (optionally) an instance id. */
|
|
6
|
+
export interface AgentPluginEntry {
|
|
7
|
+
readonly plugin: Plugin;
|
|
8
|
+
/** Defaults to the plugin id. */
|
|
9
|
+
readonly id?: string;
|
|
10
|
+
readonly options?: Readonly<Record<string, unknown>>;
|
|
11
|
+
}
|
|
12
|
+
/** Everything an agent is made of. Swapping a strategy is swapping one entry. */
|
|
13
|
+
export interface AgentConfig {
|
|
14
|
+
/** Memory log and strategy, tool registry, step loop, and any capability plugins. */
|
|
15
|
+
readonly plugins: ReadonlyArray<Plugin | AgentPluginEntry>;
|
|
16
|
+
/** Which instance provides a service several plugins provide. */
|
|
17
|
+
readonly bindings?: Readonly<Record<string, string>>;
|
|
18
|
+
/** The host's own tools, views, skills and prompt sections. */
|
|
19
|
+
readonly capabilities?: ReadonlyArray<Capability>;
|
|
20
|
+
/** Process-lifetime host services plugins may require. */
|
|
21
|
+
readonly services?: Context.Context<never>;
|
|
22
|
+
/** Keys every turn's services must carry; session plugins may require them. */
|
|
23
|
+
readonly turnServices?: ReadonlyArray<{
|
|
24
|
+
readonly key: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** The system prompt prefix, before the contributed sections. */
|
|
27
|
+
readonly system?: string;
|
|
28
|
+
/** The prompt-cache key is `<prefix>:<conversation>`; none is sent without a prefix. */
|
|
29
|
+
readonly cacheKeyPrefix?: string;
|
|
30
|
+
readonly workspace?: string;
|
|
31
|
+
/** Defaults for every run; a turn's policy overrides them. */
|
|
32
|
+
readonly limits?: Partial<LoopLimits>;
|
|
33
|
+
/** Input tokens one request may use (system, tool schemas and messages). */
|
|
34
|
+
readonly budgetTokens?: number;
|
|
35
|
+
/** How deep event reactions may nest before the bus fails the publisher. */
|
|
36
|
+
readonly maxEventDepth?: number;
|
|
37
|
+
}
|
|
38
|
+
/** A defined agent: its graph is built; each turn is composed by the host. */
|
|
39
|
+
export interface Agent {
|
|
40
|
+
readonly fingerprint: string;
|
|
41
|
+
/**
|
|
42
|
+
* Run one turn: one the host began (its `TurnWriter`; the host ends it),
|
|
43
|
+
* or one begun and ended here through the `Sessions` in the turn's
|
|
44
|
+
* services. The turn is scoped: its subscriptions and tasks end with it.
|
|
45
|
+
* On success, tasks and background subscriptions settle as the scopes
|
|
46
|
+
* close in dependency order, before the reply is recorded exactly once.
|
|
47
|
+
* Failures and interruptions record a failed reply and cancel remaining
|
|
48
|
+
* scoped work; recorded events are stored before the turn returns. A turn closed
|
|
49
|
+
* elsewhere (cancelled, reaped) fails with `turn.closed`. `use` runs in
|
|
50
|
+
* the turn's scope with the turn's services (RunContext, TurnEvents,
|
|
51
|
+
* TurnTasks and the input's `layer`) provided: the same instances the
|
|
52
|
+
* tools, policy and subscriptions see.
|
|
53
|
+
*/
|
|
54
|
+
readonly turn: <A = never, E = never, R = never>(input: TurnInput<A, E>, use: (turn: Turn) => Effect.Effect<TurnOutcome, HarnessError, R>) => Effect.Effect<TurnOutcome, HarnessError | E, Exclude<R, A | TurnServices | Scope.Scope>>;
|
|
55
|
+
}
|
|
56
|
+
export declare const Agent: {
|
|
57
|
+
define: (config: AgentConfig) => Effect.Effect<Agent, HarnessError, Scope.Scope>;
|
|
58
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Context, Effect, Option, Schema } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
cacheKeyOf,
|
|
4
|
+
CapabilitiesLive,
|
|
5
|
+
Capabilities,
|
|
6
|
+
ConversationMemory,
|
|
7
|
+
definePlugin,
|
|
8
|
+
guardTurn,
|
|
9
|
+
harnessDefectsAsFailures,
|
|
10
|
+
HarnessError,
|
|
11
|
+
openTurnTools,
|
|
12
|
+
SessionEnvironment,
|
|
13
|
+
Sessions,
|
|
14
|
+
StepLoop,
|
|
15
|
+
ToolRegistry,
|
|
16
|
+
RunContext,
|
|
17
|
+
settleTurn,
|
|
18
|
+
TurnEvents,
|
|
19
|
+
TurnTasks,
|
|
20
|
+
TurnLive,
|
|
21
|
+
TurnMemory,
|
|
22
|
+
TurnPrompt,
|
|
23
|
+
TurnToolbox,
|
|
24
|
+
turnOf
|
|
25
|
+
} from "@xandreed/core";
|
|
26
|
+
import { activateGraph, graphFingerprint, resolveGraph } from "@xandreed/runtime";
|
|
27
|
+
const failure = (code, message) => new HarnessError({ code, message }), missing = (key) => failure("service.missing", `The agent's plugins do not provide ${key}`), entryOf = (item) => ("plugin" in item) ? item : { plugin: item }, isWriter = (turn) => ("admitted" in turn), beginCodes = {
|
|
28
|
+
SessionBusy: "session.busy",
|
|
29
|
+
TurnDuplicate: "turn.duplicate",
|
|
30
|
+
KeyConflict: "turn.key-conflict",
|
|
31
|
+
NothingPending: "session.nothing-pending",
|
|
32
|
+
TurnRefused: "turn.refused",
|
|
33
|
+
SessionMissing: "session.missing",
|
|
34
|
+
SessionLogError: "session.log"
|
|
35
|
+
}, beginTurn = (context, turn) => required(context, Sessions).pipe(Effect.flatMap((sessions) => sessions.begin(turn.session, {
|
|
36
|
+
_tag: "User",
|
|
37
|
+
userMessage: turn.userMessage,
|
|
38
|
+
runId: turn.runId,
|
|
39
|
+
key: turn.key ?? turn.runId,
|
|
40
|
+
command: turn.command ?? {}
|
|
41
|
+
})), Effect.mapError((error) => error instanceof HarnessError ? error : failure(beginCodes[error._tag], `the turn could not begin (${error._tag}) in session ${turn.session.id}`))), untilClosed = (writer) => (body) => Effect.raceFirst(body, writer.closed.pipe(Effect.flatMap((closed) => Effect.fail(failure("turn.closed", `turn ${closed.turn} was ${closed.reason}`))))), required = (context, tag) => Option.match(Context.getOption(context, tag), { onNone: () => Effect.fail(missing(tag.key)), onSome: Effect.succeed }), provideHostLayer = (layer) => (effect) => Option.match(layer, { onNone: () => effect, onSome: (hostLayer) => effect.pipe(Effect.provide(hostLayer, { local: !0 })) }), hostCapabilities = (capabilities) => definePlugin({
|
|
42
|
+
id: "@xandreed/sdk/host-contributions",
|
|
43
|
+
version: "1",
|
|
44
|
+
scope: "runtime",
|
|
45
|
+
config: Schema.Struct({}),
|
|
46
|
+
defaults: {},
|
|
47
|
+
provides: [],
|
|
48
|
+
contributes: [Capabilities],
|
|
49
|
+
layer: () => CapabilitiesLive(...capabilities)
|
|
50
|
+
}), builtInTurnKeys = [RunContext, TurnEvents, TurnTasks, TurnMemory, TurnPrompt, TurnToolbox].map((tag) => tag.key), turnDependentNodes = (graph) => graph.nodes.reduce((late, node) => {
|
|
51
|
+
return [...node.plugin.requires, ...node.plugin.optional ?? []].some((key) => builtInTurnKeys.includes(key) || late.some((source) => source.plugin.provides.includes(key) || (source.plugin.contributes ?? []).includes(key))) ? [...late, node] : late;
|
|
52
|
+
}, []), makeAgent = (config) => Effect.gen(function* () {
|
|
53
|
+
const parent = yield* Effect.scope, entries = config.plugins.map(entryOf), hosted = config.capabilities ?? [], host = hostCapabilities(hosted), withHost = hosted.length > 0, plugins = [...withHost ? [host] : [], ...entries.map((entry) => entry.plugin)], harness = {
|
|
54
|
+
version: 1,
|
|
55
|
+
plugins: [
|
|
56
|
+
...withHost ? [{ id: host.id, use: host.id }] : [],
|
|
57
|
+
...entries.map((entry) => ({ id: entry.id ?? entry.plugin.id, use: entry.plugin.id, options: { ...entry.options } }))
|
|
58
|
+
],
|
|
59
|
+
...config.bindings === void 0 ? {} : { bindings: { ...config.bindings } },
|
|
60
|
+
system: config.system ?? ""
|
|
61
|
+
}, services = config.services ?? Context.empty(), turnKeys = (config.turnServices ?? []).map((tag) => tag.key), external = [SessionEnvironment.key, ...services.mapUnsafe.keys(), ...turnKeys, ...builtInTurnKeys], graph = yield* resolveGraph(harness, plugins, external), late = turnDependentNodes(graph), invalidLate = late.find((node) => node.plugin.scope === "runtime" || node.plugin.provides.some((key) => [ConversationMemory.key, ToolRegistry.key, StepLoop.key].includes(key)));
|
|
62
|
+
if (invalidLate !== void 0)
|
|
63
|
+
return yield* Effect.fail(failure("config.graph", `${invalidLate.entry.id}: a plugin requiring turn services must be session-scoped and cannot supply the turn's memory, registry or loop`));
|
|
64
|
+
const beforeTurn = { ...graph, nodes: graph.nodes.filter((node) => !late.includes(node)) }, afterTurn = { ...graph, nodes: late }, workspace = config.workspace ?? ".", seed = Context.add(services, SessionEnvironment, { workspace }), runtime = yield* activateGraph(beforeTurn, "runtime", Context.makeUnsafe(seed.mapUnsafe), parent), perTurn = beforeTurn.nodes.some((node) => node.plugin.scope === "session"), turn = (input, use) => harnessDefectsAsFailures(Effect.scoped(Effect.gen(function* () {
|
|
65
|
+
const scope = yield* Effect.scope, absent = turnKeys.filter((key) => !input.services.mapUnsafe.has(key));
|
|
66
|
+
if (absent.length > 0)
|
|
67
|
+
return yield* Effect.fail(failure("service.missing", `The turn does not provide ${absent.join(", ")}`));
|
|
68
|
+
const merged = Context.merge(runtime, input.services), context = perTurn ? yield* activateGraph(beforeTurn, "session", merged, scope) : merged, memory = yield* required(context, ConversationMemory), registry = yield* required(context, ToolRegistry), loop = yield* required(context, StepLoop), own = !isWriter(input.turn), writer = isWriter(input.turn) ? input.turn : yield* beginTurn(context, input.turn), system = input.system ?? config.system, live = {
|
|
69
|
+
turn: writer,
|
|
70
|
+
...system === void 0 ? {} : { system },
|
|
71
|
+
...config.maxEventDepth === void 0 ? {} : { maxEventDepth: config.maxEventDepth }
|
|
72
|
+
}, runOptions = {
|
|
73
|
+
...config.limits === void 0 ? {} : { limits: config.limits },
|
|
74
|
+
...config.budgetTokens === void 0 ? {} : { budgetTokens: config.budgetTokens },
|
|
75
|
+
cacheKey: Option.orElse(Option.fromNullishOr(input.cacheKey), () => cacheKeyOf(config.cacheKeyPrefix ?? "", writer.admitted.session.id)),
|
|
76
|
+
...input.steering === void 0 ? {} : { steering: input.steering }
|
|
77
|
+
}, body = Effect.gen(function* () {
|
|
78
|
+
yield* (yield* TurnMemory).persistMessage;
|
|
79
|
+
yield* openTurnTools;
|
|
80
|
+
return yield* use(yield* turnOf(runOptions));
|
|
81
|
+
}), provideTurnPlugins = (effect) => late.length === 0 ? effect : Effect.gen(function* () {
|
|
82
|
+
const liveContext = yield* Effect.context();
|
|
83
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
84
|
+
const activated = yield* activateGraph(afterTurn, "session", Context.merge(context, liveContext), yield* Effect.scope);
|
|
85
|
+
return yield* effect.pipe(Effect.provide(activated));
|
|
86
|
+
}));
|
|
87
|
+
}), outcome = yield* body.pipe(Effect.tap(() => settleTurn), Effect.scoped, Effect.tap(() => settleTurn), provideHostLayer(Option.fromNullishOr(input.layer)), Effect.tap(() => settleTurn), provideTurnPlugins, guardTurn, Effect.provide(TurnLive(live), { local: !0 }), Effect.provideService(ConversationMemory, memory), Effect.provideService(ToolRegistry, registry), Effect.provideService(StepLoop, loop), Effect.provide(context), untilClosed(writer));
|
|
88
|
+
if (own)
|
|
89
|
+
yield* writer.end({ reason: outcome.outcome, failure: Option.none() });
|
|
90
|
+
return outcome;
|
|
91
|
+
})));
|
|
92
|
+
return { fingerprint: graphFingerprint(graph), turn };
|
|
93
|
+
});
|
|
94
|
+
export const Agent = { define: makeAgent };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Tool } from "effect/ai";
|
|
2
|
+
import { Effect, Layer, Schema, Scope } from "effect";
|
|
3
|
+
import { HarnessError, SessionLog } from "@xandreed/core";
|
|
4
|
+
import { Agent } from "./agent.adapter.js";
|
|
5
|
+
import type { AgentConfig } from "./agent.adapter.js";
|
|
6
|
+
export declare const Lookup: Tool.Tool<"lookup", {
|
|
7
|
+
readonly parameters: Schema.Struct<{
|
|
8
|
+
readonly query: Schema.String;
|
|
9
|
+
}>;
|
|
10
|
+
readonly success: Schema.Struct<{
|
|
11
|
+
readonly items: Schema.$Array<Schema.Struct<{
|
|
12
|
+
readonly id: Schema.String;
|
|
13
|
+
readonly detail: Schema.String;
|
|
14
|
+
}>>;
|
|
15
|
+
}>;
|
|
16
|
+
readonly failure: Schema.Struct<{
|
|
17
|
+
readonly error: Schema.String;
|
|
18
|
+
readonly message: Schema.String;
|
|
19
|
+
}>;
|
|
20
|
+
readonly failureMode: "return";
|
|
21
|
+
}, never>;
|
|
22
|
+
export declare const Deliver: Tool.Tool<"deliver", {
|
|
23
|
+
readonly parameters: Schema.Struct<{
|
|
24
|
+
readonly text: Schema.String;
|
|
25
|
+
}>;
|
|
26
|
+
readonly success: Schema.Boolean;
|
|
27
|
+
readonly failure: Schema.Struct<{
|
|
28
|
+
readonly error: Schema.String;
|
|
29
|
+
readonly message: Schema.String;
|
|
30
|
+
}>;
|
|
31
|
+
readonly failureMode: "return";
|
|
32
|
+
}, never>;
|
|
33
|
+
export declare const goldenHost: import("@xandreed/core").Capability;
|
|
34
|
+
/** The agent every composition of the golden conversation reproduces. */
|
|
35
|
+
export declare const goldenConfig: AgentConfig;
|
|
36
|
+
/** What one turn left: its outcome (or failure), the events its host saw and what the memory held. */
|
|
37
|
+
interface Observed {
|
|
38
|
+
readonly runId: string;
|
|
39
|
+
readonly outcome: unknown;
|
|
40
|
+
readonly events: ReadonlyArray<string>;
|
|
41
|
+
readonly contextBuilt: ReadonlyArray<unknown>;
|
|
42
|
+
readonly entries: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The golden conversation over `runner` — anything that runs a turn the way
|
|
46
|
+
* `Agent.turn` does, acquired in the conversation's scope — under the fixed
|
|
47
|
+
* clock, in one session of `log` (in memory unless given): every turn, then
|
|
48
|
+
* the session's events and the memory log they hold.
|
|
49
|
+
*/
|
|
50
|
+
export declare const runGolden: (runner: Effect.Effect<Pick<Agent, "turn">, HarnessError, Scope.Scope>, log?: Layer.Layer<SessionLog, unknown>) => Effect.Effect<{
|
|
51
|
+
turns: readonly Observed[];
|
|
52
|
+
journal: string[];
|
|
53
|
+
log: readonly {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly runId: string;
|
|
56
|
+
readonly turn: number;
|
|
57
|
+
readonly step: number;
|
|
58
|
+
readonly at: number;
|
|
59
|
+
readonly body: {
|
|
60
|
+
readonly _tag: "SystemPrepared";
|
|
61
|
+
readonly fingerprint: string;
|
|
62
|
+
readonly text: string;
|
|
63
|
+
readonly sections: readonly {
|
|
64
|
+
readonly id: string;
|
|
65
|
+
readonly version: string;
|
|
66
|
+
readonly fingerprint: string;
|
|
67
|
+
}[];
|
|
68
|
+
} | {
|
|
69
|
+
readonly _tag: "TurnStarted";
|
|
70
|
+
readonly prompt: string;
|
|
71
|
+
} | {
|
|
72
|
+
readonly _tag: "TurnContext";
|
|
73
|
+
readonly sectionId: string;
|
|
74
|
+
readonly version: string;
|
|
75
|
+
readonly text: string;
|
|
76
|
+
} | {
|
|
77
|
+
readonly _tag: "Message";
|
|
78
|
+
readonly message: {
|
|
79
|
+
readonly role: "user";
|
|
80
|
+
readonly content: string;
|
|
81
|
+
readonly providerOptions?: unknown;
|
|
82
|
+
} | {
|
|
83
|
+
readonly role: "assistant";
|
|
84
|
+
readonly content: readonly ({
|
|
85
|
+
readonly type: "text";
|
|
86
|
+
readonly text: string;
|
|
87
|
+
readonly providerOptions?: unknown;
|
|
88
|
+
} | {
|
|
89
|
+
readonly type: "reasoning";
|
|
90
|
+
readonly text: string;
|
|
91
|
+
readonly providerOptions?: unknown;
|
|
92
|
+
} | {
|
|
93
|
+
readonly type: "tool-call";
|
|
94
|
+
readonly toolCallId: string;
|
|
95
|
+
readonly toolName: string;
|
|
96
|
+
readonly input: unknown;
|
|
97
|
+
readonly providerOptions?: unknown;
|
|
98
|
+
readonly providerExecuted?: boolean | undefined;
|
|
99
|
+
})[];
|
|
100
|
+
readonly providerOptions?: unknown;
|
|
101
|
+
} | {
|
|
102
|
+
readonly role: "tool";
|
|
103
|
+
readonly content: readonly {
|
|
104
|
+
readonly type: "tool-result";
|
|
105
|
+
readonly toolCallId: string;
|
|
106
|
+
readonly toolName: string;
|
|
107
|
+
readonly output: unknown;
|
|
108
|
+
readonly providerOptions?: unknown;
|
|
109
|
+
readonly isError?: boolean | undefined;
|
|
110
|
+
}[];
|
|
111
|
+
readonly providerOptions?: unknown;
|
|
112
|
+
};
|
|
113
|
+
} | {
|
|
114
|
+
readonly _tag: "ToolResult";
|
|
115
|
+
readonly toolCallId: string;
|
|
116
|
+
readonly toolName: string;
|
|
117
|
+
readonly isError: boolean;
|
|
118
|
+
readonly encoded: unknown;
|
|
119
|
+
readonly view: string;
|
|
120
|
+
readonly viewVersion: string;
|
|
121
|
+
readonly subjects: readonly {
|
|
122
|
+
readonly kind: string;
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly label: string | null;
|
|
125
|
+
readonly data: unknown;
|
|
126
|
+
}[];
|
|
127
|
+
readonly artifacts: readonly {
|
|
128
|
+
readonly id: string;
|
|
129
|
+
readonly kind: "image" | "file";
|
|
130
|
+
readonly mediaType: string;
|
|
131
|
+
readonly url: string;
|
|
132
|
+
readonly alt: string | null;
|
|
133
|
+
}[];
|
|
134
|
+
readonly pinned: boolean;
|
|
135
|
+
} | {
|
|
136
|
+
readonly _tag: "ToolDigest";
|
|
137
|
+
readonly entry: string;
|
|
138
|
+
readonly version: string;
|
|
139
|
+
readonly mode: "select" | "summarize";
|
|
140
|
+
readonly keep: readonly string[];
|
|
141
|
+
readonly text: string;
|
|
142
|
+
readonly digester: string;
|
|
143
|
+
readonly trigger: "write" | "compaction";
|
|
144
|
+
} | {
|
|
145
|
+
readonly _tag: "StepContext";
|
|
146
|
+
readonly step: number;
|
|
147
|
+
readonly text: string;
|
|
148
|
+
} | {
|
|
149
|
+
readonly _tag: "ToolsActivated";
|
|
150
|
+
readonly skills: readonly string[];
|
|
151
|
+
readonly tools: readonly string[];
|
|
152
|
+
readonly source: "always" | "matcher" | "load_skill" | "host";
|
|
153
|
+
readonly decision: string | null;
|
|
154
|
+
} | {
|
|
155
|
+
readonly _tag: "Compaction";
|
|
156
|
+
readonly strategy: string;
|
|
157
|
+
readonly version: string;
|
|
158
|
+
readonly action: {
|
|
159
|
+
readonly _tag: "CompactViews";
|
|
160
|
+
readonly entries: readonly string[];
|
|
161
|
+
readonly texts: readonly string[];
|
|
162
|
+
} | {
|
|
163
|
+
readonly _tag: "Spill";
|
|
164
|
+
readonly entry: string;
|
|
165
|
+
readonly preview: string;
|
|
166
|
+
} | {
|
|
167
|
+
readonly _tag: "DropTurns";
|
|
168
|
+
readonly throughTurn: number;
|
|
169
|
+
readonly ledger: string;
|
|
170
|
+
} | {
|
|
171
|
+
readonly _tag: "Summarize";
|
|
172
|
+
readonly keepFromTurn: number;
|
|
173
|
+
readonly summary: string;
|
|
174
|
+
};
|
|
175
|
+
} | {
|
|
176
|
+
readonly _tag: "TurnEnded";
|
|
177
|
+
readonly outcome: "partial" | "completed" | "failed";
|
|
178
|
+
readonly reply: string | null;
|
|
179
|
+
};
|
|
180
|
+
}[];
|
|
181
|
+
requests: readonly unknown[];
|
|
182
|
+
}, unknown, never>;
|
|
183
|
+
/** The golden conversation's two halves, each as the exact text of its file. */
|
|
184
|
+
export declare const goldenTexts: (result: Effect.Success<ReturnType<typeof runGolden>>) => {
|
|
185
|
+
model: string;
|
|
186
|
+
journal: string;
|
|
187
|
+
};
|
|
188
|
+
/** Compare a run with both golden files (and rewrite the ones the environment asks for). */
|
|
189
|
+
export declare const expectGolden: (result: Effect.Success<ReturnType<typeof runGolden>>) => Promise<void>;
|
|
190
|
+
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { Approval } from "@xandreed/core";
|
|
2
3
|
import type { HarnessError } from "@xandreed/core";
|
|
3
4
|
/** A host supplies interaction; unattended hosts deny requests by default. */
|
|
4
|
-
export declare const approvalPlugin: (request?: (description: string) => Effect.Effect<boolean, HarnessError>) => import("@xandreed/core").
|
|
5
|
+
export declare const approvalPlugin: (request?: (description: string) => Effect.Effect<boolean, HarnessError>) => import("@xandreed/core").TypedPlugin<{}, {}, Approval, never, never>;
|
package/dist/approval.adapter.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Effect, Layer, Schema } from "effect";
|
|
|
2
2
|
import { Approval, definePlugin } from "@xandreed/core";
|
|
3
3
|
export const approvalPlugin = (request = () => Effect.succeed(!1)) => definePlugin({
|
|
4
4
|
id: "efferent/approval-host",
|
|
5
|
-
version: "0.
|
|
5
|
+
version: "0.11.0",
|
|
6
6
|
scope: "runtime",
|
|
7
7
|
config: Schema.Struct({}),
|
|
8
8
|
defaults: {},
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
2
|
import { HarnessError, SessionStore } from "@xandreed/core";
|
|
3
3
|
import type { ConversationId, Session, SessionHandle } from "@xandreed/core";
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Adapt a domain session's event protocol to the durable harness lifecycle.
|
|
6
|
+
* When the host provides a ConversationStore, the domain session's writes to
|
|
7
|
+
* its conversation are bound to the harness run (see `turnBound`). They are
|
|
8
|
+
* recorded beside the turn, not through its writer.
|
|
9
|
+
*/
|
|
5
10
|
export declare const domainLoop: <E extends {
|
|
6
11
|
readonly type: string;
|
|
7
12
|
}, R>(options: {
|
|
@@ -1,26 +1,47 @@
|
|
|
1
|
-
import { Effect, Fiber, Option, Ref, Stream } from "effect";
|
|
2
|
-
import { AgentLoop, HarnessError, SessionEnvironment, SessionStore } from "@xandreed/core";
|
|
1
|
+
import { Context, Effect, Fiber, Filter, Option, Ref, Stream } from "effect";
|
|
2
|
+
import { AgentLoop, ConversationStore, HarnessError, SessionEnvironment, SessionStore, StoreError } from "@xandreed/core";
|
|
3
|
+
const turnBound = (store, id, active, current) => {
|
|
4
|
+
const guard = (target, write) => target !== id ? write : Effect.gen(function* () {
|
|
5
|
+
const origin = yield* current, running = yield* Ref.get(active);
|
|
6
|
+
return yield* Option.isSome(origin) && Option.contains(running, origin.value) ? write : Effect.fail(new StoreError({ message: `conversation ${id}: its turn is over, so the write is refused` }));
|
|
7
|
+
});
|
|
8
|
+
return ConversationStore.of({
|
|
9
|
+
...store,
|
|
10
|
+
append: (target, message) => guard(target, store.append(target, message)),
|
|
11
|
+
appendAll: (target, messages) => guard(target, store.appendAll(target, messages)),
|
|
12
|
+
checkpoint: (target, summary) => guard(target, store.checkpoint(target, summary)),
|
|
13
|
+
checkpointAt: (target, summary, position) => guard(target, store.checkpointAt(target, summary, position)),
|
|
14
|
+
setTitle: (target, title) => guard(target, store.setTitle(target, title)),
|
|
15
|
+
recordOutcome: (target, outcome, reason) => guard(target, store.recordOutcome(target, outcome, reason))
|
|
16
|
+
});
|
|
17
|
+
};
|
|
3
18
|
export const domainLoop = (options) => Effect.gen(function* () {
|
|
4
|
-
const context = yield* Effect.context(), store = yield* SessionStore, environment = yield* Effect.serviceOption(SessionEnvironment), acquire = (id) => Effect.gen(function* () {
|
|
19
|
+
const context = yield* Effect.context(), store = yield* SessionStore, environment = yield* Effect.serviceOption(SessionEnvironment), conversations = yield* Effect.serviceOption(ConversationStore), currentDomainRun = Context.Reference(`@xandreed/sdk/domainRun/${yield* Effect.sync(() => crypto.randomUUID())}`, { defaultValue: () => Option.none() }), running = yield* Ref.make(Option.none()), acquire = (id) => Effect.gen(function* () {
|
|
5
20
|
if (options.restore !== void 0) {
|
|
6
21
|
const snapshot = (yield* store.read(id, -1)).filter((event) => event.name === "domain.snapshot").at(-1);
|
|
7
22
|
if (snapshot !== void 0)
|
|
8
23
|
yield* options.restore(id, snapshot.data).pipe(Effect.provide(context));
|
|
9
24
|
}
|
|
10
|
-
|
|
25
|
+
const created = options.create(id);
|
|
26
|
+
return yield* Option.match(conversations, {
|
|
27
|
+
onNone: () => created,
|
|
28
|
+
onSome: (conversation) => created.pipe(Effect.provideService(ConversationStore, turnBound(conversation, id, running, currentDomainRun)))
|
|
29
|
+
}).pipe(Effect.provide(context));
|
|
11
30
|
}), record = Option.isSome(environment) ? environment.value.session : void 0, active = yield* Ref.make(record === void 0 ? Option.none() : Option.some(yield* acquire(record.id)));
|
|
12
31
|
yield* Effect.addFinalizer(() => Ref.get(active).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (session) => session.shutdown }))));
|
|
13
32
|
return AgentLoop.of({ run: (input) => Effect.scoped(Effect.gen(function* () {
|
|
33
|
+
const token = yield* Effect.sync(() => crypto.randomUUID());
|
|
34
|
+
yield* Ref.set(running, Option.some(token));
|
|
14
35
|
const found = yield* Ref.get(active), session = Option.isSome(found) ? found.value : yield* acquire(input.session.id).pipe(Effect.tap((value) => Ref.set(active, Option.some(value)))), cursor = (yield* session.state).cursor, result = yield* Ref.make(Option.none()), collector = yield* Effect.forkScoped(session.subscribe(cursor).pipe(Stream.tap(({ event }) => input.publish({ name: "domain.event", runId: input.runId, data: { event } })), Stream.tap(({ event }) => Ref.update(result, (current) => Option.orElse(options.result(event), () => current))), Stream.takeUntil(({ event }) => Option.isSome(options.result(event))), Stream.runDrain));
|
|
15
36
|
yield* Effect.forkScoped(session.transient.pipe(Stream.runForEach((event) => input.transient({ name: "domain.delta", runId: input.runId, data: { event } }))));
|
|
16
|
-
yield* session.send(input.
|
|
37
|
+
yield* session.send(input.userMessage.text).pipe(Effect.provideService(currentDomainRun, Option.some(token)));
|
|
17
38
|
if (!(yield* session.state).log.filter((entry) => entry.seq >= cursor).some(({ event }) => Option.isSome(options.result(event))))
|
|
18
39
|
return yield* Effect.fail(new HarnessError({ code: "domain.unsettled", message: "The domain turn ended without a terminal event" }));
|
|
19
40
|
yield* Fiber.join(collector);
|
|
20
41
|
if (options.snapshot !== void 0)
|
|
21
42
|
yield* options.snapshot(input.session.id).pipe(Effect.provide(context), Effect.flatMap((data) => input.publish({ name: "domain.snapshot", runId: input.runId, data })));
|
|
22
43
|
return yield* Ref.get(result).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.fail(new HarnessError({ code: "domain.result", message: "The domain session ended without a result" })), onSome: Effect.succeed })));
|
|
23
|
-
})).pipe(Effect.onInterrupt(() => Ref.get(active).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (session) => session.interrupt }))))) });
|
|
44
|
+
})).pipe(Effect.ensuring(Ref.set(running, Option.none())), Effect.onInterrupt(() => Ref.get(active).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (session) => session.interrupt }))))) });
|
|
24
45
|
}), domainSession = (handle, decode, onError) => {
|
|
25
46
|
const event = (name, data) => name === "domain.event" ? decode(data.event) : ["run.failed", "run.cancelled"].includes(name) ? Option.some(onError(String(data.message))) : Option.none();
|
|
26
47
|
return {
|
|
@@ -29,7 +50,7 @@ export const domainLoop = (options) => Effect.gen(function* () {
|
|
|
29
50
|
interrupt: handle.interrupt,
|
|
30
51
|
shutdown: handle.close,
|
|
31
52
|
state: handle.history.pipe(Effect.map((events) => ({ cursor: (events.at(-1)?.seq ?? -1) + 1, log: events.flatMap((entry) => Option.toArray(Option.map(event(entry.name, entry.data), (value) => ({ seq: entry.seq, event: value })))) })), Effect.orDie),
|
|
32
|
-
subscribe: (since) => handle.events(since - 1).pipe(Stream.map((entry) => Option.map(event(entry.name, entry.data), (value) => ({ seq: entry.seq, event: value }))), Stream.filterMap((value) => value), Stream.orDie),
|
|
33
|
-
transient: handle.transient.pipe(Stream.filterMap((entry) => entry.name === "domain.delta" ? decode(entry.data.event) : Option.none()))
|
|
53
|
+
subscribe: (since) => handle.events(since - 1).pipe(Stream.map((entry) => Option.map(event(entry.name, entry.data), (value) => ({ seq: entry.seq, event: value }))), Stream.filterMap(Filter.fromPredicateOption((value) => value)), Stream.orDie),
|
|
54
|
+
transient: handle.transient.pipe(Stream.filterMap(Filter.fromPredicateOption((entry) => entry.name === "domain.delta" ? decode(entry.data.event) : Option.none())))
|
|
34
55
|
};
|
|
35
56
|
};
|
package/dist/harness.d.ts
CHANGED
|
@@ -40,4 +40,4 @@ export declare const Harness: {
|
|
|
40
40
|
reconfigure: (config: HarnessConfig, plugins?: ReadonlyArray<Plugin>) => Effect.Effect<"applied" | "restart-required", HarnessError, never>;
|
|
41
41
|
}, HarnessError, Scope.Scope>;
|
|
42
42
|
};
|
|
43
|
-
export type Harness = Effect.
|
|
43
|
+
export type Harness = Effect.Success<ReturnType<typeof makeHarness>>;
|
package/dist/harness.js
CHANGED
|
@@ -1,30 +1,44 @@
|
|
|
1
|
-
import { Cause, Context, Effect, Exit, Fiber, Option, PubSub, Ref, Scope, Stream } from "effect";
|
|
2
|
-
import { AgentLoop, HarnessError, SessionEnvironment, SessionStore, TurnHooks } from "@xandreed/core";
|
|
1
|
+
import { Cause, Clock, Context, Effect, Exit, Fiber, Option, PubSub, Ref, Scope, Stream, Semaphore } from "effect";
|
|
2
|
+
import { AgentLoop, HarnessError, SessionEnvironment, SessionStore, Sessions, TurnHooks, UserMessage } from "@xandreed/core";
|
|
3
3
|
import { activateGraph, graphFingerprint, resolveGraph } from "@xandreed/runtime";
|
|
4
|
-
const failure = (code, message) => new HarnessError({ code, message }),
|
|
4
|
+
const failure = (code, message) => new HarnessError({ code, message }), pendingAfter = (queue, events) => events.reduce((pending, event) => {
|
|
5
|
+
if (event.name === "input.queued" && typeof event.data.id === "string" && typeof event.data.text === "string") {
|
|
6
|
+
const id = event.data.id;
|
|
7
|
+
return pending.some((input) => input.id === id) ? pending : [...pending, { id, text: event.data.text, kind: event.data.kind === "steer" ? "steer" : "turn" }];
|
|
8
|
+
}
|
|
9
|
+
return event.name === "input.claimed" ? pending.filter((input) => input.id !== event.data.id) : pending;
|
|
10
|
+
}, queue), advance = (state, events) => {
|
|
11
|
+
const fresh = events.filter((event) => event.seq > state.seen);
|
|
12
|
+
return fresh.length === 0 ? state : { seen: fresh.reduce((last, event) => Math.max(last, event.seq), state.seen), queue: pendingAfter(state.queue, fresh) };
|
|
13
|
+
}, isClosed = (error) => error.code === "turn.closed", service = (context, tag) => Option.match(Context.getOption(context, tag), {
|
|
5
14
|
onNone: () => Effect.fail(failure("service.missing", `The profile does not provide ${tag.key}`)),
|
|
6
15
|
onSome: Effect.succeed
|
|
7
16
|
});
|
|
8
17
|
export const makeHarness = (options) => Effect.gen(function* () {
|
|
9
|
-
const parent = yield* Effect.scope, initial = yield* resolveGraph(options.config, options.plugins, [SessionEnvironment.key]), graphRef = yield* Ref.make(initial), registryRef = yield* Ref.make(options.plugins), seed = Context.make(SessionEnvironment, { workspace: options.workspace }), runtime = yield* activateGraph(initial, "runtime", Context.
|
|
18
|
+
const parent = yield* Effect.scope, initial = yield* resolveGraph(options.config, options.plugins, [SessionEnvironment.key]), graphRef = yield* Ref.make(initial), registryRef = yield* Ref.make(options.plugins), seed = Context.make(SessionEnvironment, { workspace: options.workspace }), runtime = yield* activateGraph(initial, "runtime", Context.makeUnsafe(seed.mapUnsafe), parent), store = yield* service(runtime, SessionStore), sessions = yield* Option.match(Context.getOption(runtime, Sessions), {
|
|
19
|
+
onNone: () => Effect.fail(failure("service.missing", `Harness requires ${Sessions.key}: add sessionsPlugin (@xandreed/plugin-sessions) to the configuration beside the session storage plugin`)),
|
|
20
|
+
onSome: Effect.succeed
|
|
21
|
+
}), handles = yield* Ref.make(new Map), open = (record) => Effect.gen(function* () {
|
|
10
22
|
const existing = (yield* Ref.get(handles)).get(record.id);
|
|
11
23
|
if (existing !== void 0)
|
|
12
24
|
return existing;
|
|
13
|
-
const journal = yield* store.read(record.id, -1), abandoned = journal.reduce((runs, event) => {
|
|
25
|
+
const journal = yield* store.read(record.id, -1), durable = yield* sessions.get({ id: record.id, owner: options.workspace }).pipe(Effect.mapError((error) => failure("session.log", String(error)))), abandoned = journal.reduce((runs, event) => {
|
|
14
26
|
if (event.runId === void 0)
|
|
15
27
|
return runs;
|
|
16
28
|
return event.name === "run.started" ? [...runs, event.runId] : ["run.completed", "run.cancelled", "run.failed"].includes(event.name) ? runs.filter((id) => id !== event.runId) : runs;
|
|
17
29
|
}, []);
|
|
18
|
-
yield* Effect.forEach(abandoned, (runId) => store.append(record.id, { name: "run.cancelled", runId, data: { reason: "process interrupted before settlement; tool effects may be incomplete" } }));
|
|
19
|
-
const pending = yield* Ref.make(journal.
|
|
20
|
-
|
|
21
|
-
return [...queue, { id: event.data.id, text: event.data.text, kind: event.data.kind === "steer" ? "steer" : "turn" }];
|
|
22
|
-
return event.name === "input.claimed" ? queue.filter((input) => input.id !== event.data.id) : queue;
|
|
23
|
-
}, [])), notifications = yield* PubSub.sliding(1), transientHub = yield* PubSub.sliding(128), running = yield* Ref.make(Option.none()), busy = yield* Ref.make(!1), closed = yield* Ref.make(!1), gate = yield* Effect.makeSemaphore(1), acquire = (graph) => Effect.gen(function* () {
|
|
24
|
-
const scope = yield* Scope.make(), sessionSeed = Context.add(runtime, SessionEnvironment, { workspace: options.workspace, session: record }), context = yield* activateGraph(graph, "session", Context.unsafeMake(sessionSeed.unsafeMap), scope).pipe(Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
|
|
30
|
+
yield* Effect.forEach(abandoned.filter((runId) => !Option.exists(durable.open, (open) => open.runId === runId)), (runId) => store.append(record.id, { name: "run.cancelled", runId, data: { reason: "process interrupted before settlement; tool effects may be incomplete" } }));
|
|
31
|
+
const pending = yield* Ref.make(advance({ seen: -1, queue: [] }, journal)), notifications = yield* PubSub.sliding(1), transientHub = yield* PubSub.sliding(128), running = yield* Ref.make(Option.none()), busy = yield* Ref.make(!1), closed = yield* Ref.make(!1), writing = yield* Ref.make(Option.none()), position = yield* Ref.make(-1), publishing = yield* Semaphore.make(1), gate = yield* Semaphore.make(1), acquire = (graph) => Effect.gen(function* () {
|
|
32
|
+
const scope = yield* Scope.make(), sessionSeed = Context.add(runtime, SessionEnvironment, { workspace: options.workspace, session: record }), context = yield* activateGraph(graph, "session", Context.makeUnsafe(sessionSeed.mapUnsafe), scope).pipe(Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
|
|
25
33
|
yield* service(context, AgentLoop).pipe(Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
|
|
26
34
|
return { context, scope, graph, fingerprint: graphFingerprint(graph) };
|
|
27
|
-
}), mounted = yield* Ref.make(yield* acquire(yield* Ref.get(graphRef))),
|
|
35
|
+
}), mounted = yield* Ref.make(yield* acquire(yield* Ref.get(graphRef))), publishUnlocked = (body, outside = !1) => Ref.get(writing).pipe(Effect.flatMap(Option.match({
|
|
36
|
+
onNone: () => store.append(record.id, body),
|
|
37
|
+
onSome: (writer) => writer.transact((foreign) => Effect.gen(function* () {
|
|
38
|
+
const foreignPosition = foreign.reduce((max, entry) => entry.kind === "harness.event" && typeof entry.data.event === "object" && entry.data.event !== null && ("seq" in entry.data.event) && typeof entry.data.event.seq === "number" ? Math.max(max, entry.data.event.seq) : max, -1), seq = Math.max(yield* Ref.get(position), foreignPosition) + 1, event = { ...body, version: 1, id: yield* Effect.sync(() => crypto.randomUUID()), sessionId: record.id, seq, at: yield* Clock.currentTimeMillis };
|
|
39
|
+
return { drafts: [{ kind: "harness.event", data: { event } }], result: event };
|
|
40
|
+
})).pipe(Effect.map((done) => done.result), Effect.tap((event) => Ref.set(position, event.seq)), Effect.catchTag("HarnessError", (error) => outside && isClosed(error) ? store.append(record.id, body) : Effect.fail(error)))
|
|
41
|
+
})), Effect.tap(() => PubSub.publish(notifications, void 0))), publish = (body) => publishing.withPermits(1)(publishUnlocked(body)), refreshPending = Ref.get(pending).pipe(Effect.flatMap((state) => store.read(record.id, state.seen)), Effect.flatMap((events) => Ref.update(pending, (state) => advance(state, events)))), assertOpen = Ref.get(closed).pipe(Effect.flatMap((value) => value ? Effect.fail(failure("session.closed", "Session is closed")) : Effect.void)), refresh = Effect.gen(function* () {
|
|
28
42
|
if (yield* Ref.get(busy))
|
|
29
43
|
return;
|
|
30
44
|
const graph = yield* Ref.get(graphRef), previous = yield* Ref.get(mounted);
|
|
@@ -34,62 +48,93 @@ export const makeHarness = (options) => Effect.gen(function* () {
|
|
|
34
48
|
yield* Ref.set(mounted, next);
|
|
35
49
|
yield* Scope.close(previous.scope, Exit.void);
|
|
36
50
|
yield* publish({ name: "config.applied", data: { fingerprint: next.fingerprint } });
|
|
37
|
-
}),
|
|
38
|
-
|
|
51
|
+
}), endedElsewhere = (writer) => sessions.read({ id: record.id, owner: options.workspace }, { after: writer.started.seq, kinds: ["turn.ended"] }).pipe(Effect.map((events) => Option.map(Option.fromNullishOr(events.find((event) => Option.contains(event.turn, writer.admitted.turn))), (event) => event.data.reason === "cancelled" ? "cancelled" : "interrupted")), Effect.catchTag("SessionMissing", () => Effect.succeed(Option.some("removed"))), Effect.mapError((error) => failure("session.log", error.message))), claim = (input) => publish({ name: "input.claimed", data: { id: input.id } }).pipe(Effect.andThen(refreshPending)), steering = Effect.gen(function* () {
|
|
52
|
+
yield* refreshPending;
|
|
53
|
+
const inputs = (yield* Ref.get(pending)).queue.filter((input) => input.kind === "steer");
|
|
39
54
|
yield* Effect.forEach(inputs, claim);
|
|
40
55
|
return inputs.length === 0 ? Option.none() : Option.some(inputs.map((input) => input.text).join(`
|
|
41
56
|
|
|
42
57
|
`));
|
|
43
|
-
}), run = (input) => Effect.gen(function* () {
|
|
58
|
+
}), run = (input) => Effect.scoped(Effect.gen(function* () {
|
|
44
59
|
yield* refresh;
|
|
45
|
-
const current = yield* Ref.get(mounted), loop = yield* service(current.context, AgentLoop), hooks = Context.getOption(current.context, TurnHooks), runId = crypto.randomUUID()
|
|
60
|
+
const current = yield* Ref.get(mounted), loop = yield* service(current.context, AgentLoop), hooks = Context.getOption(current.context, TurnHooks), runId = crypto.randomUUID(), begun = yield* Effect.result(sessions.begin({ id: record.id, owner: options.workspace }, {
|
|
61
|
+
_tag: "User",
|
|
62
|
+
userMessage: new UserMessage({ text: input.text }),
|
|
63
|
+
runId,
|
|
64
|
+
key: input.id,
|
|
65
|
+
command: { kind: input.kind }
|
|
66
|
+
}));
|
|
67
|
+
if (begun._tag === "Failure") {
|
|
68
|
+
if (begun.failure._tag === "TurnDuplicate") {
|
|
69
|
+
yield* claim(input);
|
|
70
|
+
return !0;
|
|
71
|
+
}
|
|
72
|
+
return yield* Effect.fail(failure(begun.failure._tag === "SessionBusy" ? "session.busy" : "session.begin", `The turn could not begin (${begun.failure._tag})`));
|
|
73
|
+
}
|
|
74
|
+
const writer = begun.success;
|
|
75
|
+
yield* refreshPending;
|
|
76
|
+
if (!(yield* Ref.get(pending)).queue.some((item) => item.id === input.id)) {
|
|
77
|
+
yield* writer.end({ reason: "cancelled", failure: Option.some({ code: "input.consumed", message: "the input was consumed by an earlier turn" }) }).pipe(Effect.catchTag("HarnessError", (error) => isClosed(error) ? Effect.void : Effect.fail(error)));
|
|
78
|
+
return !0;
|
|
79
|
+
}
|
|
80
|
+
yield* Ref.set(position, (yield* Ref.get(pending)).seen);
|
|
81
|
+
yield* Ref.set(writing, Option.some(writer));
|
|
82
|
+
yield* Effect.addFinalizer(() => Ref.set(writing, Option.none()));
|
|
46
83
|
yield* claim(input);
|
|
47
|
-
yield* publish({ name: "run.started", runId, data: { config: current.fingerprint } });
|
|
84
|
+
const started = yield* publish({ name: "run.started", runId, data: { config: current.fingerprint } });
|
|
85
|
+
if (!(yield* store.read(record.id, started.seq - 1)).some((event) => event.id === started.id))
|
|
86
|
+
return yield* Effect.fail(failure("session.store", "The SessionStore does not read the events Sessions writes: it must be the projection over the same SessionLog"));
|
|
48
87
|
const args = {
|
|
49
88
|
session: record,
|
|
50
89
|
runId,
|
|
51
|
-
|
|
90
|
+
userMessage: new UserMessage({ text: input.text }),
|
|
52
91
|
system: current.graph.config.system ?? "You are a helpful agent.",
|
|
53
92
|
publish,
|
|
54
93
|
transient: (event) => PubSub.publish(transientHub, event).pipe(Effect.asVoid),
|
|
55
|
-
steering
|
|
94
|
+
steering,
|
|
95
|
+
history: (after, names) => store.read(record.id, after).pipe(Effect.map((events) => names.length === 0 ? events : events.filter((event) => names.includes(event.name)))),
|
|
96
|
+
services: Context.empty()
|
|
56
97
|
}, task = Effect.gen(function* () {
|
|
57
98
|
const prepared = Option.isSome(hooks) ? yield* hooks.value.before(args) : args, result = yield* loop.run(prepared);
|
|
58
99
|
if (Option.isSome(hooks))
|
|
59
100
|
yield* hooks.value.after(prepared);
|
|
60
101
|
return result;
|
|
61
|
-
}).pipe(Effect.interruptible);
|
|
102
|
+
}).pipe(Effect.interruptible, Effect.raceFirst(writer.closed.pipe(Effect.flatMap((ended) => Effect.fail(failure("turn.closed", `The turn was ${ended.reason}`))))));
|
|
62
103
|
yield* Ref.set(busy, !0);
|
|
104
|
+
yield* Effect.addFinalizer(() => Ref.set(busy, !1));
|
|
63
105
|
const fiber = yield* Effect.forkIn(task, parent);
|
|
64
106
|
yield* Ref.set(running, Option.some(fiber));
|
|
65
107
|
const exit = yield* Fiber.await(fiber);
|
|
66
108
|
yield* Ref.set(running, Option.none());
|
|
67
|
-
yield*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
runId,
|
|
71
|
-
data: { message: Cause.isInterruptedOnly(exit.cause) ? "Cancelled; unfinished tool effects are not replayed" : Cause.pretty(exit.cause) }
|
|
109
|
+
const elsewhere = yield* endedElsewhere(writer), completed = Exit.isSuccess(exit) && Option.isNone(elsewhere), cancelled = Option.isSome(elsewhere) || Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause), message = Option.match(elsewhere, {
|
|
110
|
+
onNone: () => Exit.isFailure(exit) ? Cause.hasInterruptsOnly(exit.cause) ? "Cancelled; unfinished tool effects are not replayed" : Cause.pretty(exit.cause) : "",
|
|
111
|
+
onSome: (reason) => `The turn was ${reason} elsewhere; unfinished tool effects are not replayed`
|
|
72
112
|
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
113
|
+
if (!Option.contains(elsewhere, "removed"))
|
|
114
|
+
yield* publishing.withPermits(1)(publishUnlocked(completed && Exit.isSuccess(exit) ? { name: "run.completed", runId, data: { ...exit.value } } : { name: cancelled ? "run.cancelled" : "run.failed", runId, data: { message } }, !0));
|
|
115
|
+
yield* writer.end({
|
|
116
|
+
reason: completed && Exit.isSuccess(exit) ? exit.value.outcome : cancelled ? "cancelled" : "failed",
|
|
117
|
+
failure: completed ? Option.none() : Option.some({ code: "run.failed", message })
|
|
118
|
+
}).pipe(Effect.catchTag("HarnessError", (error) => isClosed(error) ? Effect.void : Effect.fail(error)));
|
|
119
|
+
if (Option.isSome(elsewhere) && elsewhere.value !== "cancelled")
|
|
120
|
+
return yield* Effect.fail(failure("turn.closed", `The turn was ${elsewhere.value} elsewhere`));
|
|
121
|
+
if (Exit.isFailure(exit) && !cancelled)
|
|
76
122
|
return yield* Effect.fail(failure("run.failed", Cause.pretty(exit.cause)));
|
|
77
|
-
return
|
|
78
|
-
}).pipe(Effect.uninterruptible),
|
|
79
|
-
yield*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
})), enqueue = (text, kind) => Effect.gen(function* () {
|
|
123
|
+
return completed;
|
|
124
|
+
})).pipe(Effect.uninterruptible), runPending = () => Effect.gen(function* () {
|
|
125
|
+
if (yield* Ref.get(closed))
|
|
126
|
+
return;
|
|
127
|
+
yield* refreshPending;
|
|
128
|
+
const next = (yield* Ref.get(pending)).queue[0];
|
|
129
|
+
if (next !== void 0 && (yield* run(next)))
|
|
130
|
+
yield* runPending();
|
|
131
|
+
}), drain = gate.withPermits(1)(assertOpen.pipe(Effect.andThen(runPending()))), enqueue = (text, kind) => Effect.gen(function* () {
|
|
87
132
|
yield* assertOpen;
|
|
88
133
|
if (text.trim().length === 0)
|
|
89
134
|
return yield* Effect.fail(failure("input.empty", "Enter a message"));
|
|
90
135
|
const input = { id: crypto.randomUUID(), text, kind };
|
|
91
|
-
yield*
|
|
92
|
-
yield*
|
|
136
|
+
yield* publishing.withPermits(1)(publishUnlocked({ name: "input.queued", data: input }, !0));
|
|
137
|
+
yield* refreshPending;
|
|
93
138
|
}), interrupt = Ref.get(running).pipe(Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid) }))), close = Ref.getAndSet(closed, !0).pipe(Effect.flatMap((already) => already ? Effect.void : Effect.gen(function* () {
|
|
94
139
|
yield* interrupt;
|
|
95
140
|
yield* gate.withPermits(1)(Ref.get(mounted).pipe(Effect.flatMap((current) => Scope.close(current.scope, Exit.void))));
|
|
@@ -98,30 +143,30 @@ export const makeHarness = (options) => Effect.gen(function* () {
|
|
|
98
143
|
yield* Ref.update(handles, (all) => new Map([...all].filter(([id]) => id !== record.id)));
|
|
99
144
|
}))), handle = {
|
|
100
145
|
record,
|
|
101
|
-
use: (tag, run) => gate.withPermits(1)(assertOpen.pipe(Effect.
|
|
102
|
-
send: (text) => enqueue(text, "turn").pipe(Effect.
|
|
146
|
+
use: (tag, run) => gate.withPermits(1)(assertOpen.pipe(Effect.andThen(Ref.get(mounted)), Effect.flatMap((current) => service(current.context, tag)), Effect.flatMap(run))),
|
|
147
|
+
send: (text) => enqueue(text, "turn").pipe(Effect.andThen(drain)),
|
|
103
148
|
steer: (text) => enqueue(text, "steer"),
|
|
104
149
|
continue: drain,
|
|
105
150
|
interrupt,
|
|
106
151
|
close,
|
|
107
152
|
busy: Ref.get(busy),
|
|
108
|
-
pending: Ref.get(pending),
|
|
153
|
+
pending: Ref.get(pending).pipe(Effect.map((state) => state.queue)),
|
|
109
154
|
refresh: gate.withPermits(1)(refresh),
|
|
110
155
|
history: store.read(record.id, -1),
|
|
111
156
|
transient: Stream.fromPubSub(transientHub),
|
|
112
|
-
events: (after = -1) => Stream.
|
|
157
|
+
events: (after = -1) => Stream.unwrap(Effect.gen(function* () {
|
|
113
158
|
const subscription = yield* PubSub.subscribe(notifications), cursor = yield* Ref.make(after);
|
|
114
|
-
return Stream.concat(Stream.make(void 0), Stream.
|
|
159
|
+
return Stream.concat(Stream.make(void 0), Stream.fromSubscription(subscription)).pipe(Stream.mapEffect(() => Ref.get(cursor).pipe(Effect.flatMap((position) => store.read(record.id, position)), Effect.tap((events) => events.length === 0 ? Effect.void : Ref.set(cursor, events[events.length - 1].seq)))), Stream.flatMap(Stream.fromIterable));
|
|
115
160
|
}))
|
|
116
161
|
};
|
|
117
162
|
yield* Scope.addFinalizer(parent, close);
|
|
118
163
|
yield* Ref.update(handles, (all) => new Map([...all, [record.id, handle]]));
|
|
119
164
|
return handle;
|
|
120
|
-
}), opening = yield*
|
|
165
|
+
}), opening = yield* Semaphore.make(1), ownRecord = (id) => store.get(id).pipe(Effect.flatMap((record) => record.workspace !== options.workspace ? Effect.fail(failure("session.workspace", "This session belongs to a different workspace")) : Effect.succeed(record)));
|
|
121
166
|
return {
|
|
122
167
|
create: () => Ref.get(graphRef).pipe(Effect.flatMap((graph) => store.create(options.workspace, graph.config.profile ?? "smith")), Effect.flatMap((record) => opening.withPermits(1)(open(record)))),
|
|
123
168
|
resume: (id) => ownRecord(id).pipe(Effect.flatMap((record) => opening.withPermits(1)(open(record)))),
|
|
124
|
-
fork: (id, through) => ownRecord(id).pipe(Effect.
|
|
169
|
+
fork: (id, through) => ownRecord(id).pipe(Effect.andThen(store.fork(id, through)), Effect.flatMap((record) => opening.withPermits(1)(open(record)))),
|
|
125
170
|
list: store.list(options.workspace),
|
|
126
171
|
graph: Ref.get(graphRef),
|
|
127
172
|
reconfigure: (config, plugins) => Effect.gen(function* () {
|
|
@@ -129,7 +174,7 @@ export const makeHarness = (options) => Effect.gen(function* () {
|
|
|
129
174
|
if (graphFingerprint(next, "runtime") !== graphFingerprint(initial, "runtime"))
|
|
130
175
|
return "restart-required";
|
|
131
176
|
yield* Effect.forEach([...(yield* Ref.get(handles)).values()], (handle) => Effect.scoped(Effect.gen(function* () {
|
|
132
|
-
const scope = yield* Effect.scope, seed = Context.add(runtime, SessionEnvironment, { workspace: options.workspace, session: handle.record }), context = yield* activateGraph(next, "session", Context.
|
|
177
|
+
const scope = yield* Effect.scope, seed = Context.add(runtime, SessionEnvironment, { workspace: options.workspace, session: handle.record }), context = yield* activateGraph(next, "session", Context.makeUnsafe(seed.mapUnsafe), scope);
|
|
133
178
|
yield* service(context, AgentLoop);
|
|
134
179
|
})));
|
|
135
180
|
yield* Ref.set(registryRef, registry);
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,5 @@ export * from "@xandreed/core";
|
|
|
2
2
|
export * from "./harness.js";
|
|
3
3
|
export * from "./approval.adapter.js";
|
|
4
4
|
export { domainLoop, domainSession } from "./domain-session.adapter.js";
|
|
5
|
+
export { Agent } from "./agent.adapter.js";
|
|
6
|
+
export type { AgentConfig, AgentPluginEntry } from "./agent.adapter.js";
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Ref } from "effect";
|
|
3
|
+
declare const Tally_base: Context.ServiceClass<Tally, "test/Tally", {
|
|
4
|
+
readonly runId: string;
|
|
5
|
+
readonly seen: Ref.Ref<ReadonlyArray<string>>;
|
|
6
|
+
}>;
|
|
7
|
+
/** A per-turn host service for the agent tests: built by the turn's layer, read by use, tools and subscriptions. */
|
|
8
|
+
export declare class Tally extends Tally_base {
|
|
9
|
+
}
|
|
10
|
+
declare const Stamp_base: Context.ServiceClass<Stamp, "test/Stamp", {
|
|
11
|
+
readonly label: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** A service a turn-dependent plugin provides in the agent tests, read by the host's layer. */
|
|
14
|
+
export declare class Stamp extends Stamp_base {
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xandreed/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@xandreed/core": "0.
|
|
14
|
-
"effect": "
|
|
15
|
-
"@xandreed/runtime": "0.
|
|
16
|
-
"@xandreed/plugin-memory": "0.
|
|
17
|
-
"@xandreed/plugin-session-sqlite": "0.
|
|
13
|
+
"@xandreed/core": "0.11.0",
|
|
14
|
+
"effect": "4.0.0-rc.118",
|
|
15
|
+
"@xandreed/runtime": "0.11.0",
|
|
16
|
+
"@xandreed/plugin-memory": "0.11.0",
|
|
17
|
+
"@xandreed/plugin-session-sqlite": "0.11.0"
|
|
18
18
|
},
|
|
19
19
|
"private": false,
|
|
20
20
|
"types": "./dist/index.d.ts",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"LICENSE"
|
|
27
27
|
],
|
|
28
28
|
"engines": {
|
|
29
|
+
"node": ">=24",
|
|
29
30
|
"bun": ">=1.3.0"
|
|
30
31
|
},
|
|
31
32
|
"license": "MIT"
|