@drkmttr/adhere 0.15.0 → 0.17.5
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 +273 -130
- package/bin/adhere.js +19 -4
- package/package.json +34 -12
- package/skills/adhere/SKILL.md +180 -31
- package/skills/adhere-setup/SKILL.md +142 -16
- package/src/cli.ts +56 -23
- package/src/config.ts +49 -16
- package/src/index.ts +14 -0
- package/src/markdown.ts +9 -5
- package/src/providers/cloudflare.ts +84 -0
- package/src/providers/jev.ts +66 -0
- package/src/providers/openai.ts +89 -0
- package/src/providers/post.ts +53 -0
- package/src/providers/provider.ts +131 -0
- package/src/providers/providers.test.ts +276 -0
- package/src/providers/systemOne.ts +95 -0
- package/src/services/AdhereConfig.ts +2 -4
- package/src/services/Credentials.ts +101 -51
- package/src/services/Jev.http.ts +165 -170
- package/src/services/Jev.provider.test.ts +207 -0
- package/src/services/Jev.reads.test.ts +439 -0
- package/src/services/Jev.ts +205 -113
- package/src/services/NativeLsp.ts +289 -0
- package/src/services/SourceReads.test.ts +1040 -0
- package/src/services/SourceReads.ts +437 -0
- package/src/services/SourceWalker.ts +26 -59
- package/src/workflows/audit.ts +440 -265
- package/src/workflows/format.ts +74 -17
- package/src/workspaces.test.ts +73 -0
- package/src/workspaces.ts +91 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** How each built-in provider sends a request and reads what comes back. */
|
|
2
|
+
import { RequestFailed } from "#providers/provider.ts";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
const messageOf = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POSTs `body` as JSON, once: adhere tries again. A request that gets no
|
|
9
|
+
* answer, or whose answer breaks off, throws a `RequestFailed` with status 0.
|
|
10
|
+
*/
|
|
11
|
+
export const post = async (
|
|
12
|
+
name: string,
|
|
13
|
+
url: string,
|
|
14
|
+
headers: Readonly<Record<string, string>>,
|
|
15
|
+
body: unknown,
|
|
16
|
+
signal: AbortSignal,
|
|
17
|
+
fetch: typeof globalThis.fetch = globalThis.fetch,
|
|
18
|
+
): Promise<{ readonly response: Response; readonly text: string }> => {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(url, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
signal,
|
|
25
|
+
});
|
|
26
|
+
return { response, text: await response.text() };
|
|
27
|
+
} catch (cause) {
|
|
28
|
+
throw new RequestFailed(`${name} did not answer: ${messageOf(cause)}`, 0);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A response that is not ok, as the `RequestFailed` to throw: its status, the
|
|
34
|
+
* Ray ID of the Cloudflare edge in front of the API when there is one, and
|
|
35
|
+
* its body on one line and cut short.
|
|
36
|
+
*/
|
|
37
|
+
export const failed = (name: string, response: Response, text: string): RequestFailed => {
|
|
38
|
+
const ray = response.headers.get("cf-ray");
|
|
39
|
+
const detail = text.trim().replace(/\s+/g, " ").slice(0, 300);
|
|
40
|
+
return new RequestFailed(
|
|
41
|
+
`${name} answered HTTP ${response.status}${ray === null ? "" : `, Ray ID ${ray}`}${detail === "" ? "" : `: ${detail}`}`,
|
|
42
|
+
response.status,
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** An ok response's body, decoded by `schema`. One that does not decode throws, naming who sent it. */
|
|
47
|
+
export const decoded = <A>(name: string, schema: Schema.Codec<A, string>, text: string): A => {
|
|
48
|
+
try {
|
|
49
|
+
return Schema.decodeUnknownSync(schema)(text);
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
throw new Error(`${name}'s answer did not decode: ${messageOf(cause)}`);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What adhere asks a decision model, and what it takes back. A provider puts
|
|
3
|
+
* adhere's questions to its model in whatever form the model takes; adhere
|
|
4
|
+
* builds the questions, splits them into requests, paces and retries them,
|
|
5
|
+
* and reads the answers. Jev's provider is the default.
|
|
6
|
+
*/
|
|
7
|
+
import { Schema } from "effect";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the questions ask about: the file's code by section under `code`,
|
|
11
|
+
* beside what adhere read for it; comparing rules, the rules under `rules`.
|
|
12
|
+
*/
|
|
13
|
+
export type State = Readonly<Record<string, unknown>>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One of adhere's questions, as adhere phrases it. `question` names what it
|
|
17
|
+
* asks about in backticks: keys of the state, such as `code`, and of
|
|
18
|
+
* `fields`, such as `rule`, `must`, `never`, or `matcher`, which hold the
|
|
19
|
+
* rule it asks about and its examples. A `noul` asks whether something holds,
|
|
20
|
+
* `yes` and `no` saying what each answer means when adhere says. A `choice`
|
|
21
|
+
* asks for the key of one of its `options`, each beside what it means when
|
|
22
|
+
* its key does not say; it can have a single option.
|
|
23
|
+
*/
|
|
24
|
+
export type Question =
|
|
25
|
+
| {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly type: "noul";
|
|
28
|
+
readonly question: string;
|
|
29
|
+
readonly fields: Readonly<Record<string, unknown>>;
|
|
30
|
+
readonly yes?: string;
|
|
31
|
+
readonly no?: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly type: "choice";
|
|
36
|
+
readonly question: string;
|
|
37
|
+
readonly fields: Readonly<Record<string, unknown>>;
|
|
38
|
+
readonly options: Readonly<Record<string, string | null>>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A provider's answers by question id: a `noul`'s probability that it holds,
|
|
43
|
+
* from 0 to 1, or the key of a `choice`'s option. An id left out goes
|
|
44
|
+
* unanswered: its rule is asked again on the next run, or its finding not
|
|
45
|
+
* located. `inputTokens` and `model`, what the model read and which version
|
|
46
|
+
* answered, are for the record, such as the eval's costs.
|
|
47
|
+
*/
|
|
48
|
+
export const Answered = Schema.Struct({
|
|
49
|
+
answers: Schema.Record(Schema.String, Schema.Union([Schema.Finite, Schema.String])),
|
|
50
|
+
inputTokens: Schema.optional(Schema.Finite),
|
|
51
|
+
model: Schema.optional(Schema.String),
|
|
52
|
+
});
|
|
53
|
+
export interface Answered extends Schema.Schema.Type<typeof Answered> {}
|
|
54
|
+
|
|
55
|
+
/** The keys `adhere login` saves: TypeSafe AI's, for Jev, and with `--openai`, OpenAI's. */
|
|
56
|
+
export const SAVED_KEYS = ["typesafe", "openai"] as const;
|
|
57
|
+
export type SavedKey = (typeof SAVED_KEYS)[number];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What adhere passes each call of `ask`: the key, when the provider names a
|
|
61
|
+
* `savedKey`, and the signal that aborts the call when adhere gives up on it.
|
|
62
|
+
*/
|
|
63
|
+
export interface AskOptions {
|
|
64
|
+
readonly apiKey?: string;
|
|
65
|
+
readonly signal: AbortSignal;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where adhere's questions go. `ask` answers one request's questions. Its
|
|
70
|
+
* rejection stops the run, as Jev being down does, but for three:
|
|
71
|
+
* `ContextOverflow` skips the file, `RequestBlocked` reports it blocked, and
|
|
72
|
+
* `RequestFailed` is tried again when its status says it may pass. adhere
|
|
73
|
+
* paces each call with `--rpm`, and gives up on one after 30 seconds. `model`
|
|
74
|
+
* names what it asks: adhere's cache keeps answers by it, so a new model is
|
|
75
|
+
* asked again. A provider that names a `savedKey` gets that key as `apiKey`:
|
|
76
|
+
* its environment variable when set, otherwise the one `adhere login` saved.
|
|
77
|
+
* adhere calls `ask` on the provider, so a class can implement it.
|
|
78
|
+
*/
|
|
79
|
+
export interface Provider {
|
|
80
|
+
readonly model: string;
|
|
81
|
+
readonly savedKey?: SavedKey;
|
|
82
|
+
ask(state: State, questions: ReadonlyArray<Question>, options: AskOptions): Promise<Answered>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Splits off the choices of a single option, which some APIs refuse to be
|
|
87
|
+
* asked: each is answered by its option, and the rest are left to ask.
|
|
88
|
+
*/
|
|
89
|
+
export const withoutSingleChoices = (questions: ReadonlyArray<Question>) => {
|
|
90
|
+
const answers: Record<string, number | string> = {};
|
|
91
|
+
const asked = questions.filter((question) => {
|
|
92
|
+
const [only, ...others] = question.type === "choice" ? Object.keys(question.options) : [];
|
|
93
|
+
if (only === undefined || others.length > 0) return true;
|
|
94
|
+
answers[question.id] = only;
|
|
95
|
+
return false;
|
|
96
|
+
});
|
|
97
|
+
return { answers, asked };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Thrown from `ask` when the request is longer than the model takes: adhere skips the file. */
|
|
101
|
+
export class ContextOverflow extends Error {
|
|
102
|
+
readonly _tag = "ContextOverflow";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Thrown from `ask` when something in front of the model refused the request
|
|
107
|
+
* for what it holds: adhere reports the file blocked, with `reference` to look
|
|
108
|
+
* the block up by, and goes on.
|
|
109
|
+
*/
|
|
110
|
+
export class RequestBlocked extends Error {
|
|
111
|
+
readonly _tag = "RequestBlocked";
|
|
112
|
+
constructor(readonly reference: string) {
|
|
113
|
+
super(`blocked, reference ${reference}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Thrown from `ask` when the request failed: `status` is the HTTP status it
|
|
119
|
+
* got, or 0 when it got no answer. adhere tries one with no answer, a 408, a
|
|
120
|
+
* 429, or a 5xx again, up to three times, after half a second, one, and two;
|
|
121
|
+
* any other stops the run with its message.
|
|
122
|
+
*/
|
|
123
|
+
export class RequestFailed extends Error {
|
|
124
|
+
readonly _tag = "RequestFailed";
|
|
125
|
+
constructor(
|
|
126
|
+
message: string,
|
|
127
|
+
readonly status: number,
|
|
128
|
+
) {
|
|
129
|
+
super(message);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { Rule } from "#config.ts";
|
|
2
|
+
import { cloudflare } from "#providers/cloudflare.ts";
|
|
3
|
+
import { jev } from "#providers/jev.ts";
|
|
4
|
+
import { openai } from "#providers/openai.ts";
|
|
5
|
+
import { ContextOverflow, type Question, RequestFailed } from "#providers/provider.ts";
|
|
6
|
+
import { questionsOf, requestOf } from "#providers/systemOne.ts";
|
|
7
|
+
import { conflictBody, contradictBody, judgeBody, locateBody } from "#services/Jev.ts";
|
|
8
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
9
|
+
|
|
10
|
+
const plain: Rule = {
|
|
11
|
+
description: "Ports must be named.",
|
|
12
|
+
must: "const port = PORT;",
|
|
13
|
+
never: "listen(3000)",
|
|
14
|
+
appliesTo: ["a server's entry point"],
|
|
15
|
+
};
|
|
16
|
+
const lines = ["const port = 3000;", "", "listen(port);"];
|
|
17
|
+
const compared = [
|
|
18
|
+
{ id: "a", rule: plain },
|
|
19
|
+
{ id: "b", rule: { description: "Ports must be literals.", must: "listen(3000)" } },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** A fetch that records each request's URL, headers, and body, and answers with `respond`. */
|
|
23
|
+
const recording = (respond: (body: Record<string, unknown>, sent: number) => Response) => {
|
|
24
|
+
const sent: Array<{ url: string; authorization: string | null; body: Record<string, unknown> }> =
|
|
25
|
+
[];
|
|
26
|
+
const fetch = (async (url: unknown, init?: RequestInit) => {
|
|
27
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
28
|
+
sent.push({
|
|
29
|
+
url: String(url),
|
|
30
|
+
authorization: new Headers(init?.headers).get("authorization"),
|
|
31
|
+
body,
|
|
32
|
+
});
|
|
33
|
+
return respond(body, sent.length);
|
|
34
|
+
}) as typeof globalThis.fetch;
|
|
35
|
+
return { sent, fetch };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const noul = (id: string): Question => ({ id, type: "noul", question: `Is ${id} so?`, fields: {} });
|
|
39
|
+
|
|
40
|
+
/** What adhere passes `ask` beside the key: a signal no test aborts. */
|
|
41
|
+
const signal = new AbortController().signal;
|
|
42
|
+
|
|
43
|
+
describe("System One", () => {
|
|
44
|
+
it("puts adhere's questions back as adhere built them, for Jev and Clef", () => {
|
|
45
|
+
const bodies = [
|
|
46
|
+
judgeBody("jev-latest", lines, { plain }, ["plain"]),
|
|
47
|
+
locateBody("jev-latest", lines, { plain }),
|
|
48
|
+
conflictBody("jev-latest", compared, [[1], [0]]),
|
|
49
|
+
contradictBody("jev-latest", compared, [0, 1]),
|
|
50
|
+
];
|
|
51
|
+
for (const body of bodies) {
|
|
52
|
+
const rebuilt = requestOf(body.model, body.state, questionsOf(body.questions));
|
|
53
|
+
expect(JSON.stringify(rebuilt)).toBe(JSON.stringify(body));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("jev", () => {
|
|
59
|
+
it("sends adhere's request to TypeSafe with the key, and reads each answer as a value", async () => {
|
|
60
|
+
const body = judgeBody("jev-latest", lines, { plain });
|
|
61
|
+
const { sent, fetch } = recording(() =>
|
|
62
|
+
Response.json({
|
|
63
|
+
model: "jev-1.13.0",
|
|
64
|
+
answers: { plain: { noul: 0.91 }, "appliesTo:plain:0": { noul: 0.2 } },
|
|
65
|
+
usage: { input_tokens: 420 },
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const answered = await jev({ apiKey: "tsk_test", fetch }).ask(
|
|
69
|
+
body.state,
|
|
70
|
+
questionsOf(body.questions),
|
|
71
|
+
{ signal },
|
|
72
|
+
);
|
|
73
|
+
expect(sent.map(({ url, authorization }) => [url, authorization])).toEqual([
|
|
74
|
+
["https://api.typesafe.ai/v1/systemone", "Bearer tsk_test"],
|
|
75
|
+
]);
|
|
76
|
+
expect(JSON.stringify(sent[0]?.body)).toBe(JSON.stringify(body));
|
|
77
|
+
expect(answered).toEqual({
|
|
78
|
+
answers: { plain: 0.91, "appliesTo:plain:0": 0.2 },
|
|
79
|
+
inputTokens: 420,
|
|
80
|
+
model: "jev-1.13.0",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("saved keys", () => {
|
|
86
|
+
it("are named by jev and openai built without a key, and asked with the key adhere passes", async () => {
|
|
87
|
+
const toJev = recording(() => Response.json({ answers: {} }));
|
|
88
|
+
const toOpenAI = recording(() => Response.json({ answers: [] }));
|
|
89
|
+
const jevProvider = jev({ fetch: toJev.fetch });
|
|
90
|
+
const openaiProvider = openai({ fetch: toOpenAI.fetch });
|
|
91
|
+
expect([jevProvider.savedKey, openaiProvider.savedKey]).toEqual(["typesafe", "openai"]);
|
|
92
|
+
expect(jev({ apiKey: "tsk_own" }).savedKey).toBeUndefined();
|
|
93
|
+
await jevProvider.ask({}, [noul("a")], { apiKey: "tsk_saved", signal });
|
|
94
|
+
await openaiProvider.ask({}, [noul("a")], { apiKey: "sk-saved", signal });
|
|
95
|
+
expect([...toJev.sent, ...toOpenAI.sent].map(({ authorization }) => authorization)).toEqual([
|
|
96
|
+
"Bearer tsk_saved",
|
|
97
|
+
"Bearer sk-saved",
|
|
98
|
+
]);
|
|
99
|
+
await expect(jevProvider.ask({}, [noul("a")], { signal })).rejects.toThrow(
|
|
100
|
+
"No TypeSafe AI API key",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("cloudflare", () => {
|
|
106
|
+
it("asks 64 questions to a request under ids Clef takes, answers a choice of one option unasked, and puts each answer back under its key", async () => {
|
|
107
|
+
const questions: ReadonlyArray<Question> = [
|
|
108
|
+
...Array.from({ length: 70 }, (_, index) => noul(`linter:rule/${index}`)),
|
|
109
|
+
{ id: "start:rule:1", type: "choice", question: "Where?", fields: {}, options: { "3": "x" } },
|
|
110
|
+
];
|
|
111
|
+
const { sent, fetch } = recording((body) =>
|
|
112
|
+
Response.json({
|
|
113
|
+
result: {
|
|
114
|
+
model: "clef",
|
|
115
|
+
answers: Object.fromEntries(
|
|
116
|
+
Object.keys(body.questions as object).map((id) => [id, { type: "noul", noul: 0.5 }]),
|
|
117
|
+
),
|
|
118
|
+
usage: { input_tokens: 100 },
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const answered = await cloudflare({ accountId: "acct", apiToken: "cft", fetch }).ask(
|
|
123
|
+
{ code: { "1": "x" } },
|
|
124
|
+
questions,
|
|
125
|
+
{ signal },
|
|
126
|
+
);
|
|
127
|
+
expect(sent.map(({ url }) => url)).toEqual([
|
|
128
|
+
"https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef",
|
|
129
|
+
"https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef",
|
|
130
|
+
]);
|
|
131
|
+
expect(sent.map(({ body }) => Object.keys(body.questions as object).length)).toEqual([64, 6]);
|
|
132
|
+
expect(Object.keys(sent[1]?.body.questions as object)).toEqual([
|
|
133
|
+
"q0",
|
|
134
|
+
"q1",
|
|
135
|
+
"q2",
|
|
136
|
+
"q3",
|
|
137
|
+
"q4",
|
|
138
|
+
"q5",
|
|
139
|
+
]);
|
|
140
|
+
expect(answered.answers["linter:rule/69"]).toBe(0.5);
|
|
141
|
+
expect(answered.answers["start:rule:1"]).toBe("3");
|
|
142
|
+
expect(answered.inputTokens).toBe(200);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("skips as over its context a request it read to its limit, or one Workers AI refused as too large", async () => {
|
|
146
|
+
const read = (tokens: number) =>
|
|
147
|
+
recording(() =>
|
|
148
|
+
Response.json({
|
|
149
|
+
result: { answers: { q0: { noul: 0.5 } }, usage: { input_tokens: tokens } },
|
|
150
|
+
}),
|
|
151
|
+
).fetch;
|
|
152
|
+
const flash = (fetch: typeof globalThis.fetch) =>
|
|
153
|
+
cloudflare({ accountId: "acct", apiToken: "cft", model: "clef-flash", fetch }).ask(
|
|
154
|
+
{},
|
|
155
|
+
[noul("a")],
|
|
156
|
+
{ signal },
|
|
157
|
+
);
|
|
158
|
+
expect((await flash(read(23_999))).answers).toEqual({ a: 0.5 });
|
|
159
|
+
await expect(flash(read(24_000))).rejects.toBeInstanceOf(ContextOverflow);
|
|
160
|
+
const tooLarge = recording(() => new Response("{}", { status: 413 })).fetch;
|
|
161
|
+
await expect(flash(tooLarge)).rejects.toBeInstanceOf(ContextOverflow);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("openai", () => {
|
|
166
|
+
it("asks each question as a decision question, its parts in JSON, and leaves a refusal unanswered", async () => {
|
|
167
|
+
const questions: ReadonlyArray<Question> = [
|
|
168
|
+
{
|
|
169
|
+
id: "plain",
|
|
170
|
+
type: "noul",
|
|
171
|
+
question: "Does `code` diverge from `must`?",
|
|
172
|
+
fields: { rule: "Ports must be named.", must: "const port = PORT;" },
|
|
173
|
+
yes: "it diverges",
|
|
174
|
+
no: "it follows",
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: "plain:section",
|
|
178
|
+
type: "choice",
|
|
179
|
+
question: "Which?",
|
|
180
|
+
fields: {},
|
|
181
|
+
options: { "1": null, "2": "the second" },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "start:plain:1",
|
|
185
|
+
type: "choice",
|
|
186
|
+
question: "Where?",
|
|
187
|
+
fields: {},
|
|
188
|
+
options: { "7": "x" },
|
|
189
|
+
},
|
|
190
|
+
noul("refused"),
|
|
191
|
+
];
|
|
192
|
+
const { sent, fetch } = recording(() =>
|
|
193
|
+
Response.json({
|
|
194
|
+
model: "gpt-6-luna",
|
|
195
|
+
answers: [
|
|
196
|
+
{ type: "predicate", name: "plain", probability: 0.9 },
|
|
197
|
+
{
|
|
198
|
+
type: "choice",
|
|
199
|
+
name: "plain:section",
|
|
200
|
+
choice: "2",
|
|
201
|
+
probabilities: [],
|
|
202
|
+
confidence: 0.8,
|
|
203
|
+
},
|
|
204
|
+
{ type: "refusal", name: "refused" },
|
|
205
|
+
],
|
|
206
|
+
usage: { input_tokens: 300 },
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
const answered = await openai({ apiKey: "sk-test", fetch }).ask(
|
|
210
|
+
{ code: { "1": "x" } },
|
|
211
|
+
questions,
|
|
212
|
+
{ signal },
|
|
213
|
+
);
|
|
214
|
+
expect(sent[0]?.body).toEqual({
|
|
215
|
+
model: "gpt-6-luna",
|
|
216
|
+
input: JSON.stringify({ code: { "1": "x" } }),
|
|
217
|
+
questions: [
|
|
218
|
+
{
|
|
219
|
+
type: "predicate",
|
|
220
|
+
name: "plain",
|
|
221
|
+
instructions: JSON.stringify({
|
|
222
|
+
instructions: {
|
|
223
|
+
question: "Does `code` diverge from `must`?",
|
|
224
|
+
rule: "Ports must be named.",
|
|
225
|
+
must: "const port = PORT;",
|
|
226
|
+
},
|
|
227
|
+
criteria: { true: "it diverges", false: "it follows" },
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
type: "choice",
|
|
232
|
+
name: "plain:section",
|
|
233
|
+
instructions: "Which?",
|
|
234
|
+
choices: [{ value: "1" }, { value: "2", description: "the second" }],
|
|
235
|
+
},
|
|
236
|
+
{ type: "predicate", name: "refused", instructions: "Is refused so?" },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
expect(answered).toEqual({
|
|
240
|
+
answers: { "start:plain:1": "7", plain: 0.9, "plain:section": "2" },
|
|
241
|
+
inputTokens: 300,
|
|
242
|
+
model: "gpt-6-luna",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("a failed request", () => {
|
|
248
|
+
it("is thrown once, as a RequestFailed with its status and Ray ID, or status 0 when nothing answered", async () => {
|
|
249
|
+
const busy = recording(
|
|
250
|
+
() => new Response("busy\n", { status: 503, headers: { "cf-ray": "8c1f-LAX" } }),
|
|
251
|
+
);
|
|
252
|
+
const failed = await jev({ apiKey: "tsk_test", fetch: busy.fetch })
|
|
253
|
+
.ask({}, [noul("a")], { signal })
|
|
254
|
+
.catch((cause: unknown) => cause);
|
|
255
|
+
expect(failed).toBeInstanceOf(RequestFailed);
|
|
256
|
+
expect(failed).toMatchObject({
|
|
257
|
+
status: 503,
|
|
258
|
+
message: "Jev answered HTTP 503, Ray ID 8c1f-LAX: busy",
|
|
259
|
+
});
|
|
260
|
+
expect(busy.sent).toHaveLength(1);
|
|
261
|
+
|
|
262
|
+
const down = (async () => {
|
|
263
|
+
throw new TypeError("fetch failed");
|
|
264
|
+
}) as unknown as typeof globalThis.fetch;
|
|
265
|
+
await expect(
|
|
266
|
+
openai({ apiKey: "sk-test", fetch: down }).ask({}, [noul("a")], { signal }),
|
|
267
|
+
).rejects.toMatchObject({ status: 0, message: "OpenAI did not answer: fetch failed" });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("is an answer that does not decode, naming who sent it", async () => {
|
|
271
|
+
const { fetch } = recording(() => Response.json({ answers: "none" }));
|
|
272
|
+
await expect(
|
|
273
|
+
openai({ apiKey: "sk-test", fetch }).ask({}, [noul("a")], { signal }),
|
|
274
|
+
).rejects.toThrow("OpenAI's answer did not decode");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System One, the format Jev takes and Clef follows, and adhere's questions
|
|
3
|
+
* in it. adhere builds its questions this way, as its cache and Jev have
|
|
4
|
+
* always read them; a provider gets them split into their parts by
|
|
5
|
+
* `questionsOf`, and Jev's and Clef's put them back with `requestOf`.
|
|
6
|
+
*/
|
|
7
|
+
import type { Answered, Question, State } from "#providers/provider.ts";
|
|
8
|
+
import { Schema } from "effect";
|
|
9
|
+
|
|
10
|
+
/** A question's instructions: the question alone, or beside the fields it names. */
|
|
11
|
+
type Instructions = string | (Readonly<Record<string, unknown>> & { readonly question: string });
|
|
12
|
+
|
|
13
|
+
/** A question as System One takes it: a `noul` with what yes and no mean, or a `choice` among its criteria's keys. */
|
|
14
|
+
export type SystemOneQuestion =
|
|
15
|
+
| {
|
|
16
|
+
readonly type: "noul";
|
|
17
|
+
readonly instructions: Instructions;
|
|
18
|
+
readonly criteria?: { readonly true: string; readonly false: string };
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
readonly type: "choice";
|
|
22
|
+
readonly instructions: Instructions;
|
|
23
|
+
readonly criteria: Readonly<Record<string, string | null>>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A request as System One takes it: questions by id about `state`, its questions typed as `Q`. */
|
|
27
|
+
export interface SystemOneRequest<Q = SystemOneQuestion> {
|
|
28
|
+
readonly model: string;
|
|
29
|
+
readonly state: State;
|
|
30
|
+
readonly questions: Readonly<Record<string, Q>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What System One answers: by id, a `noul`'s probability or a `choice`'s
|
|
35
|
+
* key, with the model that answered and the tokens it read.
|
|
36
|
+
*/
|
|
37
|
+
export const SystemOneAnswered = Schema.Struct({
|
|
38
|
+
answers: Schema.Record(
|
|
39
|
+
Schema.String,
|
|
40
|
+
Schema.Struct({
|
|
41
|
+
noul: Schema.optionalKey(Schema.Finite),
|
|
42
|
+
choice: Schema.optionalKey(Schema.String),
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
model: Schema.optionalKey(Schema.String),
|
|
46
|
+
usage: Schema.optionalKey(Schema.Struct({ input_tokens: Schema.Finite })),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Each question of a request split into what it asks, the fields it names, and what its answers mean. */
|
|
50
|
+
export const questionsOf = (
|
|
51
|
+
questions: Readonly<Record<string, SystemOneQuestion>>,
|
|
52
|
+
): ReadonlyArray<Question> =>
|
|
53
|
+
Object.entries(questions).map(([id, asked]) => {
|
|
54
|
+
const { question, ...fields } =
|
|
55
|
+
typeof asked.instructions === "string"
|
|
56
|
+
? { question: asked.instructions }
|
|
57
|
+
: asked.instructions;
|
|
58
|
+
if (asked.type === "choice")
|
|
59
|
+
return { id, type: "choice", question, fields, options: asked.criteria };
|
|
60
|
+
return asked.criteria === undefined
|
|
61
|
+
? { id, type: "noul", question, fields }
|
|
62
|
+
: { id, type: "noul", question, fields, yes: asked.criteria.true, no: asked.criteria.false };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** A question as System One asks it, as adhere built it: a question with no fields is its instructions alone. */
|
|
66
|
+
export const systemOneQuestionOf = (asked: Question): SystemOneQuestion => {
|
|
67
|
+
const instructions =
|
|
68
|
+
Object.keys(asked.fields).length === 0
|
|
69
|
+
? asked.question
|
|
70
|
+
: { question: asked.question, ...asked.fields };
|
|
71
|
+
if (asked.type === "choice") return { type: "choice", instructions, criteria: asked.options };
|
|
72
|
+
return asked.yes === undefined || asked.no === undefined
|
|
73
|
+
? { type: "noul", instructions }
|
|
74
|
+
: { type: "noul", instructions, criteria: { true: asked.yes, false: asked.no } };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** A request as System One takes it, each question by its id. */
|
|
78
|
+
export const requestOf = (
|
|
79
|
+
model: string,
|
|
80
|
+
state: State,
|
|
81
|
+
questions: ReadonlyArray<Question>,
|
|
82
|
+
): SystemOneRequest => ({
|
|
83
|
+
model,
|
|
84
|
+
state,
|
|
85
|
+
questions: Object.fromEntries(questions.map((asked) => [asked.id, systemOneQuestionOf(asked)])),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** System One's answers as a provider gives them: each a probability or a key. One with neither is left out. */
|
|
89
|
+
export const answersOf = (answers: typeof SystemOneAnswered.Type.answers): Answered["answers"] =>
|
|
90
|
+
Object.fromEntries(
|
|
91
|
+
Object.entries(answers).flatMap(
|
|
92
|
+
([id, { noul, choice }]): ReadonlyArray<readonly [string, number | string]> =>
|
|
93
|
+
noul !== undefined ? [[id, noul]] : choice !== undefined ? [[id, choice]] : [],
|
|
94
|
+
),
|
|
95
|
+
);
|
|
@@ -131,10 +131,8 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
const scopedRules = withOverrides(entries, overrides);
|
|
134
|
-
//
|
|
135
|
-
const workspacePackages =
|
|
136
|
-
(entry) => entry.rule.reads?.includes("workspacePackages") === true,
|
|
137
|
-
)
|
|
134
|
+
// Read on every run so workspace edits are seen, but only when the config enables this context.
|
|
135
|
+
const workspacePackages = resolved.reads.includes("workspacePackages")
|
|
138
136
|
? yield* workspacePackagesOf(cwd)
|
|
139
137
|
: undefined;
|
|
140
138
|
return AdhereConfig.of({
|