@xandreed/sdk 0.14.0 → 0.15.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 +66 -34
- package/dist/agent.adapter.js +48 -37
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/index.js +1 -0
- package/dist/inbox-runner.adapter.d.ts +18 -0
- package/dist/inbox-runner.adapter.js +8 -0
- package/dist/index.d.ts +35 -2
- package/dist/index.js +59 -2
- package/dist/task-executor.adapter.d.ts +32 -0
- package/dist/task-executor.adapter.js +10 -0
- package/dist/task-inbox-runner.adapter.d.ts +11 -0
- package/dist/task-inbox-runner.adapter.js +22 -0
- package/dist/turn/memory-checkpoint.adapter.d.ts +17 -0
- package/dist/turn/memory-checkpoint.adapter.js +62 -0
- package/dist/turn/model-call-recorder.adapter.d.ts +9 -0
- package/dist/turn/model-call-recorder.adapter.js +93 -0
- package/dist/turn/model-call-recorder.entity.d.ts +145 -0
- package/dist/turn/model-call-recorder.entity.functions.d.ts +5 -0
- package/dist/turn/model-call-recorder.entity.functions.js +4 -0
- package/dist/turn/model-call-recorder.entity.js +8 -0
- package/dist/turn/tool-registry.conformance.d.ts +61 -0
- package/dist/turn/tool-registry.conformance.js +547 -0
- package/dist/turn/turn-after-reply.entity.functions.d.ts +9 -0
- package/dist/turn/turn-after-reply.entity.functions.js +4 -0
- package/dist/turn/turn-lifecycle.usecase.functions.d.ts +135 -0
- package/dist/turn/turn-lifecycle.usecase.functions.js +131 -0
- package/dist/turn/turn-observation.entity.d.ts +28 -0
- package/dist/turn/turn-observation.entity.functions.d.ts +15 -0
- package/dist/turn/turn-observation.entity.functions.js +17 -0
- package/dist/turn/turn-observation.entity.js +12 -0
- package/dist/turn/turn-overflow.entity.d.ts +7 -0
- package/dist/turn/turn-overflow.entity.functions.d.ts +11 -0
- package/dist/turn/turn-overflow.entity.functions.js +12 -0
- package/dist/turn/turn-overflow.entity.js +5 -0
- package/dist/turn/turn-phase.entity.d.ts +462 -0
- package/dist/turn/turn-phase.entity.functions.d.ts +44 -0
- package/dist/turn/turn-phase.entity.functions.js +49 -0
- package/dist/turn/turn-phase.entity.js +13 -0
- package/dist/turn/turn-run.usecase.functions.d.ts +72 -0
- package/dist/turn/turn-run.usecase.functions.js +329 -0
- package/dist/turn/turn.adapter.d.ts +26 -0
- package/dist/turn/turn.adapter.js +225 -0
- package/package.json +10 -4
- package/dist/activity.test.d.ts +0 -1
- package/dist/agent.adapter.test.d.ts +0 -1
- package/dist/agent.backends.test.d.ts +0 -1
- package/dist/agent.golden.test.d.ts +0 -231
- package/dist/agent.trace.test.d.ts +0 -1
- package/dist/events-guide.test.d.ts +0 -207
- package/dist/journal-compat.test.d.ts +0 -1
- package/dist/memory.conformance.test.d.ts +0 -1
- package/dist/models-plugin.test.d.ts +0 -1
- package/dist/plugin-stack.test.d.ts +0 -1
- package/dist/resume.journey.test.d.ts +0 -1
- package/dist/testing.port.d.ts +0 -16
- package/dist/testing.port.js +0 -7
- package/dist/turn-composition.test.d.ts +0 -1
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { Tool } from "effect/ai";
|
|
2
|
+
import { Array as Arr, Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Scheduler, Schema, Stream } from "effect";
|
|
3
|
+
import {
|
|
4
|
+
ActionPolicy,
|
|
5
|
+
ConversationId,
|
|
6
|
+
ConversationMemory,
|
|
7
|
+
CurrentAgentRun,
|
|
8
|
+
CurrentAgentStep,
|
|
9
|
+
CurrentToolInvocation,
|
|
10
|
+
defineCapability,
|
|
11
|
+
defineSkill,
|
|
12
|
+
defineTool,
|
|
13
|
+
EntryId,
|
|
14
|
+
Failure,
|
|
15
|
+
harnessCodeOf,
|
|
16
|
+
HarnessError,
|
|
17
|
+
openLogSession,
|
|
18
|
+
PermissionGrants,
|
|
19
|
+
publishToolProgress,
|
|
20
|
+
RunContext,
|
|
21
|
+
RunId,
|
|
22
|
+
RunOrdinal,
|
|
23
|
+
SessionEnvironment,
|
|
24
|
+
SessionLogError,
|
|
25
|
+
storedJson,
|
|
26
|
+
ToolRegistry,
|
|
27
|
+
ToolInterceptor,
|
|
28
|
+
TurnMemory,
|
|
29
|
+
turnStartedDraft,
|
|
30
|
+
TurnToolbox,
|
|
31
|
+
UserMessage
|
|
32
|
+
} from "@xandreed/core";
|
|
33
|
+
import { check, ConformanceFailure } from "@xandreed/core/conformance";
|
|
34
|
+
import { randomUuid } from "@xandreed/core/internal";
|
|
35
|
+
import { TurnLive } from "./turn.adapter.js";
|
|
36
|
+
const fail = (check, message) => Effect.fail(new ConformanceFailure({ check, message })), expect = (check, holds, message) => holds ? Effect.void : fail(check, message), failureCode = (error) => Option.getOrElse(harnessCodeOf(error), () => error._tag), freshId = Effect.map(randomUuid, (id) => ConversationId.make(id)), RUN = RunId.make("conformance-run"), Opener = Context.Reference("efferent/conformance/ToolRegistryOpener", { defaultValue: () => "none" }), DurableStarted = Context.Reference("efferent/conformance/ToolRegistryDurableStarted", { defaultValue: () => Effect.succeed(!1) }), ExecutionProbe = Context.Reference("@xandreed/sdk/conformance/RegistryExecutionProbe", { defaultValue: () => () => Effect.void }), Ping = Tool.make("kit_ping", {
|
|
37
|
+
description: "Answer pong, at most twice per turn.",
|
|
38
|
+
parameters: Schema.Struct({ n: Schema.Int }),
|
|
39
|
+
success: Schema.String,
|
|
40
|
+
failure: Failure,
|
|
41
|
+
failureMode: "return"
|
|
42
|
+
}), Report = Tool.make("kit_report", {
|
|
43
|
+
description: "Report progress twice, then say which call this was.",
|
|
44
|
+
parameters: Schema.Struct({}),
|
|
45
|
+
success: Schema.String,
|
|
46
|
+
failure: Failure,
|
|
47
|
+
failureMode: "return"
|
|
48
|
+
}), Durability = Tool.make("kit_durability", {
|
|
49
|
+
description: "Say whether this call's start was durably flushed before the handler ran.",
|
|
50
|
+
parameters: Schema.Struct({}),
|
|
51
|
+
success: Schema.Boolean,
|
|
52
|
+
failure: Failure,
|
|
53
|
+
failureMode: "return"
|
|
54
|
+
}), Where = Tool.make("kit_where", {
|
|
55
|
+
description: "Say which workspace the call runs in.",
|
|
56
|
+
parameters: Schema.Struct({}),
|
|
57
|
+
success: Schema.String,
|
|
58
|
+
failure: Failure,
|
|
59
|
+
failureMode: "return"
|
|
60
|
+
}), Vault = Tool.make("kit_vault", {
|
|
61
|
+
description: "Read the vault, with the kit.vault permission.",
|
|
62
|
+
parameters: Schema.Struct({ key: Schema.String }),
|
|
63
|
+
success: Schema.String,
|
|
64
|
+
failure: Failure,
|
|
65
|
+
failureMode: "return"
|
|
66
|
+
}), Archive = Tool.make("kit_archive", {
|
|
67
|
+
description: "Archive a record, once its skill is loaded.",
|
|
68
|
+
parameters: Schema.Struct({ id: Schema.String }),
|
|
69
|
+
success: Schema.String,
|
|
70
|
+
failure: Failure,
|
|
71
|
+
failureMode: "return"
|
|
72
|
+
}), Flaky = Tool.make("kit_flaky", {
|
|
73
|
+
description: "Exercise handler failures.",
|
|
74
|
+
parameters: Schema.Struct({ outcome: Schema.Literals(["success", "failure", "defect"]) }),
|
|
75
|
+
success: Schema.String,
|
|
76
|
+
failure: Failure,
|
|
77
|
+
failureMode: "return"
|
|
78
|
+
}), TOOLS = [Ping.name, Report.name, Durability.name, Where.name, Vault.name, Archive.name, Flaky.name], SKILLS = ["kit.basics", "kit.vault", "kit.archiving"], capabilityOf = (ran) => {
|
|
79
|
+
const note = (tool) => Ref.update(ran, (all) => [...all, tool]);
|
|
80
|
+
return defineCapability({
|
|
81
|
+
id: "conformance.tools",
|
|
82
|
+
version: "1",
|
|
83
|
+
tools: [
|
|
84
|
+
defineTool({ tool: Flaky, handler: ({ outcome }) => note(Flaky.name).pipe(Effect.andThen(Effect.service(ExecutionProbe)), Effect.flatMap((probe) => probe(Flaky.name)), Effect.andThen(outcome === "defect" ? Effect.die(Error("broken handler")) : outcome === "failure" ? Effect.fail({ error: "ToolFailed", message: "try a different input" }) : Effect.succeed("ok"))) }),
|
|
85
|
+
defineTool({ tool: Durability, handler: () => Effect.service(DurableStarted).pipe(Effect.flatten) }),
|
|
86
|
+
defineTool({ tool: Ping, handler: ({ n }) => note(Ping.name).pipe(Effect.as(`pong ${n}`)), annotations: { maxCallsPerRun: Option.some(2) } }),
|
|
87
|
+
defineTool({
|
|
88
|
+
tool: Report,
|
|
89
|
+
handler: () => Effect.gen(function* () {
|
|
90
|
+
yield* note(Report.name);
|
|
91
|
+
const invocation = yield* Effect.service(CurrentToolInvocation), opener = yield* Effect.service(Opener);
|
|
92
|
+
yield* publishToolProgress("stdout", "working");
|
|
93
|
+
yield* publishToolProgress("status", "half way");
|
|
94
|
+
const provider = Option.flatMap(invocation, (call) => Option.fromNullishOr(call.toolCallId));
|
|
95
|
+
return `${Option.match(invocation, { onNone: () => "no invocation", onSome: (call) => `${call.invocationId}|${call.tool}|${call.step}` })}|${opener}${Option.match(provider, { onNone: () => "", onSome: (id) => `|${id}` })}`;
|
|
96
|
+
})
|
|
97
|
+
}),
|
|
98
|
+
defineTool({
|
|
99
|
+
tool: Where,
|
|
100
|
+
requires: [SessionEnvironment],
|
|
101
|
+
annotations: { readOnly: !0 },
|
|
102
|
+
handler: () => note(Where.name).pipe(Effect.andThen(Effect.service(ExecutionProbe)), Effect.flatMap((probe) => probe(Where.name)), Effect.andThen(SessionEnvironment), Effect.map((environment) => environment.workspace))
|
|
103
|
+
}),
|
|
104
|
+
defineTool({ tool: Vault, handler: ({ key }) => note(Vault.name).pipe(Effect.as(`secret ${key}`)), annotations: { permissions: ["kit.vault"] } }),
|
|
105
|
+
defineTool({ tool: Archive, handler: ({ id }) => note(Archive.name).pipe(Effect.as(`archived ${id}`)) })
|
|
106
|
+
],
|
|
107
|
+
skills: [
|
|
108
|
+
defineSkill({ id: "kit.basics", summary: "Ping, report and say where.", tools: [Ping.name, Report.name, Durability.name, Where.name, Flaky.name], always: !0 }),
|
|
109
|
+
defineSkill({ id: "kit.vault", summary: "Read the vault.", tools: [Vault.name], permissions: ["kit.vault"] }),
|
|
110
|
+
defineSkill({ id: "kit.archiving", summary: "Archive records.", tools: [Archive.name] })
|
|
111
|
+
]
|
|
112
|
+
});
|
|
113
|
+
}, policy = {
|
|
114
|
+
strategy: { id: "conformance", version: "1" },
|
|
115
|
+
render: { turnContext: "current", replies: !0, digests: !0, media: { mode: "none", maxImages: 0, maxChars: 0 } },
|
|
116
|
+
digestOnWrite: Option.none(),
|
|
117
|
+
maintain: () => Effect.succeed({ actions: [], digest: [] })
|
|
118
|
+
}, memoryWriter = Effect.gen(function* () {
|
|
119
|
+
const session = yield* freshId, userMessage = new UserMessage({ text: "use the tools" }), draft = yield* turnStartedDraft(1, {
|
|
120
|
+
runId: RUN,
|
|
121
|
+
key: RUN,
|
|
122
|
+
origin: "user",
|
|
123
|
+
userMessage,
|
|
124
|
+
command: {},
|
|
125
|
+
claimed: [],
|
|
126
|
+
entry: EntryId.make(`${RUN}:0`),
|
|
127
|
+
at: 0
|
|
128
|
+
}).pipe(Effect.orDie), started = { session, seq: 1, turn: draft.turn, kind: draft.kind, at: 0, data: draft.data }, stored = yield* Ref.make([started]), flushed = yield* Ref.make([started]), append = (drafts) => Effect.yieldNow.pipe(Effect.andThen(Effect.forEach(drafts, (next) => storedJson(next.data), { concurrency: 1 })), Effect.mapError((error) => new HarnessError({ code: "session.encode", message: error.message })), Effect.flatMap((data) => Ref.update(stored, (all) => [...all, ...Arr.zip(drafts, data).map(([next, json], index) => ({
|
|
129
|
+
session,
|
|
130
|
+
seq: all.length + index + 1,
|
|
131
|
+
turn: Option.some(1),
|
|
132
|
+
kind: next.kind,
|
|
133
|
+
at: 0,
|
|
134
|
+
data: json
|
|
135
|
+
}))]))), held = yield* Ref.make([]);
|
|
136
|
+
return { writer: {
|
|
137
|
+
admitted: { session: { id: session, owner: "conformance" }, turn: 1, runId: RUN, key: RUN, origin: "user", userMessage, command: {}, claimed: [], meta: {} },
|
|
138
|
+
started,
|
|
139
|
+
history: () => Effect.succeed([]),
|
|
140
|
+
snapshot: (kinds, after) => Ref.get(stored).pipe(Effect.map((events) => events.filter((event) => (after === void 0 || event.seq > after) && (kinds.length === 0 || kinds.includes(event.kind))))),
|
|
141
|
+
steers: Effect.succeed([]),
|
|
142
|
+
take: () => Effect.succeed(!1),
|
|
143
|
+
append,
|
|
144
|
+
write: (op) => op,
|
|
145
|
+
transact: (decide) => decide([]).pipe(Effect.flatMap((decision) => append(decision.drafts).pipe(Effect.as({ result: decision.result, events: [] })))),
|
|
146
|
+
flush: Ref.get(stored).pipe(Effect.flatMap((events) => Ref.set(flushed, events))),
|
|
147
|
+
closing: (drafts) => Ref.set(held, drafts),
|
|
148
|
+
end: () => Ref.getAndSet(held, []).pipe(Effect.flatMap(append), Effect.as({ pending: 0 })),
|
|
149
|
+
closed: Effect.never
|
|
150
|
+
}, stored: Ref.get(stored), flushed: Ref.get(flushed) };
|
|
151
|
+
}), messageOf = (result) => typeof result === "object" && result !== null && ("message" in result) && typeof result.message === "string" ? result.message : "none", errorOf = (result) => typeof result === "object" && result !== null && ("error" in result) && typeof result.error === "string" ? result.error : "none", isModelName = (name) => /^[A-Z][A-Za-z0-9]*$/.test(name), codeOf = (error) => Option.getOrElse(harnessCodeOf(error), () => "none"), LOAD_SKILL = "load_skill", toolsOf = (data) => typeof data === "object" && data !== null && ("body" in data) && typeof data.body === "object" && data.body !== null && ("tools" in data.body) && Array.isArray(data.body.tools) ? data.body.tools.filter((tool) => typeof tool === "string") : [];
|
|
152
|
+
export const toolRegistryConformance = (make) => {
|
|
153
|
+
const inTurn = (setup, body) => Effect.scoped(Effect.gen(function* () {
|
|
154
|
+
const ran = yield* Ref.make([]), registry = yield* make([capabilityOf(ran)], { maxCallsPerRun: setup.maxCallsPerRun ?? 32, ...setup.readConcurrency === void 0 ? {} : { readConcurrency: setup.readConcurrency }, ...setup.toolPolicy === void 0 ? {} : { toolPolicy: setup.toolPolicy } }), recorded = yield* memoryWriter, writer = setup.auditFailure === void 0 ? recorded.writer : {
|
|
155
|
+
...recorded.writer,
|
|
156
|
+
flush: recorded.stored.pipe(Effect.flatMap((events) => events.some((event) => event.kind === "tool.intercepted") ? Effect.fail(setup.auditFailure) : recorded.writer.flush))
|
|
157
|
+
}, services = Layer.merge(Layer.succeed(ConversationMemory, ConversationMemory.of({ strategy: policy.strategy, open: ({ runId, log }) => openLogSession(log, policy, { runId }) })), Layer.succeed(ToolRegistry, registry));
|
|
158
|
+
return yield* Effect.gen(function* () {
|
|
159
|
+
const run = yield* RunContext, published = yield* Ref.make([]);
|
|
160
|
+
yield* run.events.subscribe((event) => event._tag.startsWith("tool.") || event._tag === "skills.activated" ? Option.some(event) : Option.none(), (event) => Ref.update(published, (all) => [...all, event]));
|
|
161
|
+
const memory = yield* TurnMemory;
|
|
162
|
+
yield* memory.persistMessage;
|
|
163
|
+
if (setup.vaultActive === !0)
|
|
164
|
+
yield* memory.session.record([{ _tag: "ToolsActivated", skills: ["kit.vault"], tools: [Vault.name], source: "host", decision: Option.none() }], 0);
|
|
165
|
+
const tools = yield* (yield* TurnToolbox).open.pipe(Effect.provideService(DurableStarted, recorded.flushed.pipe(Effect.map((events) => events.some((event) => event.kind === "tool.started" && event.data.tool === Durability.name)))), Effect.provideService(Opener, "the opener's"), Effect.provideService(ExecutionProbe, setup.execution ?? (() => Effect.void)), (open) => setup.withoutEnvironment === !0 ? open.pipe(Effect.updateContext((context) => Context.omit(SessionEnvironment)(context))) : open.pipe(Effect.provideService(SessionEnvironment, { workspace: "the opener's" })), (open) => Option.match(Option.fromNullishOr(setup.policy), { onNone: () => open, onSome: (denying) => open.pipe(Effect.provideService(ActionPolicy, denying)) }), (open) => Option.match(Option.fromNullishOr(setup.interceptor), { onNone: () => open, onSome: (interceptor) => open.pipe(Effect.provideService(ToolInterceptor, interceptor)) }), (open) => Option.match(Option.fromNullishOr(setup.grants), {
|
|
166
|
+
onNone: () => open,
|
|
167
|
+
onSome: (grants) => open.pipe(Effect.provideService(PermissionGrants, PermissionGrants.of({ grants: Effect.succeed(new Set(grants)) })))
|
|
168
|
+
}));
|
|
169
|
+
yield* tools.select(run.userMessage);
|
|
170
|
+
const callWith = (selected, name, params, step = 1, toolCallId, ordinal = 0) => Effect.gen(function* () {
|
|
171
|
+
const kit = yield* selected.toolkit.pipe(Effect.provide(selected.handlers)), last = (yield* Stream.runCollect(yield* kit.handle(name, params, toolCallId))).at(-1);
|
|
172
|
+
return { isFailure: last?.isFailure ?? !0, result: last?.result, encoded: last?.encodedResult };
|
|
173
|
+
}).pipe(Effect.provideService(CurrentAgentStep, Option.some(step)), Effect.provideService(CurrentAgentRun, Option.some({ id: RUN, turn: 1, ordinal: RunOrdinal.make(ordinal) })), Effect.provideService(Opener, "the caller's"), Effect.provideService(SessionEnvironment, { workspace: "the caller's" })), call = (name, params, step, toolCallId, ordinal) => callWith(tools, name, params, step, toolCallId, ordinal), reopened = yield* registry.open(memory.session).pipe(Effect.provideService(SessionEnvironment, { workspace: "the opener's" }), Effect.provideService(ExecutionProbe, setup.execution ?? (() => Effect.void))), reopenCall = (name, params) => reopened.select(run.userMessage).pipe(Effect.andThen(callWith(reopened, name, params)));
|
|
174
|
+
return yield* body({
|
|
175
|
+
catalog: registry.catalog,
|
|
176
|
+
tools,
|
|
177
|
+
call,
|
|
178
|
+
reopenCall,
|
|
179
|
+
published: run.flush.pipe(Effect.orDie, Effect.andThen(Ref.get(published))),
|
|
180
|
+
stored: run.flush.pipe(Effect.orDie, Effect.andThen(recorded.stored)),
|
|
181
|
+
durable: recorded.flushed,
|
|
182
|
+
ran: Ref.get(ran)
|
|
183
|
+
});
|
|
184
|
+
}).pipe(Effect.provide(TurnLive({ turn: writer })), Effect.provide(services));
|
|
185
|
+
})), tags = (events) => events.map((event) => event._tag).join(), idOf = (event) => ("invocationId" in event) ? event.invocationId : "", allow = Effect.succeed({ _tag: "Allow" }), keep = Effect.succeed({ _tag: "Keep" }), tick = Effect.forEach(Array.from({ length: 50 }), () => Effect.yieldNow, { concurrency: 1, discard: !0 }), interceptCheck = (name, id, body) => check(name, id, () => body().pipe(Effect.catchDefect(() => fail(id, "the interception path failed before fulfilling its contract")), Effect.timeout("3 seconds"))), originalFailure = (exit, original) => Exit.isFailure(exit) && exit.cause.reasons.some((reason) => Cause.isFailReason(reason) ? reason.error === original : Cause.isDieReason(reason) && reason.defect === original), failureHasCode = (exit, code) => Exit.isFailure(exit) && exit.cause.reasons.some((reason) => codeOf(Cause.isFailReason(reason) ? reason.error : Cause.isDieReason(reason) ? reason.defect : void 0) === code), refusal = (reason, id, calls, setup = {}) => check(`a call refused as ${reason} takes its invocation id first, publishes one tool.refused and no tool.started on the run's bus only, and never runs the handler`, id, () => inTurn(setup, (turn) => Effect.gen(function* () {
|
|
186
|
+
const refusedAt = calls.length - 1, [tool, input] = calls[refusedAt], results = yield* Effect.forEach(calls, ([name, params]) => turn.call(name, params), { concurrency: 1 }), events = yield* turn.published, refused = events.flatMap((event) => event._tag === "tool.refused" ? [event] : []);
|
|
187
|
+
yield* expect(id, refused.length === 1, `the calls published ${refused.length} tool.refused`);
|
|
188
|
+
const event = refused[0];
|
|
189
|
+
yield* expect(id, event.reason === reason && event.tool === tool && event.step === 1 && JSON.stringify(event.input) === JSON.stringify(input), `the refusal was published as ${event.reason} of ${event.tool} at step ${event.step} with input ${JSON.stringify(event.input)}`);
|
|
190
|
+
yield* expect(id, event.invocationId === `${RUN}:call:${refusedAt}`, `the refused call's id is ${event.invocationId}, not ${RUN}:call:${refusedAt} (ids are minted before the checks)`);
|
|
191
|
+
const outcome = results[refusedAt];
|
|
192
|
+
yield* expect(id, outcome.isFailure && messageOf(outcome.result) === event.message && event.message.length > 0, `the refused call returned ${JSON.stringify(outcome.result)} (failure: ${outcome.isFailure}), not the refusal's message "${event.message}"`);
|
|
193
|
+
yield* expect(id, isModelName(errorOf(outcome.result)), `the refused call failed as ${errorOf(outcome.result)}, not under a PascalCase name the model reads`);
|
|
194
|
+
const others = events.filter((other) => other._tag !== "tool.refused" && idOf(other) === event.invocationId);
|
|
195
|
+
yield* expect(id, others.length === 0, `the refused call also published ${tags(others)}`);
|
|
196
|
+
const ran = yield* turn.ran;
|
|
197
|
+
yield* expect(id, ran.length === refusedAt, `the handlers ran for ${ran.join() || "no call"}; only the ${refusedAt} calls before the refused one should have`);
|
|
198
|
+
const stored = (yield* turn.stored).filter((record) => record.kind === "tool.refused");
|
|
199
|
+
yield* expect(id, stored.length === 1 && stored[0].data.invocationId === event.invocationId && !("input" in stored[0].data), `the turn stored ${stored.length} tool.refused${stored.length > 0 && "input" in stored[0].data ? ", with the call's input" : ""} (a registry publishes on the run's bus and never writes the journal itself)`);
|
|
200
|
+
})));
|
|
201
|
+
return [
|
|
202
|
+
interceptCheck("absence keeps the original handler lifecycle and encoded answer", "interceptor-absence", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
203
|
+
const outcome = yield* turn.call(Where.name, {}), events = yield* turn.published;
|
|
204
|
+
yield* expect("interceptor-absence", outcome.result === "the opener's" && outcome.encoded === "the opener's" && !events.some((event) => event._tag === "tool.intercepted") && events.filter((event) => event._tag === "tool.started" || event._tag === "tool.completed").length === 2, "absence changed the result or introduced interception facts");
|
|
205
|
+
}))),
|
|
206
|
+
interceptCheck("ActionPolicy precedes both hook phases and denied original calls skip them", "interceptor-order", () => Effect.gen(function* () {
|
|
207
|
+
const order = yield* Ref.make([]), decisions = yield* Ref.make(0);
|
|
208
|
+
yield* inTurn({
|
|
209
|
+
policy: { authorize: () => Ref.updateAndGet(decisions, (n) => n + 1).pipe(Effect.flatMap((n) => Ref.update(order, (all) => [...all, "policy"]).pipe(Effect.andThen(n === 1 ? Effect.fail(new HarnessError({ code: "conformance.denied", message: "closed" })) : Effect.void)))) },
|
|
210
|
+
interceptor: { before: () => Ref.update(order, (all) => [...all, "before"]).pipe(Effect.andThen(allow)), after: () => Ref.update(order, (all) => [...all, "after"]).pipe(Effect.andThen(keep)) },
|
|
211
|
+
execution: () => Ref.update(order, (all) => [...all, "handler"])
|
|
212
|
+
}, (turn) => Effect.gen(function* () {
|
|
213
|
+
yield* turn.call(Where.name, {});
|
|
214
|
+
yield* expect("interceptor-order", (yield* Ref.get(order)).join() === "policy", "an original policy refusal invoked a hook");
|
|
215
|
+
yield* turn.call(Where.name, {});
|
|
216
|
+
yield* expect("interceptor-order", (yield* Ref.get(order)).join() === "policy,policy,before,handler,after", "the fixed policy/before/handler/after order changed");
|
|
217
|
+
yield* expect("interceptor-order", (yield* turn.published).filter((event) => event._tag === "tool.intercepted").length === 2, "accepted Allow/Keep phases were not audited");
|
|
218
|
+
}));
|
|
219
|
+
})),
|
|
220
|
+
interceptCheck("Deny returns its refusal without consuming caps or fabricating a handler", "interceptor-deny", () => Effect.gen(function* () {
|
|
221
|
+
const calls = yield* Ref.make(0), after = yield* Ref.make(0);
|
|
222
|
+
yield* inTurn({ maxCallsPerRun: 1, interceptor: {
|
|
223
|
+
before: () => Ref.updateAndGet(calls, (n) => n + 1).pipe(Effect.map((n) => n === 1 ? { _tag: "Deny", failure: { error: "HostDenied", message: "Use another action." } } : { _tag: "Allow" })),
|
|
224
|
+
after: () => Ref.update(after, (n) => n + 1).pipe(Effect.andThen(keep))
|
|
225
|
+
} }, (turn) => Effect.gen(function* () {
|
|
226
|
+
const denied = yield* turn.call(Where.name, {});
|
|
227
|
+
yield* expect("interceptor-deny", denied.isFailure && errorOf(denied.result) === "HostDenied" && (yield* turn.ran).length === 0, "Deny ran a handler or lost its failure");
|
|
228
|
+
const allowed = yield* turn.call(Where.name, {}), limited = yield* turn.call(Where.name, {});
|
|
229
|
+
yield* expect("interceptor-deny", !allowed.isFailure && errorOf(limited.result) === "CallLimit" && (yield* Ref.get(after)) === 1 && (yield* turn.ran).length === 1, "Deny consumed a cap or invoked after");
|
|
230
|
+
yield* expect("interceptor-deny", (yield* turn.published).some((event) => event._tag === "tool.refused" && event.reason === "intercepted"), "Deny lost its interception refusal");
|
|
231
|
+
}));
|
|
232
|
+
})),
|
|
233
|
+
interceptCheck("Rewrite validates encoded input and reauthorizes decoded effective parameters", "interceptor-rewrite", () => Effect.gen(function* () {
|
|
234
|
+
const seen = yield* Ref.make([]), nOf = (input) => Option.match(Schema.decodeUnknownOption(Ping.parametersSchema)(input), { onNone: () => -1, onSome: (value) => value.n });
|
|
235
|
+
yield* inTurn({ policy: { authorize: (_tool, input) => Ref.update(seen, (all) => [...all, nOf(input)]) }, interceptor: {
|
|
236
|
+
before: () => Effect.succeed({ _tag: "Rewrite", input: { n: 7 } }),
|
|
237
|
+
after: () => keep
|
|
238
|
+
} }, (turn) => Effect.gen(function* () {
|
|
239
|
+
const result = yield* turn.call(Ping.name, { n: 1 });
|
|
240
|
+
yield* expect("interceptor-rewrite", result.result === "pong 7" && (yield* Ref.get(seen)).join() === "1,7", "Rewrite did not reauthorize the actual effective input");
|
|
241
|
+
}));
|
|
242
|
+
yield* inTurn({ policy: { authorize: (_tool, input) => nOf(input) === 99 ? Effect.fail(new HarnessError({ code: "conformance.denied", message: "effective input denied" })) : Effect.void }, interceptor: {
|
|
243
|
+
before: () => Effect.succeed({ _tag: "Rewrite", input: { n: 99 } }),
|
|
244
|
+
after: () => keep
|
|
245
|
+
} }, (turn) => Effect.gen(function* () {
|
|
246
|
+
const denied = yield* turn.call(Ping.name, { n: 2 });
|
|
247
|
+
yield* expect("interceptor-rewrite", denied.isFailure && errorOf(denied.result) === "PolicyDenied" && (yield* turn.ran).length === 0, "rewritten input bypassed effective authorization");
|
|
248
|
+
}));
|
|
249
|
+
})),
|
|
250
|
+
interceptCheck("Respond consumes admission but skips handler gates, lifecycle and after", "interceptor-respond", () => Effect.gen(function* () {
|
|
251
|
+
const after = yield* Ref.make(0);
|
|
252
|
+
yield* inTurn({ maxCallsPerRun: 1, interceptor: {
|
|
253
|
+
before: () => Effect.succeed({ _tag: "Respond", outcome: { _tag: "Success", encoded: "synthetic" } }),
|
|
254
|
+
after: () => Ref.update(after, (n) => n + 1).pipe(Effect.andThen(keep))
|
|
255
|
+
} }, (turn) => Effect.gen(function* () {
|
|
256
|
+
const first = yield* turn.call(Where.name, {}), limited = yield* turn.call(Where.name, {}), events = yield* turn.published;
|
|
257
|
+
yield* expect("interceptor-respond", first.result === "synthetic" && first.encoded === "synthetic" && errorOf(limited.result) === "CallLimit" && (yield* turn.ran).length === 0 && (yield* Ref.get(after)) === 0 && !events.some((event) => event._tag === "tool.started" || event._tag === "tool.completed"), "Respond ran a handler, fabricated lifecycle or escaped admission");
|
|
258
|
+
}));
|
|
259
|
+
})),
|
|
260
|
+
interceptCheck("Replace changes the effective answer while onTool keeps the actual handler evidence", "interceptor-after", () => inTurn({ interceptor: {
|
|
261
|
+
before: () => allow,
|
|
262
|
+
after: () => Effect.succeed({ _tag: "Replace", outcome: { _tag: "Failure", encoded: { error: "HostReplaced", message: "The replacement is final." } } })
|
|
263
|
+
} }, (turn) => Effect.gen(function* () {
|
|
264
|
+
const result = yield* turn.call(Where.name, {}), completed = (yield* turn.published).filter((event) => event._tag === "tool.completed");
|
|
265
|
+
yield* expect("interceptor-after", result.isFailure && errorOf(result.result) === "HostReplaced" && completed.length === 1 && completed[0]?.ok === !0 && completed[0].result === "the opener's" && (yield* turn.ran).length === 1, "Replace changed actual handler evidence or failed to replace the final answer");
|
|
266
|
+
}))),
|
|
267
|
+
interceptCheck("before audit is acknowledged before the handler and audit failure runs no handler", "interceptor-durable", () => Effect.gen(function* () {
|
|
268
|
+
const read = yield* Ref.make(Effect.succeed([])), observed = yield* Ref.make(!1);
|
|
269
|
+
yield* inTurn({ interceptor: { before: () => allow, after: () => keep }, execution: () => Ref.get(read).pipe(Effect.flatten, Effect.flatMap((events) => Ref.set(observed, events.some((event) => event.kind === "tool.intercepted")))) }, (turn) => Effect.gen(function* () {
|
|
270
|
+
yield* Ref.set(read, turn.durable);
|
|
271
|
+
yield* turn.call(Where.name, {});
|
|
272
|
+
yield* expect("interceptor-durable", yield* Ref.get(observed), "handler ran before its audit ACK");
|
|
273
|
+
}));
|
|
274
|
+
const original = new SessionLogError({ code: "store.io", message: "audit acknowledgment failed" });
|
|
275
|
+
yield* inTurn({ auditFailure: original, interceptor: { before: () => allow, after: () => keep } }, (turn) => Effect.gen(function* () {
|
|
276
|
+
const exit = yield* Effect.exit(turn.call(Where.name, {}));
|
|
277
|
+
yield* expect("interceptor-durable", originalFailure(exit, original) && (yield* turn.ran).length === 0, "an audit failure was relabelled or ran a handler");
|
|
278
|
+
}));
|
|
279
|
+
})),
|
|
280
|
+
interceptCheck("invalid decisions and hook defects are hard failures, original turn errors and interruption survive", "interceptor-errors", () => Effect.gen(function* () {
|
|
281
|
+
const original = new SessionLogError({ code: "store.io", message: "original hook failure" });
|
|
282
|
+
yield* inTurn({ interceptor: { before: () => Effect.fail(original), after: () => keep } }, (turn) => Effect.gen(function* () {
|
|
283
|
+
yield* expect("interceptor-errors", originalFailure(yield* Effect.exit(turn.call(Where.name, {})), original) && (yield* turn.ran).length === 0, "a typed hook failure lost its identity");
|
|
284
|
+
}));
|
|
285
|
+
yield* inTurn({ interceptor: { before: () => Effect.die(Error("foreign hook defect")), after: () => keep } }, (turn) => Effect.gen(function* () {
|
|
286
|
+
yield* expect("interceptor-errors", failureHasCode(yield* Effect.exit(turn.call(Where.name, {})), "tool.intercept") && (yield* turn.ran).length === 0, "a hook defect became a model result or ran a handler");
|
|
287
|
+
}));
|
|
288
|
+
yield* inTurn({ interceptor: { before: () => Effect.succeed({ _tag: "Rewrite", input: "invalid parameters" }), after: () => keep } }, (turn) => Effect.gen(function* () {
|
|
289
|
+
yield* expect("interceptor-errors", failureHasCode(yield* Effect.exit(turn.call(Ping.name, { n: 1 })), "tool.intercept") && (yield* turn.ran).length === 0, "invalid Rewrite was treated as a model mistake");
|
|
290
|
+
}));
|
|
291
|
+
const calls = yield* Ref.make(0), release = yield* Deferred.make();
|
|
292
|
+
yield* inTurn({ interceptor: { before: () => Ref.update(calls, (n) => n + 1).pipe(Effect.andThen(Deferred.await(release)), Effect.andThen(allow)), after: () => keep } }, (turn) => Effect.gen(function* () {
|
|
293
|
+
const work = yield* turn.call(Where.name, {}).pipe(Effect.forkChild);
|
|
294
|
+
yield* tick;
|
|
295
|
+
const stopping = yield* Fiber.interrupt(work).pipe(Effect.forkChild);
|
|
296
|
+
yield* tick;
|
|
297
|
+
const stopped = stopping.pollUnsafe() !== void 0;
|
|
298
|
+
yield* Deferred.succeed(release, void 0);
|
|
299
|
+
yield* Fiber.join(stopping);
|
|
300
|
+
const exit = yield* Fiber.await(work);
|
|
301
|
+
yield* expect("interceptor-errors", (yield* Ref.get(calls)) === 1 && stopped && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) && (yield* turn.ran).length === 0, "held hook was masked or interruption relabelled");
|
|
302
|
+
}));
|
|
303
|
+
})),
|
|
304
|
+
interceptCheck("hook evidence is encoded, immutable and bound to actual caller run and captured services", "interceptor-scope", () => Effect.gen(function* () {
|
|
305
|
+
const observations = yield* Ref.make([]), observe = (input) => Effect.gen(function* () {
|
|
306
|
+
const run = yield* CurrentAgentRun, step = yield* CurrentAgentStep, opener = yield* Opener;
|
|
307
|
+
yield* Ref.update(observations, (all) => [...all, Object.isFrozen(input.input) && JSON.stringify(input.input) === '{"n":3}' && Option.isSome(input.run) && input.run.value.ordinal === 4 && input.step === 9 && Option.isSome(run) && run.value.ordinal === 4 && Option.contains(step, 9) && opener === "the opener's"]);
|
|
308
|
+
});
|
|
309
|
+
yield* inTurn({ interceptor: { before: (input) => observe(input).pipe(Effect.andThen(allow)), after: (input) => observe(input).pipe(Effect.andThen(keep)) } }, (turn) => Effect.gen(function* () {
|
|
310
|
+
const outcome = yield* turn.call(Ping.name, { n: 3 }, 9, "interceptor-scope", 4);
|
|
311
|
+
yield* expect("interceptor-scope", outcome.result === "pong 3" && (yield* Ref.get(observations)).join() === "true,true", "callbacks saw mutable evidence or the wrong run/services");
|
|
312
|
+
}));
|
|
313
|
+
})),
|
|
314
|
+
interceptCheck("replacement preserves actual breaker failures and Respond cannot revive a tripped tool", "interceptor-policy", () => Effect.gen(function* () {
|
|
315
|
+
const calls = yield* Ref.make(0);
|
|
316
|
+
yield* inTurn({ toolPolicy: { [Flaky.name]: { breakAfter: 1, breakOn: "failures" } }, interceptor: {
|
|
317
|
+
before: () => Ref.updateAndGet(calls, (n) => n + 1).pipe(Effect.map((n) => n === 1 ? { _tag: "Allow" } : { _tag: "Respond", outcome: { _tag: "Success", encoded: "revived" } })),
|
|
318
|
+
after: () => Effect.succeed({ _tag: "Replace", outcome: { _tag: "Success", encoded: "masked failure" } })
|
|
319
|
+
} }, (turn) => Effect.gen(function* () {
|
|
320
|
+
const first = yield* turn.call(Flaky.name, { outcome: "failure" }), tripped = yield* turn.call(Flaky.name, { outcome: "success" });
|
|
321
|
+
yield* expect("interceptor-policy", first.result === "masked failure" && !first.isFailure && errorOf(tripped.result) === "ToolTripped" && (yield* turn.ran).length === 1, "replacement reset actual failure accounting or Respond escaped a tripped breaker");
|
|
322
|
+
}));
|
|
323
|
+
})),
|
|
324
|
+
check("nested synchronous toolkit calls preserve a queued writer's priority", "nested-writer-priority", () => Effect.gen(function* () {
|
|
325
|
+
const count = yield* Ref.make(0), both = yield* Deferred.make(), release = yield* Deferred.make(), held = yield* Ref.make(Option.none()), order = yield* Ref.make([]), nested = yield* Ref.make("none");
|
|
326
|
+
yield* inTurn({ readConcurrency: 2, execution: (tool) => tool === Flaky.name ? Ref.update(order, (all) => [...all, "writer"]) : Effect.gen(function* () {
|
|
327
|
+
const position = yield* Ref.updateAndGet(count, (n) => n + 1);
|
|
328
|
+
if (position <= 2) {
|
|
329
|
+
if (position === 2)
|
|
330
|
+
yield* Deferred.succeed(both, void 0);
|
|
331
|
+
return yield* Deferred.await(release);
|
|
332
|
+
}
|
|
333
|
+
yield* Ref.update(order, (all) => [...all, position === 3 ? "reader" : "nested"]);
|
|
334
|
+
if (position === 3) {
|
|
335
|
+
const outcome = yield* Option.getOrThrow(yield* Ref.get(held)).call(Where.name, {}).pipe(Effect.provideService(Scheduler.Scheduler, new Scheduler.MixedScheduler("sync")), Effect.orDie);
|
|
336
|
+
yield* Ref.set(nested, errorOf(outcome.result));
|
|
337
|
+
}
|
|
338
|
+
}) }, (turn) => Effect.gen(function* () {
|
|
339
|
+
yield* Ref.set(held, Option.some(turn));
|
|
340
|
+
const holders = yield* Effect.forEach([0, 1], () => turn.call(Where.name, {}), { concurrency: 2 }).pipe(Effect.forkChild);
|
|
341
|
+
yield* Deferred.await(both);
|
|
342
|
+
const reader = yield* turn.call(Where.name, {}).pipe(Effect.forkChild);
|
|
343
|
+
yield* Effect.forEach(Array.from({ length: 40 }), () => Effect.yieldNow, { concurrency: 1 });
|
|
344
|
+
const writer = yield* turn.call(Flaky.name, { outcome: "success" }).pipe(Effect.forkChild);
|
|
345
|
+
yield* Effect.forEach(Array.from({ length: 40 }), () => Effect.yieldNow, { concurrency: 1 });
|
|
346
|
+
yield* Deferred.succeed(release, void 0);
|
|
347
|
+
yield* Fiber.join(holders);
|
|
348
|
+
yield* Fiber.join(reader);
|
|
349
|
+
yield* Fiber.join(writer);
|
|
350
|
+
yield* expect("nested-writer-priority", (yield* Ref.get(order)).join() === "reader,writer" && (yield* Ref.get(nested)) === "ToolConcurrency", "a synchronous nested call overtook the queued writer");
|
|
351
|
+
}));
|
|
352
|
+
}).pipe(Effect.timeout("2 seconds"))),
|
|
353
|
+
check("a turn's writers exclude readers and cannot be overtaken by later readers", "read-write-lanes", () => Effect.gen(function* () {
|
|
354
|
+
const entered = yield* Deferred.make(), release = yield* Deferred.make(), order = yield* Ref.make([]);
|
|
355
|
+
yield* inTurn({ execution: (tool) => Ref.updateAndGet(order, (all) => [...all, tool]).pipe(Effect.flatMap((all) => all.length === 1 ? Deferred.succeed(entered, void 0).pipe(Effect.andThen(Deferred.await(release))) : Effect.void)) }, (turn) => Effect.gen(function* () {
|
|
356
|
+
const first = yield* turn.call(Where.name, {}).pipe(Effect.forkChild);
|
|
357
|
+
yield* Deferred.await(entered);
|
|
358
|
+
const writer = yield* turn.call(Flaky.name, { outcome: "success" }).pipe(Effect.forkChild);
|
|
359
|
+
yield* Effect.forEach(Array.from({ length: 40 }), () => Effect.yieldNow, { concurrency: 1 });
|
|
360
|
+
const last = yield* turn.call(Where.name, {}).pipe(Effect.forkChild);
|
|
361
|
+
yield* Effect.forEach(Array.from({ length: 40 }), () => Effect.yieldNow, { concurrency: 1 });
|
|
362
|
+
yield* expect("read-write-lanes", (yield* Ref.get(order)).join() === Where.name, "a writer overlapped a reader or a later reader overtook it");
|
|
363
|
+
yield* Deferred.succeed(release, void 0);
|
|
364
|
+
yield* Fiber.join(first);
|
|
365
|
+
yield* Fiber.join(writer);
|
|
366
|
+
yield* Fiber.join(last);
|
|
367
|
+
yield* expect("read-write-lanes", (yield* Ref.get(order)).join() === [Where.name, Flaky.name, Where.name].join(), "a waiting writer was starved by a later reader");
|
|
368
|
+
}));
|
|
369
|
+
}).pipe(Effect.timeout("2 seconds"))),
|
|
370
|
+
check("a run of another open inside a handler waits on that open's own lanes instead of refusing as nested", "nested-other-open", () => Effect.gen(function* () {
|
|
371
|
+
const count = yield* Ref.make(0), writing = yield* Deferred.make(), release = yield* Deferred.make(), held = yield* Ref.make(Option.none()), inner = yield* Ref.make([]);
|
|
372
|
+
yield* inTurn({ execution: () => Effect.gen(function* () {
|
|
373
|
+
const position = yield* Ref.updateAndGet(count, (n) => n + 1);
|
|
374
|
+
if (position === 2)
|
|
375
|
+
return yield* Deferred.succeed(writing, void 0).pipe(Effect.andThen(Deferred.await(release)));
|
|
376
|
+
if (position !== 1)
|
|
377
|
+
return;
|
|
378
|
+
const turn = Option.getOrThrow(yield* Ref.get(held)), writer = yield* turn.reopenCall(Flaky.name, { outcome: "success" }).pipe(Effect.forkChild);
|
|
379
|
+
yield* Deferred.await(writing);
|
|
380
|
+
const reader = yield* turn.reopenCall(Where.name, {}).pipe(Effect.forkChild);
|
|
381
|
+
yield* tick;
|
|
382
|
+
yield* Deferred.succeed(release, void 0);
|
|
383
|
+
const results = yield* Effect.forEach([writer, reader], (fiber) => Fiber.join(fiber), { concurrency: 1 });
|
|
384
|
+
yield* Ref.set(inner, results.map((result) => result.isFailure ? errorOf(result.result) : "ok"));
|
|
385
|
+
}).pipe(Effect.orDie) }, (turn) => Effect.gen(function* () {
|
|
386
|
+
yield* Ref.set(held, Option.some(turn));
|
|
387
|
+
yield* turn.call(Where.name, {});
|
|
388
|
+
const outcomes = (yield* Ref.get(inner)).join();
|
|
389
|
+
yield* expect("nested-other-open", outcomes === "ok,ok", `the other open's writer and reader returned ${outcomes || "nothing"}, not ok,ok: a call into another open's lanes is not nested`);
|
|
390
|
+
}));
|
|
391
|
+
}).pipe(Effect.timeout("2 seconds"))),
|
|
392
|
+
check("per-tool concurrency permits are shared across turn opens and are released after interrupted handlers", "policy-concurrency", () => Effect.gen(function* () {
|
|
393
|
+
const entered = yield* Deferred.make(), active = yield* Ref.make(0), peak = yield* Ref.make(0), waiting = yield* Ref.make(!1);
|
|
394
|
+
yield* inTurn({ toolPolicy: { [Where.name]: { concurrency: 1 } }, execution: () => Ref.updateAndGet(active, (n) => n + 1).pipe(Effect.tap((n) => Ref.update(peak, (value) => Math.max(value, n))), Effect.flatMap((n) => n === 1 ? Deferred.succeed(entered, void 0).pipe(Effect.andThen(Effect.never)) : Effect.void), Effect.ensuring(Ref.update(active, (n) => n - 1))) }, (turn) => Effect.gen(function* () {
|
|
395
|
+
const first = yield* turn.call(Where.name, {}).pipe(Effect.forkChild);
|
|
396
|
+
yield* Deferred.await(entered);
|
|
397
|
+
const second = yield* turn.reopenCall(Where.name, {}).pipe(Effect.ensuring(Ref.set(waiting, !0)), Effect.forkChild);
|
|
398
|
+
yield* Effect.forEach(Array.from({ length: 40 }), () => Effect.yieldNow, { concurrency: 1 });
|
|
399
|
+
yield* expect("policy-concurrency", (yield* Ref.get(peak)) === 1 && !(yield* Ref.get(waiting)), "a second open escaped the agent-wide per-tool permit");
|
|
400
|
+
const stopping = yield* Fiber.interrupt(second).pipe(Effect.forkChild);
|
|
401
|
+
yield* Effect.forEach(Array.from({ length: 40 }), () => Effect.yieldNow, { concurrency: 1 });
|
|
402
|
+
const cancelledBeforeRelease = yield* Ref.get(waiting);
|
|
403
|
+
yield* Fiber.interrupt(first);
|
|
404
|
+
yield* Fiber.join(stopping);
|
|
405
|
+
yield* expect("policy-concurrency", cancelledBeforeRelease, "a queued permit wait could not be interrupted while its holder remained active");
|
|
406
|
+
const fresh = yield* turn.reopenCall(Where.name, {}).pipe(Effect.forkChild);
|
|
407
|
+
yield* Effect.forEach(Array.from({ length: 40 }), () => Effect.yieldNow, { concurrency: 1 });
|
|
408
|
+
yield* expect("policy-concurrency", (yield* turn.ran).length === 2, "interruption leaked the first call's permit");
|
|
409
|
+
yield* Fiber.interrupt(fresh);
|
|
410
|
+
}));
|
|
411
|
+
}).pipe(Effect.timeout("2 seconds"))),
|
|
412
|
+
check("host per-tool call limits are atomic and reset by exact loop run while author limits remain per turn", "policy-run-limit", () => inTurn({ toolPolicy: { [Where.name]: { maxCallsPerRun: 1 }, [Ping.name]: { maxCallsPerRun: 100 } } }, (turn) => Effect.gen(function* () {
|
|
413
|
+
const results = yield* Effect.forEach([0, 0], (run) => turn.call(Where.name, {}, 1, void 0, run), { concurrency: 2 });
|
|
414
|
+
yield* expect("policy-run-limit", results.filter((result) => !result.isFailure).length === 1 && results.some((result) => errorOf(result.result) === "ToolCallLimit"), "simultaneous admissions did not share the run's one-call budget");
|
|
415
|
+
yield* expect("policy-run-limit", !(yield* turn.call(Where.name, {}, 1, void 0, 1)).isFailure, "a fresh run did not reset its host call budget");
|
|
416
|
+
yield* turn.call(Ping.name, { n: 1 }, 1, void 0, 0);
|
|
417
|
+
yield* turn.call(Ping.name, { n: 2 }, 1, void 0, 1);
|
|
418
|
+
yield* expect("policy-run-limit", errorOf((yield* turn.call(Ping.name, { n: 3 }, 1, void 0, 2)).result) === "ToolCallLimit", "host policy widened the author's per-turn budget");
|
|
419
|
+
}))),
|
|
420
|
+
check("the default breaker counts consecutive handler defects per run and tool, with successful calls resetting it", "policy-breaker", () => inTurn({ toolPolicy: { [Flaky.name]: { breakAfter: 2 } } }, (turn) => Effect.gen(function* () {
|
|
421
|
+
yield* turn.call(Flaky.name, { outcome: "defect" });
|
|
422
|
+
yield* turn.call(Flaky.name, { outcome: "success" });
|
|
423
|
+
yield* turn.call(Flaky.name, { outcome: "defect" });
|
|
424
|
+
yield* turn.call(Flaky.name, { outcome: "failure" });
|
|
425
|
+
yield* expect("policy-breaker", !(yield* turn.call(Flaky.name, { outcome: "success" })).isFailure, "a typed failure tripped the defects-only breaker, or success did not reset it");
|
|
426
|
+
yield* turn.call(Flaky.name, { outcome: "defect" });
|
|
427
|
+
yield* turn.call(Flaky.name, { outcome: "defect" });
|
|
428
|
+
const before = (yield* turn.ran).length, refused = yield* turn.call(Flaky.name, { outcome: "success" }, 1, "tripped-call"), events = yield* turn.published;
|
|
429
|
+
yield* expect("policy-breaker", errorOf(refused.result) === "ToolTripped" && (yield* turn.ran).length === before, "a tripped tool ran its handler");
|
|
430
|
+
yield* expect("policy-breaker", events.some((event) => event._tag === "tool.refused" && event.reason === "tripped" && event.toolCallId === "tripped-call"), "the tripped refusal lost its provider id or reason");
|
|
431
|
+
yield* expect("policy-breaker", !(yield* turn.call(Where.name, {})).isFailure && !(yield* turn.call(Flaky.name, { outcome: "success" }, 1, void 0, 1)).isFailure, "a breaker escaped its tool or loop-run scope");
|
|
432
|
+
yield* expect("policy-breaker", errorOf((yield* turn.call(Flaky.name, { outcome: "success" }, 1, void 0, 0)).result) === "ToolTripped", "a concurrent later run reset an earlier run's breaker");
|
|
433
|
+
}))),
|
|
434
|
+
check("breakOn failures counts typed handler failures as well as defects", "policy-failures", () => inTurn({ toolPolicy: { [Flaky.name]: { breakAfter: 1, breakOn: "failures" } } }, (turn) => Effect.gen(function* () {
|
|
435
|
+
yield* turn.call(Flaky.name, { outcome: "failure" });
|
|
436
|
+
yield* expect("policy-failures", errorOf((yield* turn.call(Flaky.name, { outcome: "success" })).result) === "ToolTripped", "a typed handler failure did not trip the configured breaker");
|
|
437
|
+
}))),
|
|
438
|
+
check("the catalog and a run's tools hold every tool and skill the capabilities define, with their permissions", "catalog", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
439
|
+
const recipes = turn.catalog.recipes.map((recipe) => recipe.id), listed = turn.catalog.tools.map((tool) => tool.id);
|
|
440
|
+
yield* expect("catalog", SKILLS.every((skill) => recipes.includes(skill)) && TOOLS.every((tool) => listed.includes(tool)), `the catalog lists the skills ${recipes.join()} and the tools ${listed.join()}`);
|
|
441
|
+
const vault = turn.catalog.tools.find((tool) => tool.id === Vault.name);
|
|
442
|
+
yield* expect("catalog", vault?.permissions.join() === "kit.vault", `the catalog gives ${Vault.name} the permissions [${vault?.permissions.join() ?? "of no entry"}]`);
|
|
443
|
+
const skills = turn.tools.skills.map((skill) => skill.id);
|
|
444
|
+
yield* expect("catalog", SKILLS.every((skill) => skills.includes(skill)), `the run's skills are ${skills.join()}`);
|
|
445
|
+
const toolkit = Object.keys(turn.tools.toolkit.tools);
|
|
446
|
+
yield* expect("catalog", TOOLS.every((tool) => toolkit.includes(tool)), `the run's toolkit holds ${toolkit.join()}`);
|
|
447
|
+
}))),
|
|
448
|
+
check("always-on skills are active, others only once activated; an unknown skill is refused and an ungranted one stays inactive; the active set only grows", "activation", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
449
|
+
const active = yield* turn.tools.active;
|
|
450
|
+
yield* expect("activation", active.includes(Ping.name) && active.includes(Report.name) && !active.includes(Archive.name) && !active.includes(Vault.name), `with only the always-on skill applied, the active tools are ${active.join()}`);
|
|
451
|
+
const returned = yield* turn.tools.activate(["kit.archiving"], "host"), grown = yield* turn.tools.active;
|
|
452
|
+
yield* expect("activation", returned.includes(Archive.name) && grown.includes(Archive.name) && active.every((tool) => grown.includes(tool)), `after activating kit.archiving the active tools are ${grown.join()} (activate returned ${returned.join()})`);
|
|
453
|
+
yield* turn.tools.activate(["kit.archiving"], "host");
|
|
454
|
+
const again = yield* turn.tools.active;
|
|
455
|
+
yield* expect("activation", again.join() === grown.join(), `activating kit.archiving again changed the active tools to ${again.join()}`);
|
|
456
|
+
const activated = (yield* turn.published).flatMap((event) => event._tag === "skills.activated" ? [event] : []);
|
|
457
|
+
yield* expect("activation", activated.filter((event) => event.skills.includes("kit.archiving")).length === 1 && activated.some((event) => event.tools.includes(Archive.name)), `kit.archiving's activation published ${activated.length} skills.activated`);
|
|
458
|
+
const unknown = yield* Effect.flip(turn.tools.activate(["kit.no-such-skill"], "host"));
|
|
459
|
+
yield* expect("activation", failureCode(unknown) === "skills.unknown", `activating an unknown skill failed with ${failureCode(unknown)}`);
|
|
460
|
+
yield* Effect.result(turn.tools.activate(["kit.vault"], "host"));
|
|
461
|
+
const after = yield* turn.tools.active;
|
|
462
|
+
yield* expect("activation", !after.includes(Vault.name) && grown.every((tool) => after.includes(tool)), `after asking for a skill whose permission is not granted the active tools are ${after.join()}`);
|
|
463
|
+
}))),
|
|
464
|
+
check("load_skill refuses an unknown skill and an ungranted one as the call's result, under a PascalCase name and never the host's code, and activates neither", "load-refusal", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
465
|
+
const refusals = yield* Effect.forEach([["kit.no-such-skill"], ["kit.vault"]], (skills) => turn.call(LOAD_SKILL, { skills }), { concurrency: 1 });
|
|
466
|
+
yield* expect("load-refusal", refusals.every((refusal) => refusal.isFailure && isModelName(errorOf(refusal.result)) && !["", "none"].includes(messageOf(refusal.result))), `load_skill of an unknown and an ungranted skill returned ${JSON.stringify(refusals.map((refusal) => refusal.result))}, not failures under PascalCase names with a message`);
|
|
467
|
+
const active = yield* turn.tools.active;
|
|
468
|
+
yield* expect("load-refusal", !active.includes(Vault.name), `after load_skill refused kit.vault the active tools are ${active.join()}`);
|
|
469
|
+
}))),
|
|
470
|
+
check("skills loaded by concurrent load_skill calls (tool concurrency above 1) all leave their tools active and listed, each activated and recorded once", "concurrent-load", () => inTurn({ grants: ["kit.vault"] }, (turn) => Effect.gen(function* () {
|
|
471
|
+
const toolkit = Object.keys(turn.tools.toolkit.tools);
|
|
472
|
+
yield* expect("concurrent-load", toolkit.includes(LOAD_SKILL), `the run's toolkit holds no ${LOAD_SKILL} (${toolkit.join()})`);
|
|
473
|
+
const requests = [["kit.archiving"], ["kit.vault"], ["kit.archiving"]], loads = yield* Effect.forEach(requests, (skills) => turn.call(LOAD_SKILL, { skills }), { concurrency: requests.length });
|
|
474
|
+
yield* expect("concurrent-load", loads.every((load) => !load.isFailure), `the concurrent loads returned ${JSON.stringify(loads.map((load) => load.result))}`);
|
|
475
|
+
const active = yield* turn.tools.active;
|
|
476
|
+
yield* expect("concurrent-load", active.includes(Archive.name) && active.includes(Vault.name), `after loading kit.archiving and kit.vault concurrently the active tools are ${active.join()}`);
|
|
477
|
+
const used = yield* Effect.all([turn.call(Archive.name, { id: "r1" }), turn.call(Vault.name, { key: "k" })], { concurrency: 1 });
|
|
478
|
+
yield* expect("concurrent-load", used.every((call) => !call.isFailure), `the loaded tools returned ${JSON.stringify(used.map((call) => call.result))}`);
|
|
479
|
+
const loaded = [Archive.name, Vault.name], once = (tools) => loaded.every((tool) => tools.filter((other) => other === tool).length === 1), published = (yield* turn.published).flatMap((event) => event._tag === "skills.activated" ? event.tools : []);
|
|
480
|
+
yield* expect("concurrent-load", once(published), `the loads published skills.activated for ${published.join()}, not each loaded tool once`);
|
|
481
|
+
const recorded = (yield* turn.stored).flatMap((event) => event.kind === "memory.skills" ? toolsOf(event.data) : []);
|
|
482
|
+
yield* expect("concurrent-load", once(recorded), `memory recorded the activations of ${recorded.join()}, not each loaded tool once`);
|
|
483
|
+
}))),
|
|
484
|
+
check("a handled call publishes tool.started, its progress and tool.completed under one invocation id, minted after a refused call's, which its handler sees, on the run's bus only", "invocation", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
485
|
+
yield* turn.call(Archive.name, { id: "r0" });
|
|
486
|
+
const outcome = yield* turn.call(Report.name, {}, 2), id = `${RUN}:call:1`;
|
|
487
|
+
yield* expect("invocation", !outcome.isFailure && outcome.result === `${id}|${Report.name}|2|the opener's`, `the handler saw ${JSON.stringify(outcome.result)}, not its call ${id}|${Report.name}|2 and the services where the tools were opened`);
|
|
488
|
+
const events = (yield* turn.published).filter((event) => event._tag.startsWith("tool."));
|
|
489
|
+
yield* expect("invocation", tags(events) === "tool.refused,tool.started,tool.progress,tool.progress,tool.completed", `the two calls published ${tags(events)}`);
|
|
490
|
+
const handled = events.slice(1);
|
|
491
|
+
yield* expect("invocation", idOf(events[0]) === `${RUN}:call:0` && handled.every((event) => idOf(event) === id && ("tool" in event) && event.tool === Report.name && ("step" in event) && event.step === 2), `the calls' events carry the ids ${events.map(idOf).join()}`);
|
|
492
|
+
const completed = events.at(-1);
|
|
493
|
+
yield* expect("invocation", completed?._tag === "tool.completed" && completed.ok, "the handled call did not complete ok");
|
|
494
|
+
const stored = (yield* turn.stored).map((event) => event.kind).filter((kind) => kind.startsWith("tool."));
|
|
495
|
+
yield* expect("invocation", stored.join() === "tool.refused,tool.started,tool.completed", `the turn stored ${stored.join()} (a registry publishes on the run's bus and never writes the journal itself)`);
|
|
496
|
+
const next = yield* turn.call(Ping.name, { n: 1 }), started = (yield* turn.published).filter((event) => event._tag === "tool.started").map(idOf);
|
|
497
|
+
yield* expect("invocation", !next.isFailure && started.join() === `${id},${RUN}:call:2`, `the next call took the id ${started.at(-1) ?? "none"}`);
|
|
498
|
+
}))),
|
|
499
|
+
check("the provider's call id reaches concurrent handlers, their lifecycle events and the journal, including refusals; a direct call has none", "provider-call-id", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
500
|
+
const ids = ["provider:a", "provider:b"], outcomes = yield* Effect.forEach(ids, (id) => turn.call(Report.name, {}, 3, id), { concurrency: 2 });
|
|
501
|
+
yield* expect("provider-call-id", outcomes.every((outcome, at) => !outcome.isFailure && typeof outcome.result === "string" && outcome.result.endsWith(`|${ids[at]}`)), "a concurrent handler did not see its own provider call id");
|
|
502
|
+
yield* turn.call(Vault.name, { key: "k" }, 3, "provider:refused");
|
|
503
|
+
const events = (yield* turn.published).filter((event) => event._tag.startsWith("tool."));
|
|
504
|
+
yield* expect("provider-call-id", ids.every((id) => {
|
|
505
|
+
const own = events.filter((event) => ("toolCallId" in event) && event.toolCallId === id);
|
|
506
|
+
return tags(own) === "tool.started,tool.progress,tool.progress,tool.completed" && own.every((event) => idOf(event) === idOf(own[0]));
|
|
507
|
+
}), "a handled call's lifecycle events lost or mixed provider call ids");
|
|
508
|
+
const refused = events.at(-1);
|
|
509
|
+
yield* expect("provider-call-id", refused?._tag === "tool.refused" && refused.toolCallId === "provider:refused", "a refusal lost the provider call id");
|
|
510
|
+
const stored = (yield* turn.stored).filter((record) => record.kind.startsWith("tool."));
|
|
511
|
+
yield* expect("provider-call-id", ids.every((id) => stored.filter((record) => record.data.toolCallId === id).map((record) => record.kind).join() === "tool.started,tool.completed") && stored.at(-1)?.data.toolCallId === "provider:refused", "the journal lost the calls' provider ids");
|
|
512
|
+
const before = events.length;
|
|
513
|
+
yield* turn.call(Report.name, {});
|
|
514
|
+
const direct = (yield* turn.published).filter((event) => event._tag.startsWith("tool.")).slice(before);
|
|
515
|
+
yield* expect("provider-call-id", direct.length === 4 && direct.every((event) => !("toolCallId" in event)), "a direct call inherited another call's provider id");
|
|
516
|
+
}))),
|
|
517
|
+
check("a handler starts only after its tool.started record was flushed", "started-durable", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
518
|
+
const outcome = yield* turn.call(Durability.name, {});
|
|
519
|
+
yield* expect("started-durable", !outcome.isFailure && outcome.result === !0, "the handler ran before its tool.started record was flushed");
|
|
520
|
+
}))),
|
|
521
|
+
check("a run lacking a service a tool declares fails to open its tools, with requires.missing naming the tool and the service", "requires-missing", () => Effect.gen(function* () {
|
|
522
|
+
const failed = yield* inTurn({ withoutEnvironment: !0 }, () => Effect.void).pipe(Effect.match({ onFailure: Option.some, onSuccess: () => Option.none() }));
|
|
523
|
+
yield* Option.match(failed, {
|
|
524
|
+
onNone: () => fail("requires-missing", `the tools opened in a run without ${SessionEnvironment.key}, which ${Where.name} declares`),
|
|
525
|
+
onSome: (error) => expect("requires-missing", codeOf(error) === "requires.missing" && messageOf(error).includes(Where.name) && messageOf(error).includes(SessionEnvironment.key), `opening the tools without ${SessionEnvironment.key} failed with ${codeOf(error)} ("${messageOf(error)}"), not requires.missing naming ${Where.name} and the service`)
|
|
526
|
+
});
|
|
527
|
+
})),
|
|
528
|
+
check("a handler sees the services it declares as they are where its run's tools were opened, not where it is called", "requires-opened", () => inTurn({}, (turn) => Effect.gen(function* () {
|
|
529
|
+
const outcome = yield* turn.call(Where.name, {});
|
|
530
|
+
yield* expect("requires-opened", !outcome.isFailure && outcome.result === "the opener's", `${Where.name} returned ${JSON.stringify(outcome.result)} (failure: ${outcome.isFailure}), not the workspace of where the tools were opened`);
|
|
531
|
+
}))),
|
|
532
|
+
check("the turn's PermissionGrants, read where the tools are opened, let a skill that needs them be activated and its tools run", "grants", () => inTurn({ grants: ["kit.vault"] }, (turn) => Effect.gen(function* () {
|
|
533
|
+
yield* turn.tools.activate(["kit.vault"], "host");
|
|
534
|
+
const active = yield* turn.tools.active;
|
|
535
|
+
yield* expect("grants", active.includes(Vault.name), `with kit.vault granted, activating its skill left the active tools ${active.join()}`);
|
|
536
|
+
const outcome = yield* turn.call(Vault.name, { key: "k" });
|
|
537
|
+
yield* expect("grants", !outcome.isFailure && outcome.result === "secret k", `the granted call returned ${JSON.stringify(outcome.result)}`);
|
|
538
|
+
}))),
|
|
539
|
+
refusal("inactive", "refused-inactive", [[Archive.name, { id: "r1" }]]),
|
|
540
|
+
refusal("forbidden", "refused-forbidden", [[Vault.name, { key: "k" }]], { vaultActive: !0 }),
|
|
541
|
+
refusal("denied", "refused-denied", [[Ping.name, { n: 1 }]], {
|
|
542
|
+
policy: ActionPolicy.of({ authorize: (tool) => tool === Ping.name ? Effect.fail(new HarnessError({ code: "conformance.denied", message: "pings are closed today" })) : Effect.void })
|
|
543
|
+
}),
|
|
544
|
+
refusal("call-limit", "refused-call-limit", [[Ping.name, { n: 1 }], [Ping.name, { n: 2 }]], { maxCallsPerRun: 1 }),
|
|
545
|
+
refusal("tool-call-limit", "refused-tool-call-limit", [[Ping.name, { n: 1 }], [Ping.name, { n: 2 }], [Ping.name, { n: 3 }]])
|
|
546
|
+
];
|
|
547
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Cause } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* Whether what stopped the work after a reply is the writer's (a store
|
|
4
|
+
* failure, a fence, a close), failed or carried as a defect: that stays hard.
|
|
5
|
+
* Anything else, an ordinary failure, a defect or an interruption, is logged
|
|
6
|
+
* and skipped: the reply is sealed, and the next run's start maintains what
|
|
7
|
+
* the work left.
|
|
8
|
+
*/
|
|
9
|
+
export declare const isWriterCause: (cause: Cause.Cause<unknown>) => boolean;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Cause } from "effect";
|
|
2
|
+
import { isHarnessError, isTurnError } from "@xandreed/core";
|
|
3
|
+
const WRITER_TAGS = new Set(["TurnClosed", "SessionLogError", "SessionContended", "ReservedKind"]), isWriterFailure = (error) => isTurnError(error) && (isHarnessError(error) ? error.code.startsWith("session.") : WRITER_TAGS.has(error._tag));
|
|
4
|
+
export const isWriterCause = (cause) => cause.reasons.some((reason) => Cause.isFailReason(reason) ? isWriterFailure(reason.error) : Cause.isDieReason(reason) && isWriterFailure(reason.defect));
|