@xandreed/core 0.13.0 → 0.14.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/conformance.functions.d.ts +34 -0
- package/dist/conformance.functions.js +25 -0
- package/dist/domain/bash.entity.d.ts +82 -0
- package/dist/domain/bash.entity.functions.d.ts +17 -0
- package/dist/domain/bash.entity.functions.js +26 -0
- package/dist/domain/bash.entity.functions.test.d.ts +1 -0
- package/dist/domain/bash.entity.js +30 -0
- package/dist/domain/file-system.entity.d.ts +91 -0
- package/dist/domain/file-system.entity.functions.d.ts +18 -0
- package/dist/domain/file-system.entity.functions.js +30 -0
- package/dist/domain/file-system.entity.js +37 -0
- package/dist/domain/loop-event.entity.d.ts +9 -0
- package/dist/domain/media.entity.d.ts +79 -0
- package/dist/domain/media.entity.functions.d.ts +76 -0
- package/dist/domain/media.entity.functions.js +110 -0
- package/dist/domain/media.entity.js +38 -0
- package/dist/domain/media.entity.test.d.ts +1 -0
- package/dist/domain/message.entity.d.ts +157 -5
- package/dist/domain/message.entity.js +8 -4
- package/dist/domain/model-catalog.entity.d.ts +2 -0
- package/dist/domain/model-catalog.entity.js +2 -1
- package/dist/domain/token-usage.entity.d.ts +45 -1
- package/dist/domain/token-usage.entity.functions.d.ts +30 -1
- package/dist/domain/token-usage.entity.functions.js +24 -4
- package/dist/domain/token-usage.entity.js +19 -2
- package/dist/domain/token-usage.entity.test.d.ts +1 -0
- package/dist/harness/capability.entity.functions.js +2 -1
- package/dist/harness/plugin.adapter.d.ts +3 -0
- package/dist/harness/plugin.adapter.js +7 -1
- package/dist/harness/skill-files.entity.d.ts +101 -0
- package/dist/harness/skill-files.entity.functions.d.ts +58 -0
- package/dist/harness/skill-files.entity.functions.js +150 -0
- package/dist/harness/skill-files.entity.functions.test.d.ts +1 -0
- package/dist/harness/skill-files.entity.js +25 -0
- package/dist/index.d.ts +48 -8
- package/dist/index.js +67 -6
- package/dist/loop/mapping.d.ts +12 -6
- package/dist/loop/mapping.js +30 -6
- package/dist/loop/stepContext.d.ts +8 -0
- package/dist/loop/stepContext.js +1 -1
- package/dist/loop/traceContent.d.ts +29 -0
- package/dist/loop/traceContent.js +4 -0
- package/dist/loop/traceContent.test.d.ts +1 -0
- package/dist/memory/memory-log.entity.d.ts +152 -14
- package/dist/memory/memory-log.entity.functions.d.ts +35 -5
- package/dist/memory/memory-log.entity.functions.js +59 -20
- package/dist/memory/memory-log.entity.js +19 -6
- package/dist/memory/memory-session.js +10 -6
- package/dist/memory/memory.conformance.d.ts +8 -1
- package/dist/memory/memory.conformance.js +32 -0
- package/dist/models/auth-store.conformance.d.ts +13 -0
- package/dist/models/auth-store.conformance.js +73 -0
- package/dist/models/auth-store.conformance.test.d.ts +1 -0
- package/dist/models/model-catalog.conformance.d.ts +15 -0
- package/dist/models/model-catalog.conformance.js +27 -0
- package/dist/models/model-catalog.conformance.test.d.ts +1 -0
- package/dist/models/settings-store.conformance.d.ts +26 -0
- package/dist/models/settings-store.conformance.js +80 -0
- package/dist/models/settings-store.conformance.test.d.ts +1 -0
- package/dist/ports/activity.port.d.ts +35 -0
- package/dist/ports/activity.port.js +7 -0
- package/dist/ports/auth-store.port.d.ts +7 -5
- package/dist/ports/bash.port.d.ts +22 -0
- package/dist/ports/bash.port.js +4 -0
- package/dist/ports/capability.port.d.ts +11 -1
- package/dist/ports/file-system.port.d.ts +44 -15
- package/dist/ports/file-system.port.js +1 -7
- package/dist/ports/memory.port.d.ts +13 -1
- package/dist/ports/model-catalog.port.d.ts +4 -1
- package/dist/ports/run-context.port.d.ts +6 -1
- package/dist/ports/session-log.port.d.ts +4 -1
- package/dist/ports/sessions.port.d.ts +35 -6
- package/dist/ports/settings-store.port.d.ts +11 -7
- package/dist/ports/step-loop.port.d.ts +30 -2
- package/dist/ports/tasks.port.d.ts +5 -1
- package/dist/ports/turn-admission.port.d.ts +8 -2
- package/dist/ports/turn-events.port.d.ts +8 -1
- package/dist/ports/turn-scope.port.d.ts +19 -1
- package/dist/ports/turn.port.d.ts +70 -5
- package/dist/ports/utility-llm.port.d.ts +2 -0
- package/dist/session/activity.entity.d.ts +42 -0
- package/dist/session/activity.entity.functions.d.ts +21 -0
- package/dist/session/activity.entity.functions.js +23 -0
- package/dist/session/activity.entity.functions.test.d.ts +1 -0
- package/dist/session/activity.entity.js +15 -0
- package/dist/session/chassis.js +2 -1
- package/dist/session/session-event.entity.d.ts +68 -6
- package/dist/session/session-event.entity.functions.d.ts +34 -2
- package/dist/session/session-event.entity.functions.js +54 -16
- package/dist/session/session-event.entity.js +19 -2
- package/dist/session/session-log.conformance.d.ts +4 -3
- package/dist/session/session-log.conformance.js +16 -14
- package/dist/session/session-log.recovery.conformance.d.ts +40 -0
- package/dist/session/session-log.recovery.conformance.js +143 -0
- package/dist/session/session-log.recovery.conformance.test.d.ts +1 -0
- package/dist/session/sessions.conformance.d.ts +79 -0
- package/dist/session/sessions.conformance.js +355 -0
- package/dist/session/sessions.entity.d.ts +36 -3
- package/dist/session/sessions.entity.functions.d.ts +42 -0
- package/dist/session/sessions.entity.functions.js +26 -0
- package/dist/session/sessions.entity.js +17 -4
- package/dist/session/sessions.entity.test.d.ts +1 -0
- package/dist/session/tasks.conformance.d.ts +70 -0
- package/dist/session/tasks.conformance.js +213 -0
- package/dist/session/tasks.conformance.test.d.ts +1 -0
- package/dist/turn/model-request.entity.d.ts +4 -0
- package/dist/turn/model-request.entity.js +7 -2
- package/dist/turn/step-loop.conformance.d.ts +4 -1
- package/dist/turn/step-loop.conformance.js +73 -7
- package/dist/turn/tool-registry.conformance.d.ts +38 -0
- package/dist/turn/tool-registry.conformance.js +206 -0
- package/dist/turn/turn-bus.js +4 -3
- package/dist/turn/turn-event.entity.d.ts +134 -34
- package/dist/turn/turn-event.entity.functions.d.ts +11 -0
- package/dist/turn/turn-event.entity.functions.js +9 -1
- package/dist/turn/turn-event.entity.js +76 -3
- package/dist/turn/turn-event.entity.test.d.ts +1 -0
- package/dist/turn/turn-lifecycle.d.ts +46 -8
- package/dist/turn/turn-lifecycle.js +33 -3
- package/dist/turn/turn-lifecycle.test.d.ts +1 -0
- package/dist/turn/turn-run.d.ts +15 -3
- package/dist/turn/turn-run.js +37 -9
- package/dist/turn/turn.adapter.d.ts +6 -5
- package/dist/turn/turn.adapter.js +15 -3
- package/dist/turn/turn.conformance.d.ts +11 -2
- package/dist/turn/turn.conformance.js +63 -2
- package/dist/turn/user-message.entity.d.ts +14 -4
- package/dist/turn/user-message.entity.functions.d.ts +21 -0
- package/dist/turn/user-message.entity.functions.js +24 -0
- package/dist/turn/user-message.entity.js +6 -8
- package/dist/turn/user-message.entity.test.d.ts +1 -0
- package/dist/util/glob.d.ts +30 -0
- package/dist/util/glob.js +91 -0
- package/dist/util/glob.test.d.ts +1 -0
- package/dist/workspace/bash.conformance.d.ts +37 -0
- package/dist/workspace/bash.conformance.js +120 -0
- package/dist/workspace/bash.conformance.test.d.ts +1 -0
- package/dist/workspace/file-system.conformance.d.ts +38 -0
- package/dist/workspace/file-system.conformance.js +254 -0
- package/dist/workspace/file-system.conformance.test.d.ts +1 -0
- package/dist/workspace/file-system.memory.adapter.d.ts +23 -0
- package/dist/workspace/file-system.memory.adapter.js +132 -0
- package/package.json +1 -1
- package/dist/harness/skill-files.d.ts +0 -17
- package/dist/harness/skill-files.js +0 -25
- package/dist/ports/shell.port.d.ts +0 -32
- package/dist/ports/shell.port.js +0 -16
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { elideMediaData, IMAGE_TOKEN_ESTIMATE, mediaAt, mediaLabel, mediaSlotsOf } from "../domain/media.entity.functions.js";
|
|
3
|
+
import { MEDIA_LIMITS } from "../domain/media.entity.js";
|
|
2
4
|
import { handoffToMessage } from "../loop/mapping.js";
|
|
5
|
+
import { attachmentsOf, userMessageText } from "../turn/user-message.entity.functions.js";
|
|
3
6
|
import { EntryId as EntryIdSchema } from "./memory-log.entity.js";
|
|
4
7
|
export const canonicalJson = (value) => JSON.stringify(sortKeys(value)) ?? "null";
|
|
5
8
|
const sortKeys = (value) => Array.isArray(value) ? value.map(sortKeys) : typeof value === "object" && value !== null ? Object.fromEntries(Object.keys(value).sort().flatMap((key) => {
|
|
@@ -8,11 +11,15 @@ const sortKeys = (value) => Array.isArray(value) ? value.map(sortKeys) : typeof
|
|
|
8
11
|
})) : value;
|
|
9
12
|
export const fingerprintOf = (text) => {
|
|
10
13
|
return `${Array.from(text).reduce((h, ch) => Math.imul((h ^ ch.charCodeAt(0)) >>> 0, 16777619) >>> 0, 2166136261).toString(16).padStart(8, "0")}${text.length.toString(16)}`;
|
|
11
|
-
}, estimateTokens = (text) => Math.ceil(text.length / 3), estimateMessageTokens = (messages) => messages.reduce((sum, message) => sum + estimateTokens(canonicalJson(message)), 0), entryId = (runId, index) => EntryIdSchema.make(`${runId}:${index}`);
|
|
14
|
+
}, estimateTokens = (text) => Math.ceil(text.length / 3), estimateMessageTokens = (messages) => messages.reduce((sum, message) => sum + estimateTokens(canonicalJson(elideMediaData(message))) + mediaSlotsOf(message).length * IMAGE_TOKEN_ESTIMATE, 0), entryId = (runId, index) => EntryIdSchema.make(`${runId}:${index}`);
|
|
12
15
|
const digestsOf = (entries) => new Map(entries.flatMap((entry) => entry.body._tag === "ToolDigest" ? [[String(entry.body.entry), entry.body.text]] : [])), appliedCompactions = (entries, strategy) => entries.reduce((applied, entry) => {
|
|
13
|
-
if (entry.body._tag !== "Compaction"
|
|
16
|
+
if (entry.body._tag !== "Compaction")
|
|
14
17
|
return applied;
|
|
15
18
|
const action = entry.body.action, ids = [...applied.ids, entry.id];
|
|
19
|
+
if (action._tag === "OmitMedia")
|
|
20
|
+
return { ...applied, ids, omitted: new Set([...applied.omitted, ...action.entries.map(String)]) };
|
|
21
|
+
if (entry.body.strategy !== strategy)
|
|
22
|
+
return applied;
|
|
16
23
|
if (action._tag === "CompactViews")
|
|
17
24
|
return { ...applied, ids, views: new Map([...applied.views, ...action.entries.map((id, index) => [id, action.texts[index] ?? ""])]) };
|
|
18
25
|
if (action._tag === "Spill")
|
|
@@ -32,7 +39,7 @@ ${action.ledger}` })
|
|
|
32
39
|
cutTurn: action.keepFromTurn - 1,
|
|
33
40
|
preamble: Option.some(handoffToMessage(action.summary))
|
|
34
41
|
};
|
|
35
|
-
}, { views: new Map, cutTurn: 0, preamble: Option.none(), ids: [] }), toolCallIds = (message) => message.role === "assistant" ? message.content.flatMap((part) => part.type === "tool-call" ? [part.toolCallId] : []) : [], assistantText = (message) => {
|
|
42
|
+
}, { views: new Map, cutTurn: 0, preamble: Option.none(), ids: [], omitted: new Set }), toolCallIds = (message) => message.role === "assistant" ? message.content.flatMap((part) => part.type === "tool-call" ? [part.toolCallId] : []) : [], assistantText = (message) => {
|
|
36
43
|
if (message.role !== "assistant")
|
|
37
44
|
return Option.none();
|
|
38
45
|
const text = message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
|
|
@@ -53,12 +60,47 @@ export const artifactLines = (artifacts, maxImages) => {
|
|
|
53
60
|
].join(`
|
|
54
61
|
`);
|
|
55
62
|
};
|
|
56
|
-
const userRoleMessage = (content) => ({ role: "user", content }), assistantMessage = (text) => ({ role: "assistant", content: [{ type: "text", text }] })
|
|
63
|
+
const userRoleMessage = (content) => ({ role: "user", content }), assistantMessage = (text) => ({ role: "assistant", content: [{ type: "text", text }] }), imageKey = (entry, index) => `${entry.id}#${index}`, noneChosen = { chosen: new Set, images: 0, chars: 0, full: !1 }, charsOf = (images) => images.reduce((sum, image) => sum + image.media.data.length, 0), mediaPlan = (entries, options) => {
|
|
64
|
+
const applied = appliedCompactions(entries, options.strategy), digests = options.digests ? digestsOf(entries) : new Map, kept = (entry) => entry.turn > applied.cutTurn, imagesOf = (entry) => {
|
|
65
|
+
const body = entry.body;
|
|
66
|
+
if (!kept(entry) || applied.omitted.has(entry.id))
|
|
67
|
+
return [];
|
|
68
|
+
if (body._tag === "TurnStarted")
|
|
69
|
+
return attachmentsOf(body.userMessage).map((media, index) => ({ key: imageKey(entry, index), media }));
|
|
70
|
+
if (body._tag !== "ToolResult" || body.isError || applied.views.has(entry.id) || digests.has(entry.id))
|
|
71
|
+
return [];
|
|
72
|
+
return (body.media ?? []).flatMap((slot, index) => Option.toArray(Option.map(mediaAt(body.encoded, slot), (media) => ({ key: imageKey(entry, index), media }))));
|
|
73
|
+
}, maxImages = Math.min(options.media.maxImages, MEDIA_LIMITS.requestImages), take = (choice, group) => choice.full ? choice : choice.images + group.length <= maxImages && choice.chars + charsOf(group) <= options.media.maxChars ? {
|
|
74
|
+
...choice,
|
|
75
|
+
chosen: new Set([...choice.chosen, ...group.map((image) => image.key)]),
|
|
76
|
+
images: choice.images + group.length,
|
|
77
|
+
chars: choice.chars + charsOf(group)
|
|
78
|
+
} : { ...choice, full: !0 }, current = entries.filter((entry) => entry.turn === options.currentTurn), attachments = current.flatMap((entry) => entry.body._tag === "TurnStarted" ? imagesOf(entry).map((image) => [image]) : []), results = current.flatMap((entry) => entry.body._tag === "ToolResult" ? [imagesOf(entry)] : []).filter((group) => group.length > 0).reverse(), older = options.media.mode === "none" ? [] : entries.flatMap((entry) => entry.turn < options.currentTurn ? imagesOf(entry).map((image) => ({ turn: entry.turn, image })) : []), earlier = [...new Set(older.map((item) => item.turn))].sort((left, right) => right - left).map((turn) => older.filter((item) => item.turn === turn).map((item) => item.image)), inline = [...attachments, ...results, ...earlier].reduce(take, noneChosen).chosen;
|
|
79
|
+
return { applied, digests, kept, imagesOf, inline };
|
|
80
|
+
}, userMessageContent = (entry, message, inline) => {
|
|
81
|
+
const images = attachmentsOf(message), sent = images.flatMap((media, index) => inline.has(imageKey(entry, index)) ? [media] : []);
|
|
82
|
+
if (sent.length === 0)
|
|
83
|
+
return userRoleMessage(userMessageText(message));
|
|
84
|
+
const text = [message.text, ...images.flatMap((media, index) => inline.has(imageKey(entry, index)) ? [] : [mediaLabel(media)])].filter((line) => line.length > 0).join(`
|
|
85
|
+
`);
|
|
86
|
+
return { role: "user", content: [...sent, ...text.length === 0 ? [] : [{ type: "text", text }]] };
|
|
87
|
+
}, toolResultPart = (entry, body, text, options, inline) => {
|
|
88
|
+
const images = (body.media ?? []).map((slot, index) => ({ key: imageKey(entry, index), slot, media: mediaAt(body.encoded, slot) })), sent = images.flatMap((image) => inline.has(image.key) ? Option.toArray(image.media) : []), labels = [...(body.mediaNotes ?? []).filter((line) => !text.includes(line)), ...images.flatMap((image) => inline.has(image.key) ? [] : [mediaLabel(Option.getOrElse(image.media, () => image.slot))])], shown = labels.length === 0 ? text : [text, ...labels].filter((line) => line.length > 0).join(`
|
|
89
|
+
`);
|
|
90
|
+
return {
|
|
91
|
+
type: "tool-result",
|
|
92
|
+
toolCallId: body.toolCallId,
|
|
93
|
+
toolName: body.toolName,
|
|
94
|
+
output: `${shown}${artifactLines(body.artifacts, options.media.maxImages)}`,
|
|
95
|
+
isError: body.isError,
|
|
96
|
+
...sent.length === 0 ? {} : { media: sent }
|
|
97
|
+
};
|
|
98
|
+
};
|
|
57
99
|
export const renderLog = (entries, options) => {
|
|
58
|
-
const applied
|
|
100
|
+
const { applied, digests, kept, inline } = mediaPlan(entries, options), pinned = new Set(entries.flatMap((entry) => entry.body._tag === "ToolResult" && entry.body.pinned ? [String(entry.body.toolCallId)] : [])), stepContext = options.stepContext === "none" ? [] : entries.filter((entry) => entry.body._tag === "StepContext" && entry.runId === options.currentRun).slice(-1), initial = { out: [], lastText: new Map }, body = entries.reduce((state, entry) => {
|
|
59
101
|
const b = entry.body;
|
|
60
102
|
if (b._tag === "TurnStarted")
|
|
61
|
-
return kept(entry) ? { ...state, out: [...state.out,
|
|
103
|
+
return kept(entry) ? { ...state, out: [...state.out, userMessageContent(entry, b.userMessage, inline)] } : state;
|
|
62
104
|
if (b._tag === "TurnContext")
|
|
63
105
|
return kept(entry) && (options.turnContext === "all" || entry.turn === options.currentTurn) ? { ...state, out: [...state.out, userRoleMessage(b.text)] } : state;
|
|
64
106
|
if (b._tag === "Message") {
|
|
@@ -74,13 +116,7 @@ export const renderLog = (entries, options) => {
|
|
|
74
116
|
if (b._tag === "ToolResult") {
|
|
75
117
|
if (!kept(entry) && !b.pinned)
|
|
76
118
|
return state;
|
|
77
|
-
const text = applied.views.get(entry.id) ?? digests.get(entry.id) ?? b.view, message = { role: "tool", content: [
|
|
78
|
-
type: "tool-result",
|
|
79
|
-
toolCallId: b.toolCallId,
|
|
80
|
-
toolName: b.toolName,
|
|
81
|
-
output: `${text}${artifactLines(b.artifacts, options.media.maxImages)}`,
|
|
82
|
-
isError: b.isError
|
|
83
|
-
}] };
|
|
119
|
+
const text = applied.views.get(entry.id) ?? digests.get(entry.id) ?? b.view, message = { role: "tool", content: [toolResultPart(entry, b, text, options, inline)] };
|
|
84
120
|
return { ...state, out: [...state.out, message] };
|
|
85
121
|
}
|
|
86
122
|
if (b._tag === "TurnEnded" && options.replies && kept(entry))
|
|
@@ -91,11 +127,14 @@ export const renderLog = (entries, options) => {
|
|
|
91
127
|
return state;
|
|
92
128
|
}, initial).out, tail = stepContext.flatMap((entry) => entry.body._tag === "StepContext" ? [userRoleMessage(entry.body.text)] : []);
|
|
93
129
|
return mergeToolMessages([...Option.toArray(applied.preamble), ...body, ...tail]);
|
|
130
|
+
}, entriesSendingMedia = (entries, options) => {
|
|
131
|
+
const { imagesOf, inline } = mediaPlan(entries, options);
|
|
132
|
+
return entries.flatMap((entry) => imagesOf(entry).some((image) => inline.has(image.key)) ? [entry.id] : []);
|
|
94
133
|
}, buildContext = (entries, options) => {
|
|
95
134
|
const messages = renderLog(entries, options);
|
|
96
135
|
return {
|
|
97
136
|
messages,
|
|
98
|
-
fingerprint: fingerprintOf(canonicalJson(messages)),
|
|
137
|
+
fingerprint: fingerprintOf(canonicalJson(messages.map(elideMediaData))),
|
|
99
138
|
estimatedTokens: estimateMessageTokens(messages),
|
|
100
139
|
compactions: appliedCompactions(entries, options.strategy).ids,
|
|
101
140
|
through: Option.fromNullishOr(entries.at(-1)?.id)
|
|
@@ -108,17 +147,17 @@ export const renderLog = (entries, options) => {
|
|
|
108
147
|
tools: [...active.tools, ...entry.body.tools.filter((tool) => !active.tools.includes(tool))]
|
|
109
148
|
}, { skills: [], tools: [] }), referenceTranscript = (entries) => entries.flatMap((entry) => {
|
|
110
149
|
if (entry.body._tag === "TurnStarted")
|
|
111
|
-
return [userRoleMessage(entry.body.userMessage
|
|
150
|
+
return [userRoleMessage(userMessageText(entry.body.userMessage))];
|
|
112
151
|
if (entry.body._tag === "TurnEnded")
|
|
113
152
|
return Option.match(entry.body.reply, { onNone: () => [], onSome: (text) => text.length === 0 ? [] : [{ role: "assistant", content: [{ type: "text", text }] }] });
|
|
114
153
|
return [];
|
|
115
154
|
}), rawTranscript = (entries) => mergeToolMessages(entries.flatMap((entry) => {
|
|
116
155
|
if (entry.body._tag === "TurnStarted")
|
|
117
|
-
return [userRoleMessage(entry.body.userMessage
|
|
156
|
+
return [userRoleMessage(userMessageText(entry.body.userMessage))];
|
|
118
157
|
if (entry.body._tag === "Message")
|
|
119
158
|
return [entry.body.message];
|
|
120
159
|
if (entry.body._tag === "ToolResult")
|
|
121
|
-
return [{ role: "tool", content: [{ type: "tool-result", toolCallId: entry.body.toolCallId, toolName: entry.body.toolName, output: entry.body.encoded, isError: entry.body.isError }] }];
|
|
160
|
+
return [{ role: "tool", content: [{ type: "tool-result", toolCallId: entry.body.toolCallId, toolName: entry.body.toolName, output: elideMediaData(entry.body.encoded), isError: entry.body.isError }] }];
|
|
122
161
|
return [];
|
|
123
162
|
})), emptyQuery = {
|
|
124
163
|
kinds: [],
|
|
@@ -131,7 +170,7 @@ export const renderLog = (entries, options) => {
|
|
|
131
170
|
const matches = entries.filter((entry) => (query.kinds.length === 0 || query.kinds.includes(entry.body._tag)) && Option.match(query.turns, { onNone: () => !0, onSome: (range) => entry.turn >= range.from && entry.turn <= range.to }) && Option.match(query.tool, { onNone: () => !0, onSome: (tool) => entry.body._tag === "ToolResult" && entry.body.toolName === tool }) && Option.match(query.subject, {
|
|
132
171
|
onNone: () => !0,
|
|
133
172
|
onSome: (wanted) => entry.body._tag === "ToolResult" && entry.body.subjects.some((subject) => subject.kind === wanted.kind && Option.match(wanted.id, { onNone: () => !0, onSome: (id) => subject.id === id }))
|
|
134
|
-
}) && Option.match(query.text, { onNone: () => !0, onSome: (text) => canonicalJson(entry.body).toLowerCase().includes(text.toLowerCase()) }));
|
|
173
|
+
}) && Option.match(query.text, { onNone: () => !0, onSome: (text) => canonicalJson(elideMediaData(entry.body)).toLowerCase().includes(text.toLowerCase()) }));
|
|
135
174
|
return Option.match(query.limit, { onNone: () => matches, onSome: (limit) => matches.slice(-limit) });
|
|
136
175
|
}, toolInputsOf = (entries, tail) => new Map([
|
|
137
176
|
...entries.flatMap((entry) => entry.body._tag === "Message" ? [entry.body.message] : []),
|
|
@@ -149,7 +188,7 @@ export const digestTranscript = (entries, caps) => {
|
|
|
149
188
|
const lines = entries.flatMap((entry) => {
|
|
150
189
|
const body = entry.body;
|
|
151
190
|
if (body._tag === "TurnStarted")
|
|
152
|
-
return [`User: ${body.userMessage
|
|
191
|
+
return [`User: ${userMessageText(body.userMessage)}`];
|
|
153
192
|
if (body._tag === "Message")
|
|
154
193
|
return Option.toArray(Option.map(assistantText(body.message), (text) => `Assistant: ${text}`));
|
|
155
194
|
if (body._tag === "ToolResult")
|
|
@@ -168,7 +207,7 @@ ${lines.slice(1).join(`
|
|
|
168
207
|
`).slice(-(caps.total - head.length))}`;
|
|
169
208
|
}, ledgerOf = (entries, throughTurn, perTurnChars) => {
|
|
170
209
|
const kept = entries.filter((entry) => entry.turn <= throughTurn), lines = [...new Set(kept.map((entry) => entry.turn))].sort((left, right) => left - right).flatMap((turn) => {
|
|
171
|
-
const inTurn = kept.filter((entry) => entry.turn === turn), asked = inTurn.flatMap((entry) => entry.body._tag === "TurnStarted" ? [entry.body.userMessage
|
|
210
|
+
const inTurn = kept.filter((entry) => entry.turn === turn), asked = inTurn.flatMap((entry) => entry.body._tag === "TurnStarted" ? [userMessageText(entry.body.userMessage)] : []).at(0), reply = inTurn.flatMap((entry) => entry.body._tag === "TurnEnded" ? Option.toArray(entry.body.reply) : []).at(-1);
|
|
172
211
|
return asked === void 0 ? [] : [`- Asked: ${clipText(asked, perTurnChars)}${reply === void 0 ? "" : ` \u2014 replied: ${clipText(reply, perTurnChars)}`}`];
|
|
173
212
|
}), subjects = subjectsOf(kept, []).slice(-24).map((subject) => `${subject.kind} ${subject.id}${Option.match(subject.label, { onNone: () => "", onSome: (label) => ` (${label})` })}`);
|
|
174
213
|
return [
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
1
|
+
import { Schema, SchemaTransformation } from "effect";
|
|
2
|
+
import { Media, MediaSlot } from "../domain/media.entity.js";
|
|
2
3
|
import { AgentMessage, ToolCallId } from "../domain/message.entity.js";
|
|
3
4
|
import { DecisionId } from "../decision-record.entity.js";
|
|
4
|
-
import {
|
|
5
|
+
import { UserMessage } from "../turn/user-message.entity.js";
|
|
5
6
|
export const EntryId = Schema.Trimmed.check(Schema.isNonEmpty()).pipe(Schema.brand("EntryId")), Subject = Schema.Struct({
|
|
6
7
|
kind: Schema.Trimmed.check(Schema.isNonEmpty()),
|
|
7
8
|
id: Schema.Trimmed.check(Schema.isNonEmpty()),
|
|
@@ -17,14 +18,24 @@ export const EntryId = Schema.Trimmed.check(Schema.isNonEmpty()).pipe(Schema.bra
|
|
|
17
18
|
Schema.TaggedStruct("CompactViews", { entries: Schema.Array(EntryId), texts: Schema.Array(Schema.String) }),
|
|
18
19
|
Schema.TaggedStruct("Spill", { entry: EntryId, preview: Schema.String }),
|
|
19
20
|
Schema.TaggedStruct("DropTurns", { throughTurn: Schema.Int, ledger: Schema.String }),
|
|
20
|
-
Schema.TaggedStruct("Summarize", { keepFromTurn: Schema.Int, summary: Schema.String })
|
|
21
|
-
|
|
21
|
+
Schema.TaggedStruct("Summarize", { keepFromTurn: Schema.Int, summary: Schema.String }),
|
|
22
|
+
Schema.TaggedStruct("OmitMedia", { entries: Schema.Array(EntryId), reason: Schema.String })
|
|
23
|
+
]), ActivationSource = Schema.Literals(["always", "matcher", "load_skill", "host"]);
|
|
24
|
+
const StoredTurnStarted = Schema.Struct({
|
|
25
|
+
_tag: Schema.Literal("TurnStarted"),
|
|
26
|
+
prompt: Schema.String,
|
|
27
|
+
attachments: Schema.optionalKey(Schema.Array(Schema.toEncoded(Media)))
|
|
28
|
+
});
|
|
29
|
+
export const LogBody = Schema.Union([
|
|
22
30
|
Schema.TaggedStruct("SystemPrepared", {
|
|
23
31
|
fingerprint: Schema.String,
|
|
24
32
|
text: Schema.String,
|
|
25
33
|
sections: Schema.Array(Schema.Struct({ id: Schema.String, version: Schema.String, fingerprint: Schema.String }))
|
|
26
34
|
}),
|
|
27
|
-
Schema.TaggedStruct("TurnStarted", { userMessage:
|
|
35
|
+
StoredTurnStarted.pipe(Schema.decodeTo(Schema.TaggedStruct("TurnStarted", { userMessage: UserMessage }), SchemaTransformation.transform({
|
|
36
|
+
decode: ({ prompt, attachments }) => ({ _tag: "TurnStarted", userMessage: attachments === void 0 ? { text: prompt } : { text: prompt, attachments } }),
|
|
37
|
+
encode: ({ userMessage }) => userMessage.attachments === void 0 ? { _tag: "TurnStarted", prompt: userMessage.text } : { _tag: "TurnStarted", prompt: userMessage.text, attachments: userMessage.attachments }
|
|
38
|
+
}))),
|
|
28
39
|
Schema.TaggedStruct("TurnContext", { sectionId: Schema.String, version: Schema.String, text: Schema.String }),
|
|
29
40
|
Schema.TaggedStruct("Message", { message: AgentMessage }),
|
|
30
41
|
Schema.TaggedStruct("ToolResult", {
|
|
@@ -36,7 +47,9 @@ export const EntryId = Schema.Trimmed.check(Schema.isNonEmpty()).pipe(Schema.bra
|
|
|
36
47
|
viewVersion: Schema.String,
|
|
37
48
|
subjects: Schema.Array(Subject),
|
|
38
49
|
artifacts: Schema.Array(ArtifactRef),
|
|
39
|
-
pinned: Schema.Boolean
|
|
50
|
+
pinned: Schema.Boolean,
|
|
51
|
+
media: Schema.optionalKey(Schema.Array(MediaSlot)),
|
|
52
|
+
mediaNotes: Schema.optionalKey(Schema.Array(Schema.String))
|
|
40
53
|
}),
|
|
41
54
|
Schema.TaggedStruct("ToolDigest", {
|
|
42
55
|
entry: EntryId,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Clock, Effect, Option, Ref, Semaphore } from "effect";
|
|
2
|
+
import { toolResultMedia } from "../domain/media.entity.functions.js";
|
|
2
3
|
import { ResultDigester } from "../ports/memory.port.js";
|
|
3
4
|
import {
|
|
4
5
|
buildContext,
|
|
@@ -55,19 +56,22 @@ export const openLogSession = (log, policy, scope) => Effect.gen(function* () {
|
|
|
55
56
|
})
|
|
56
57
|
}))), recordTail = (tail, views, step) => Effect.gen(function* () {
|
|
57
58
|
const inputs = toolInputsOf(yield* Ref.get(entries), tail), bodies = yield* Effect.forEach(tail, (message) => message.role !== "tool" ? Effect.succeed([{ _tag: "Message", message }]) : Effect.forEach(message.content, (part) => {
|
|
58
|
-
const params = inputs.get(String(part.toolCallId)) ?? {};
|
|
59
|
-
return views.view(part.toolName, part.output, params, part.isError ??
|
|
59
|
+
const params = inputs.get(String(part.toolCallId)) ?? {}, isError = part.isError ?? !1;
|
|
60
|
+
return views.view(part.toolName, part.output, params, isError).pipe(Effect.flatMap((view) => toolResultMedia(part.output, isError ? [] : view.media ?? []).pipe(Effect.map((media) => ({
|
|
60
61
|
_tag: "ToolResult",
|
|
61
62
|
toolCallId: part.toolCallId,
|
|
62
63
|
toolName: part.toolName,
|
|
63
|
-
isError
|
|
64
|
+
isError,
|
|
64
65
|
encoded: part.output,
|
|
65
|
-
view: view.text,
|
|
66
|
+
view: media.labels.length === 0 ? view.text : [view.text, ...media.labels].filter((line) => line.length > 0).join(`
|
|
67
|
+
`),
|
|
66
68
|
viewVersion: view.version,
|
|
67
69
|
subjects: view.subjects,
|
|
68
70
|
artifacts: view.artifacts,
|
|
69
|
-
pinned: view.pinned
|
|
70
|
-
|
|
71
|
+
pinned: view.pinned,
|
|
72
|
+
...media.slots.length === 0 ? {} : { media: media.slots },
|
|
73
|
+
...media.labels.length === 0 ? {} : { mediaNotes: media.labels }
|
|
74
|
+
})))));
|
|
71
75
|
})), recorded = yield* record(bodies.flat(), step), onWrite = policy.digestOnWrite, targets = Option.isNone(onWrite) ? [] : recorded.filter((entry) => entry.body._tag === "ToolResult" && onWrite.value({ tool: entry.body.toolName, chars: entry.body.view.length })), digests = yield* digest(targets, views, "write", step);
|
|
72
76
|
return [...recorded, ...digests];
|
|
73
77
|
}), maintain = (signal) => Effect.gen(function* () {
|
|
@@ -24,7 +24,14 @@ type Memory = Context.Service.Shape<typeof ConversationMemory>;
|
|
|
24
24
|
* The ConversationMemory contract, as checks any strategy must pass: own
|
|
25
25
|
* entry ids, a log that reads back exactly after a reopen, views and
|
|
26
26
|
* artifacts kept, a pure build, the step context only when asked,
|
|
27
|
-
* append-only maintenance
|
|
27
|
+
* append-only maintenance, a reference transcript without tool traffic, and
|
|
28
|
+
* a message's images kept through a reopen and built (never with an empty
|
|
29
|
+
* text part): the current turn's as image parts, an earlier turn's as image
|
|
30
|
+
* parts or label lines; a tool result's images stored once, kept through a
|
|
31
|
+
* reopen, and the current turn's built as the result's images. Those checks
|
|
32
|
+
* need a media budget that admits one small image, as the memory plugins'
|
|
33
|
+
* defaults do: a strategy set to send none (`maxImages: 0` or
|
|
34
|
+
* `maxChars: 0`) fails them, as it fails `turnConformance`'s.
|
|
28
35
|
* `services` are the turn services the strategy needs (a summarizer…); each
|
|
29
36
|
* session is opened with them provided.
|
|
30
37
|
*/
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Context, Effect, Option, Ref, Schema } from "effect";
|
|
2
2
|
import { ConformanceFailure } from "../conformance.entity.js";
|
|
3
|
+
import { pixelPng } from "../conformance.functions.js";
|
|
4
|
+
import { mediaFromBase64, mediaLabel, mediaSlotsOf } from "../domain/media.entity.functions.js";
|
|
3
5
|
import { ConversationId, ToolCallId } from "../domain/message.entity.js";
|
|
4
6
|
import { HarnessError } from "../harness/plugin.entity.js";
|
|
5
7
|
import { draftOfEntry, entriesOfEvents, turnStartedDraft } from "../session/session-event.entity.functions.js";
|
|
@@ -92,6 +94,36 @@ export const memoryConformance = (memory, services = Context.empty()) => {
|
|
|
92
94
|
const after = yield* session.entries;
|
|
93
95
|
yield* expect("append-only", encodeEntries(after.slice(0, before.length)) === encodeEntries(before), "maintenance rewrote earlier entries");
|
|
94
96
|
})),
|
|
97
|
+
check("a message's images survive a reopen; the current turn's are built as image parts, an earlier turn's as image parts or label lines", "attachments", (open, log) => Effect.gen(function* () {
|
|
98
|
+
const image = yield* mediaFromBase64(pixelPng, { name: "pixel.png" }).pipe(Effect.mapError((error) => new ConformanceFailure({ check: "attachments", message: error.message }))), session = yield* open(log, "run-1");
|
|
99
|
+
yield* session.record([{ _tag: "TurnStarted", userMessage: new UserMessage({ text: "what is this?", attachments: [image] }) }], 0);
|
|
100
|
+
yield* session.record([{ _tag: "TurnEnded", outcome: "completed", reply: Option.some("a pixel") }], 0);
|
|
101
|
+
const other = yield* open(log, "run-2");
|
|
102
|
+
yield* other.record([{ _tag: "TurnStarted", userMessage: new UserMessage({ text: "", attachments: [image] }) }], 0);
|
|
103
|
+
const reopened = yield* open(log, "run-2");
|
|
104
|
+
yield* expect("attachments", encodeEntries(yield* reopened.entries) === encodeEntries(yield* other.entries), "the reopened log lost or changed an image");
|
|
105
|
+
const users = (yield* reopened.build({ stepContext: "tail" })).messages.filter((message) => message.role === "user"), shown = users.map((message) => typeof message.content === "string" ? message.content.includes(mediaLabel(image)) ? "label" : "missing" : message.content.some((part) => part.type === "text" && part.text.length === 0) ? "empty text" : message.content.some((part) => part.type === "media" && part.fingerprint === image.fingerprint && part.data === image.data) ? "image" : "missing"), [earlier, current] = [shown[0] ?? "missing", shown[1] ?? "missing"];
|
|
106
|
+
yield* expect("attachments", shown.length === 2 && (earlier === "image" || earlier === "label") && current === "image", `the earlier message showed its image as ${earlier} (an image or its label line), the current turn's as ${current} (its image, with the same data)`);
|
|
107
|
+
const firstText = Option.match(Option.fromNullishOr(users[0]), {
|
|
108
|
+
onNone: () => "",
|
|
109
|
+
onSome: (message) => typeof message.content === "string" ? message.content : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join(`
|
|
110
|
+
`)
|
|
111
|
+
});
|
|
112
|
+
yield* expect("attachments", firstText.includes("what is this?"), "the first message lost its text");
|
|
113
|
+
})),
|
|
114
|
+
check("a tool result's images survive a reopen, stored once, and the current turn's reach the build as the result's images", "tool-media", (open, log) => Effect.gen(function* () {
|
|
115
|
+
const image = yield* mediaFromBase64(pixelPng, { name: "shot.png" }).pipe(Effect.mapError((error) => new ConformanceFailure({ check: "tool-media", message: error.message }))), imaging = { ...views, view: (_tool, encoded) => Effect.succeed({ text: "a screenshot", version: "1", subjects: [], artifacts: [], pinned: !1, media: mediaSlotsOf(encoded) }) }, session = yield* open(log, "run-1");
|
|
116
|
+
yield* session.record([said("how does it look?")], 0);
|
|
117
|
+
yield* session.recordTail([
|
|
118
|
+
{ role: "assistant", content: [{ type: "tool-call", toolCallId: callId, toolName: "screenshot", input: {} }] },
|
|
119
|
+
{ role: "tool", content: [{ type: "tool-result", toolCallId: callId, toolName: "screenshot", output: { shot: image }, isError: !1 }] }
|
|
120
|
+
], imaging, 0);
|
|
121
|
+
const reopened = yield* open(log, "run-1"), entries = yield* reopened.entries;
|
|
122
|
+
yield* expect("tool-media", encodeEntries(entries) === encodeEntries(yield* session.entries), "the reopened log lost or changed the result's image");
|
|
123
|
+
yield* expect("tool-media", encodeEntries(entries).split(image.data).length - 1 === 1, "the result's image is not stored exactly once");
|
|
124
|
+
const parts = (yield* reopened.build({ stepContext: "tail" })).messages.flatMap((message) => message.role === "tool" ? message.content : []);
|
|
125
|
+
yield* expect("tool-media", parts.some((part) => (part.media ?? []).some((media) => media.fingerprint === image.fingerprint && media.data === image.data)), "the current turn's tool result was built without its image");
|
|
126
|
+
})),
|
|
95
127
|
check("the reference transcript holds user messages and replies, never tool traffic", "reference", (open, log) => Effect.gen(function* () {
|
|
96
128
|
const session = yield* open(log, "run-1");
|
|
97
129
|
yield* recordTurn(session, "find alpha", "found record-alpha");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Context } from "effect";
|
|
2
|
+
import type { ConformanceCheck } from "../conformance.entity.js";
|
|
3
|
+
import type { AuthStore } from "../ports/auth-store.port.js";
|
|
4
|
+
type Store = Context.Service.Shape<typeof AuthStore>;
|
|
5
|
+
/**
|
|
6
|
+
* The AuthStore contract, as checks any store must pass: what `get`, `all`
|
|
7
|
+
* and `resolveKey` say about each kind of credential, what `set` and
|
|
8
|
+
* `remove` change, and that providers do not share state. Every check uses
|
|
9
|
+
* fresh provider ids (`conformance-<uuid>`, which no store can refresh) and
|
|
10
|
+
* removes them when it ends, so the kit can run against a shared store.
|
|
11
|
+
*/
|
|
12
|
+
export declare const authStoreConformance: (store: Store) => ReadonlyArray<ConformanceCheck>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Clock, Effect, Option, Redacted } from "effect";
|
|
2
|
+
import { check, expect, fail } from "../conformance.functions.js";
|
|
3
|
+
import { ProviderId } from "../domain/model-selection.entity.js";
|
|
4
|
+
import { canonicalJson } from "../memory/memory-log.entity.functions.js";
|
|
5
|
+
const HOUR = 3600000, freshProvider = Effect.sync(() => ProviderId.make(`conformance-${crypto.randomUUID()}`)), secret = (label) => `conformance-${label}-${crypto.randomUUID()}`, same = (a, b) => Option.isSome(a) && canonicalJson(a.value) === canonicalJson(b), keyOf = (id, resolved) => Option.match(resolved, {
|
|
6
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
7
|
+
onSome: (key) => Redacted.isRedacted(key) ? Effect.succeed(Option.some(Redacted.value(key))) : fail(id, "the resolved key is not Redacted")
|
|
8
|
+
}), withProviders = (store, count, body) => Effect.forEach(Array.from({ length: count }), () => freshProvider).pipe(Effect.flatMap((providers) => body(providers).pipe(Effect.ensuring(Effect.forEach(providers, (provider) => store.remove(provider).pipe(Effect.ignore), { discard: !0 })))));
|
|
9
|
+
export const authStoreConformance = (store) => [
|
|
10
|
+
check("an absent provider has no credential, no key and no entry", "absent", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
|
|
11
|
+
const id = provider;
|
|
12
|
+
yield* expect("absent", Option.isNone(yield* store.get(id)), "get found a credential");
|
|
13
|
+
yield* expect("absent", Option.isNone(yield* store.resolveKey(id)), "resolveKey found a key");
|
|
14
|
+
yield* expect("absent", !(yield* store.all).has(id), "all lists the provider");
|
|
15
|
+
}))),
|
|
16
|
+
check("an api_key credential reads back, is listed, and resolves to its key as Redacted", "api-key", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
|
|
17
|
+
const id = provider, credential = { type: "api_key", key: secret("key") };
|
|
18
|
+
yield* store.set(id, credential);
|
|
19
|
+
yield* expect("api-key", same(yield* store.get(id), credential), "get returned a different credential");
|
|
20
|
+
yield* expect("api-key", same(Option.fromNullishOr((yield* store.all).get(id)), credential), "all lists a different credential, or none");
|
|
21
|
+
const key = yield* keyOf("api-key", yield* store.resolveKey(id));
|
|
22
|
+
yield* expect("api-key", Option.getOrUndefined(key) === credential.key, "resolveKey did not return the stored key");
|
|
23
|
+
}))),
|
|
24
|
+
check("a local credential reads back and resolves to no key", "local", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
|
|
25
|
+
const id = provider, credential = { type: "local", baseUrl: "http://127.0.0.1:1/v1" };
|
|
26
|
+
yield* store.set(id, credential);
|
|
27
|
+
yield* expect("local", same(yield* store.get(id), credential), "get returned a different credential");
|
|
28
|
+
yield* expect("local", Option.isNone(yield* store.resolveKey(id)), "resolveKey returned a key for a local credential");
|
|
29
|
+
}))),
|
|
30
|
+
check("an OAuth token that has not expired resolves to its access token", "oauth-valid", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
|
|
31
|
+
const id = provider, now = yield* Clock.currentTimeMillis, credential = { type: "oauth", access: secret("access"), refresh: secret("refresh"), expires: now + HOUR };
|
|
32
|
+
yield* store.set(id, credential);
|
|
33
|
+
yield* expect("oauth-valid", same(yield* store.get(id), credential), "get returned a different credential");
|
|
34
|
+
const key = yield* keyOf("oauth-valid", yield* store.resolveKey(id));
|
|
35
|
+
yield* expect("oauth-valid", Option.getOrUndefined(key) === credential.access, "resolveKey did not return the access token");
|
|
36
|
+
}))),
|
|
37
|
+
check("an expired OAuth token the store cannot refresh fails with an AuthError naming the provider, never the stale token", "oauth-expired", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
|
|
38
|
+
const id = provider, now = yield* Clock.currentTimeMillis;
|
|
39
|
+
yield* store.set(id, { type: "oauth", access: secret("stale"), refresh: secret("refresh"), expires: now - HOUR });
|
|
40
|
+
const resolved = yield* Effect.result(store.resolveKey(id));
|
|
41
|
+
if (resolved._tag === "Success")
|
|
42
|
+
return yield* fail("oauth-expired", Option.isSome(resolved.success) ? "resolveKey returned the expired token" : "resolveKey found no key instead of failing");
|
|
43
|
+
yield* expect("oauth-expired", resolved.failure._tag === "AuthError" && resolved.failure.provider === id, `failed with ${resolved.failure._tag} for ${resolved.failure.provider}`);
|
|
44
|
+
}))),
|
|
45
|
+
check("a second set replaces the first", "overwrite", () => withProviders(store, 1, ([provider]) => Effect.gen(function* () {
|
|
46
|
+
const id = provider, second = { type: "api_key", key: secret("second") };
|
|
47
|
+
yield* store.set(id, { type: "api_key", key: secret("first") });
|
|
48
|
+
yield* store.set(id, second);
|
|
49
|
+
yield* expect("overwrite", same(yield* store.get(id), second), "get still returns the first credential");
|
|
50
|
+
const key = yield* keyOf("overwrite", yield* store.resolveKey(id));
|
|
51
|
+
yield* expect("overwrite", Option.getOrUndefined(key) === second.key, "resolveKey still returns the first key");
|
|
52
|
+
}))),
|
|
53
|
+
check("remove deletes a credential wherever it is read, and removing an absent provider succeeds", "remove", () => withProviders(store, 2, ([provider, unknown]) => Effect.gen(function* () {
|
|
54
|
+
const id = provider;
|
|
55
|
+
yield* store.set(id, { type: "api_key", key: secret("removed") });
|
|
56
|
+
yield* store.remove(id);
|
|
57
|
+
yield* expect("remove", Option.isNone(yield* store.get(id)), "get still finds the credential");
|
|
58
|
+
yield* expect("remove", Option.isNone(yield* store.resolveKey(id)), "resolveKey still finds a key");
|
|
59
|
+
yield* expect("remove", !(yield* store.all).has(id), "all still lists the provider");
|
|
60
|
+
yield* store.remove(id);
|
|
61
|
+
yield* store.remove(unknown);
|
|
62
|
+
}))),
|
|
63
|
+
check("providers are isolated: writing or removing one leaves another as it was", "isolation", () => withProviders(store, 2, ([first, second]) => Effect.gen(function* () {
|
|
64
|
+
const a = first, b = second, kept = { type: "api_key", key: secret("kept") };
|
|
65
|
+
yield* store.set(b, kept);
|
|
66
|
+
yield* store.set(a, { type: "api_key", key: secret("other") });
|
|
67
|
+
yield* store.remove(a);
|
|
68
|
+
yield* store.set(a, { type: "local" });
|
|
69
|
+
yield* expect("isolation", same(yield* store.get(b), kept), "another provider's credential changed");
|
|
70
|
+
const key = yield* keyOf("isolation", yield* store.resolveKey(b));
|
|
71
|
+
yield* expect("isolation", Option.getOrUndefined(key) === kept.key, "another provider's key changed");
|
|
72
|
+
})))
|
|
73
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Context } from "effect";
|
|
2
|
+
import type { ConformanceCheck } from "../conformance.entity.js";
|
|
3
|
+
import type { ModelCatalog } from "../ports/model-catalog.port.js";
|
|
4
|
+
type Catalog = Context.Service.Shape<typeof ModelCatalog>;
|
|
5
|
+
/**
|
|
6
|
+
* The ModelCatalog contract: every entry is a `ModelCatalogEntry`, its
|
|
7
|
+
* `selection` is `"<provider>:<modelId>"` of the entry's own `provider`, no
|
|
8
|
+
* selection is listed twice, two lists with nothing changed between them
|
|
9
|
+
* agree, and the selections in `includes` (the models a host expects this
|
|
10
|
+
* catalog to offer) are listed. The kit only reads.
|
|
11
|
+
*/
|
|
12
|
+
export declare const modelCatalogConformance: (catalog: Catalog, options?: {
|
|
13
|
+
readonly includes?: ReadonlyArray<string>;
|
|
14
|
+
}) => ReadonlyArray<ConformanceCheck>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { check, expect } from "../conformance.functions.js";
|
|
3
|
+
import { ModelCatalogEntry } from "../domain/model-catalog.entity.js";
|
|
4
|
+
import { parseModelSelection } from "../domain/model-selection.entity.functions.js";
|
|
5
|
+
const isEntry = Schema.is(ModelCatalogEntry);
|
|
6
|
+
export const modelCatalogConformance = (catalog, options = {}) => [
|
|
7
|
+
check("every entry is a ModelCatalogEntry", "decode", () => Effect.gen(function* () {
|
|
8
|
+
const invalid = (yield* catalog.list).filter((entry) => !isEntry(entry));
|
|
9
|
+
yield* expect("decode", invalid.length === 0, `entries that do not decode: ${JSON.stringify(invalid.slice(0, 3))}`);
|
|
10
|
+
})),
|
|
11
|
+
check(`every selection is "<provider>:<modelId>" of the entry's provider`, "selection", () => Effect.gen(function* () {
|
|
12
|
+
const wrong = (yield* catalog.list).filter((entry) => !Option.exists(parseModelSelection(entry.selection), (parsed) => parsed.provider === entry.provider));
|
|
13
|
+
yield* expect("selection", wrong.length === 0, `selections that do not name their provider: ${wrong.slice(0, 3).map((entry) => `${entry.selection} (${entry.provider})`).join(", ")}`);
|
|
14
|
+
})),
|
|
15
|
+
check("no selection is listed twice", "unique", () => Effect.gen(function* () {
|
|
16
|
+
const selections = (yield* catalog.list).map((entry) => entry.selection), repeated = selections.filter((selection, index) => selections.indexOf(selection) !== index);
|
|
17
|
+
yield* expect("unique", repeated.length === 0, `listed more than once: ${[...new Set(repeated)].slice(0, 3).join(", ")}`);
|
|
18
|
+
})),
|
|
19
|
+
check("two lists with nothing changed between them agree", "stable", () => Effect.gen(function* () {
|
|
20
|
+
const first = JSON.stringify(yield* catalog.list);
|
|
21
|
+
yield* expect("stable", JSON.stringify(yield* catalog.list) === first, "two lists differ");
|
|
22
|
+
})),
|
|
23
|
+
check("the selections the caller expects are listed", "includes", () => Effect.gen(function* () {
|
|
24
|
+
const listed = new Set((yield* catalog.list).map((entry) => entry.selection)), missing = (options.includes ?? []).filter((selection) => !listed.has(selection));
|
|
25
|
+
yield* expect("includes", missing.length === 0, `not listed: ${missing.join(", ")}`);
|
|
26
|
+
}))
|
|
27
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context } from "effect";
|
|
2
|
+
import type { ConformanceCheck } from "../conformance.entity.js";
|
|
3
|
+
import type { SettingsStore } from "../ports/settings-store.port.js";
|
|
4
|
+
type Store = Context.Service.Shape<typeof SettingsStore>;
|
|
5
|
+
/**
|
|
6
|
+
* The SettingsStore contract. Every store: `load` is stable. A writable
|
|
7
|
+
* store: each role reads back what `setRole` wrote and clearing it returns
|
|
8
|
+
* the value before the set; `set` accepts each key's canonical string form
|
|
9
|
+
* and `load` gives it typed; `None` clears a key back to the value before the
|
|
10
|
+
* set; a value that does not parse (a selection that is not
|
|
11
|
+
* `"<provider>:<modelId>"`, a count below 1, a switch that is not
|
|
12
|
+
* true/false, an unknown effort) fails with a `SettingsError` and changes
|
|
13
|
+
* nothing, through `set` and `setRole` alike. A read-only store refuses
|
|
14
|
+
* every write with a `SettingsError` and changes nothing.
|
|
15
|
+
*
|
|
16
|
+
* The kit writes the store's shared keys (settings have no per-check ids),
|
|
17
|
+
* and restores each key it touches; do not run it against settings that are
|
|
18
|
+
* in use. A key it would touch that holds a value the store must refuse (a
|
|
19
|
+
* selection that is not `"<provider>:<modelId>"`, a count below 1, left by
|
|
20
|
+
* an older, unchecked write) could not be put back, so the check that would
|
|
21
|
+
* touch it fails before writing anything.
|
|
22
|
+
*/
|
|
23
|
+
export declare const settingsStoreConformance: (store: Store, options: {
|
|
24
|
+
readonly writable: boolean;
|
|
25
|
+
}) => ReadonlyArray<ConformanceCheck>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { check, expect, fail } from "../conformance.functions.js";
|
|
3
|
+
import { parseModelSelection } from "../domain/model-selection.entity.functions.js";
|
|
4
|
+
import { canonicalJson } from "../memory/memory-log.entity.functions.js";
|
|
5
|
+
import { SETTINGS_KEYS } from "../ports/settings-store.port.js";
|
|
6
|
+
const ROLE_FIELDS = { general: "model", code: "codeModel", fast: "fastModel" }, ROLES = ["general", "code", "fast"], FIELDS = ["model", "codeModel", "fastModel", ...SETTINGS_KEYS], fieldOf = (settings, field) => settings[field], valueOf = (settings, field) => Option.map(fieldOf(settings, field), String), snapshot = (settings) => canonicalJson(Object.fromEntries(FIELDS.map((field) => [field, Option.getOrNull(valueOf(settings, field))]))), others = (settings, except) => canonicalJson(Object.fromEntries(FIELDS.filter((field) => field !== except).map((field) => [field, Option.getOrNull(valueOf(settings, field))]))), selection = (label) => `conformance:${label}-${crypto.randomUUID()}`, SELECTIONS = new Set(["model", "codeModel", "fastModel", "fallbackModel"]), COUNTS = new Set(["maxAttempts"]), restorable = (field, value) => SELECTIONS.has(field) ? Option.isSome(parseModelSelection(value)) : COUNTS.has(field) ? Number.isInteger(Number(value)) && Number(value) >= 1 : !0, restoring = (store, id, field, write) => (body) => store.load.pipe(Effect.flatMap((before) => Option.match(Option.filter(valueOf(before, field), (value) => !restorable(field, value)), {
|
|
7
|
+
onSome: (value) => fail(id, `the store holds ${field} "${value}", which a conforming store refuses to write, so the kit could not put it back; it changed nothing. Correct the value, then run the kit`),
|
|
8
|
+
onNone: () => body.pipe(Effect.ensuring(store.load.pipe(Effect.flatMap((after) => Option.getOrNull(valueOf(after, field)) === Option.getOrNull(valueOf(before, field)) ? Effect.void : write(valueOf(before, field))), Effect.ignore)))
|
|
9
|
+
}))), restoringRole = (store, id, role) => restoring(store, id, ROLE_FIELDS[role], (value) => store.setRole(role, value)), restoringKey = (store, id, key) => restoring(store, id, key, (value) => store.set(key, value)), refused = (store, id, what, write) => Effect.gen(function* () {
|
|
10
|
+
const before = snapshot(yield* store.load), result = yield* Effect.result(write);
|
|
11
|
+
if (result._tag === "Success")
|
|
12
|
+
return yield* fail(id, `${what} was accepted`);
|
|
13
|
+
const tag = typeof result.failure === "object" && result.failure !== null && "_tag" in result.failure ? String(result.failure._tag) : "an error";
|
|
14
|
+
yield* expect(id, tag === "SettingsError", `${what} failed with ${tag}, not a SettingsError`);
|
|
15
|
+
yield* expect(id, snapshot(yield* store.load) === before, `${what} changed the settings`);
|
|
16
|
+
}), CANONICAL = [
|
|
17
|
+
{ key: "maxAttempts", text: "3", reads: 3 },
|
|
18
|
+
{ key: "sandbox", text: "true", reads: !0 },
|
|
19
|
+
{ key: "reasoningEffort", text: "high", reads: "high" }
|
|
20
|
+
], CLEARED = [
|
|
21
|
+
{ key: "maxAttempts", texts: ["4", "5"] },
|
|
22
|
+
{ key: "sandbox", texts: ["false", "true"] },
|
|
23
|
+
{ key: "reasoningEffort", texts: ["low", "medium"] }
|
|
24
|
+
], BAD = [
|
|
25
|
+
{ key: "fallbackModel", text: "not-a-selection" },
|
|
26
|
+
{ key: "maxAttempts", text: "0" },
|
|
27
|
+
{ key: "maxAttempts", text: "two" },
|
|
28
|
+
{ key: "sandbox", text: "maybe" },
|
|
29
|
+
{ key: "reasoningEffort", text: "extreme" }
|
|
30
|
+
];
|
|
31
|
+
export const settingsStoreConformance = (store, options) => [
|
|
32
|
+
check("load is stable: two loads with no write between them agree", "load", () => Effect.gen(function* () {
|
|
33
|
+
const first = snapshot(yield* store.load);
|
|
34
|
+
yield* expect("load", snapshot(yield* store.load) === first, "two loads disagree");
|
|
35
|
+
})),
|
|
36
|
+
...options.writable ? writableChecks(store) : readOnlyChecks(store)
|
|
37
|
+
];
|
|
38
|
+
const writableChecks = (store) => [
|
|
39
|
+
check("each role reads back what setRole wrote, and clearing it returns the value before the set", "role", () => Effect.forEach(ROLES, (role) => Effect.gen(function* () {
|
|
40
|
+
const field = ROLE_FIELDS[role];
|
|
41
|
+
yield* store.setRole(role, Option.none());
|
|
42
|
+
const base = yield* store.load, value = selection(role);
|
|
43
|
+
yield* store.setRole(role, Option.some(value));
|
|
44
|
+
const set = yield* store.load;
|
|
45
|
+
yield* expect("role", Option.getOrUndefined(valueOf(set, field)) === value, `the ${role} role reads ${Option.getOrElse(valueOf(set, field), () => "nothing")}, not what was set`);
|
|
46
|
+
yield* expect("role", others(set, field) === others(base, field), `setting the ${role} role changed another value`);
|
|
47
|
+
yield* store.setRole(role, Option.none());
|
|
48
|
+
yield* expect("role", snapshot(yield* store.load) === snapshot(base), `clearing the ${role} role did not return the value before the set`);
|
|
49
|
+
}).pipe(restoringRole(store, "role", role)), { discard: !0 })),
|
|
50
|
+
check("set accepts each key's canonical string form and load reads it back typed", "values", () => Effect.forEach([
|
|
51
|
+
...CANONICAL,
|
|
52
|
+
{ key: "fallbackModel", text: selection("fallback"), reads: void 0 }
|
|
53
|
+
], ({ key, text, reads }) => Effect.gen(function* () {
|
|
54
|
+
const before = yield* store.load;
|
|
55
|
+
yield* store.set(key, Option.some(text));
|
|
56
|
+
const after = yield* store.load, expected = reads ?? text, read = Option.getOrNull(fieldOf(after, key));
|
|
57
|
+
yield* expect("values", read === expected, `${key} "${text}" reads back as ${JSON.stringify(read)}`);
|
|
58
|
+
yield* expect("values", others(after, key) === others(before, key), `setting ${key} changed another value`);
|
|
59
|
+
}).pipe(restoringKey(store, "values", key)), { discard: !0 })),
|
|
60
|
+
check("None clears a key back to the value before the set", "clear", () => Effect.forEach(CLEARED, ({ key, texts }) => Effect.gen(function* () {
|
|
61
|
+
yield* store.set(key, Option.none());
|
|
62
|
+
const base = yield* store.load, text = texts.find((candidate) => Option.getOrNull(valueOf(base, key)) !== candidate) ?? texts[0];
|
|
63
|
+
yield* store.set(key, Option.some(text));
|
|
64
|
+
yield* store.set(key, Option.none());
|
|
65
|
+
yield* expect("clear", snapshot(yield* store.load) === snapshot(base), `clearing ${key} did not return the value before the set`);
|
|
66
|
+
}).pipe(restoringKey(store, "clear", key)), { discard: !0 })),
|
|
67
|
+
check("set refuses a value that does not parse with a SettingsError and changes nothing", "bad-values", () => Effect.forEach(BAD, ({ key, text }) => refused(store, "bad-values", `${key} "${text}"`, store.set(key, Option.some(text))).pipe(restoringKey(store, "bad-values", key)), { discard: !0 })),
|
|
68
|
+
check('setRole refuses a selection that is not "<provider>:<modelId>" with a SettingsError and changes nothing', "bad-role", () => Effect.forEach(ROLES, (role) => Effect.forEach(["not-a-selection", ":model", "provider:"], (text) => refused(store, "bad-role", `the ${role} role "${text}"`, store.setRole(role, Option.some(text))), { discard: !0 }).pipe(restoringRole(store, "bad-role", role)), { discard: !0 }))
|
|
69
|
+
], readOnlyChecks = (store) => [
|
|
70
|
+
check("a read-only store refuses every write with a SettingsError and changes nothing", "read-only", () => Effect.gen(function* () {
|
|
71
|
+
yield* Effect.forEach(ROLES, (role) => Effect.all([
|
|
72
|
+
refused(store, "read-only", `setRole ${role}`, store.setRole(role, Option.some(selection(role)))),
|
|
73
|
+
refused(store, "read-only", `clearing the ${role} role`, store.setRole(role, Option.none()))
|
|
74
|
+
]), { discard: !0 });
|
|
75
|
+
yield* Effect.forEach(CANONICAL, ({ key, text }) => Effect.all([
|
|
76
|
+
refused(store, "read-only", `set ${key}`, store.set(key, Option.some(text))),
|
|
77
|
+
refused(store, "read-only", `clearing ${key}`, store.set(key, Option.none()))
|
|
78
|
+
]), { discard: !0 });
|
|
79
|
+
}))
|
|
80
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { TurnsInFlight } from "../session/activity.entity.js";
|
|
4
|
+
declare const TurnActivity_base: Context.ServiceClass<TurnActivity, "efferent/TurnActivity", {
|
|
5
|
+
/** The turns held open now (oldest first) and the begins in progress, read at one moment. */
|
|
6
|
+
readonly current: Effect.Effect<TurnsInFlight>;
|
|
7
|
+
/** Completes once no turn is held open and none is beginning (at once when so). */
|
|
8
|
+
readonly idle: Effect.Effect<void>;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* The turns this process has in flight, from the Sessions service that opens
|
|
12
|
+
* them (`SessionsLive` provides it). A begin counts from the moment it is
|
|
13
|
+
* asked for, through the host's admission and the opening commit, until its
|
|
14
|
+
* writer is handed over (or it fails); the turn then counts as open until
|
|
15
|
+
* its writer's scope closes. Only this process's turns count: one held by
|
|
16
|
+
* another process over the same log does not. `processActivity` and
|
|
17
|
+
* `awaitIdle` read it, when it is there.
|
|
18
|
+
*/
|
|
19
|
+
export declare class TurnActivity extends TurnActivity_base {
|
|
20
|
+
}
|
|
21
|
+
declare const TaskActivity_base: Context.ServiceClass<TaskActivity, "efferent/TaskActivity", {
|
|
22
|
+
/** The pieces of work running now. */
|
|
23
|
+
readonly running: Effect.Effect<number>;
|
|
24
|
+
/** Completes once none runs (at once when none does). */
|
|
25
|
+
readonly idle: Effect.Effect<void>;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* The background work this process runs, from the TaskRunner that runs it
|
|
29
|
+
* (`InProcessTaskRunnerLive` provides it). A piece of work counts from the
|
|
30
|
+
* moment `run` hands it over until it ends. `processActivity` and
|
|
31
|
+
* `awaitIdle` read it, when it is there.
|
|
32
|
+
*/
|
|
33
|
+
export declare class TaskActivity extends TaskActivity_base {
|
|
34
|
+
}
|
|
35
|
+
export {};
|