@gobing-ai/ts-ai-runner 0.5.0 → 0.5.2
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 +114 -0
- package/dist/agents/shims.js +4 -4
- package/dist/decision/decision-maker.d.ts +48 -0
- package/dist/decision/decision-maker.d.ts.map +1 -0
- package/dist/decision/decision-maker.js +86 -0
- package/dist/decision/errors.d.ts +62 -0
- package/dist/decision/errors.d.ts.map +1 -0
- package/dist/decision/errors.js +70 -0
- package/dist/decision/types.d.ts +105 -0
- package/dist/decision/types.d.ts.map +1 -0
- package/dist/decision/types.js +34 -0
- package/dist/decision/typesafe-driver.d.ts +24 -0
- package/dist/decision/typesafe-driver.d.ts.map +1 -0
- package/dist/decision/typesafe-driver.js +134 -0
- package/dist/decision/validation.d.ts +6 -0
- package/dist/decision/validation.d.ts.map +1 -0
- package/dist/decision/validation.js +92 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/package.json +5 -4
- package/src/agents/shims.ts +4 -4
- package/src/decision/decision-maker.ts +160 -0
- package/src/decision/errors.ts +88 -0
- package/src/decision/types.ts +111 -0
- package/src/decision/typesafe-driver.ts +175 -0
- package/src/decision/validation.ts +100 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TypeSafe backend driver — the only decision file that imports
|
|
3
|
+
* `@typesafe-ai/sdk` (0069 pin). Client wiring (R1/R2), neutral⇄SDK question
|
|
4
|
+
* and answer mapping (R4/R5), and the error translation table (R7) live here;
|
|
5
|
+
* everything above this file stays vendor-neutral.
|
|
6
|
+
*/
|
|
7
|
+
import { APIConnectionError, APIError, APITimeoutError, AuthenticationError, choice, noul, PermissionDeniedError, RateLimitError, score, TypeSafeClient, TypeSafeError, } from '@typesafe-ai/sdk';
|
|
8
|
+
import { DecisionAuthError, DecisionBackendError, DecisionConnectionError, DecisionRateLimitError, DecisionRequestError, DecisionTimeoutError, } from './errors.js';
|
|
9
|
+
import { validateAnswers, validateQuestions } from './validation.js';
|
|
10
|
+
/**
|
|
11
|
+
* Build the TypeSafe driver: exactly one `TypeSafeClient` per driver instance
|
|
12
|
+
* (R2), the API key always passed explicitly so the SDK's `TYPESAFE_API_KEY`
|
|
13
|
+
* self-resolution never runs (R1). All transport failures — at construction
|
|
14
|
+
* or at ask time — come back as `DecisionError`s, never SDK classes (R7).
|
|
15
|
+
*/
|
|
16
|
+
export function createTypesafeDriver(config) {
|
|
17
|
+
let client;
|
|
18
|
+
try {
|
|
19
|
+
client = new TypeSafeClient({
|
|
20
|
+
apiKey: config.apiKey,
|
|
21
|
+
// baseURL is forwarded as-is. SDK 0.6.0 exports no default
|
|
22
|
+
// base-URL constant (`ENV` holds env-var names only), so an
|
|
23
|
+
// omitted baseURL still lets the SDK self-resolve
|
|
24
|
+
// `TYPESAFE_BASE_URL` — documented residual for 0073.
|
|
25
|
+
baseURL: config.baseURL,
|
|
26
|
+
defaultModel: config.model,
|
|
27
|
+
timeout: config.timeoutMs,
|
|
28
|
+
retry: config.maxRetries === undefined ? undefined : { maxRetries: config.maxRetries },
|
|
29
|
+
fetch: config.fetch,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
translateError(err);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
name: 'typesafe',
|
|
37
|
+
async ask({ state, questions, model }) {
|
|
38
|
+
validateQuestions(questions);
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
const sdkQuestions = Object.fromEntries(Object.entries(questions).map(([name, question]) => [name, toSdkQuestion(question)]));
|
|
42
|
+
result = await client.systemOne({ state, questions: sdkQuestions, model });
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
translateError(err);
|
|
46
|
+
}
|
|
47
|
+
// R6: the SDK echoes the request's question names, so the mapped
|
|
48
|
+
// record keeps the caller's keys in correspondence. R8: `model`
|
|
49
|
+
// and `usage` on the result are deliberately not surfaced.
|
|
50
|
+
if (!result?.answers || typeof result.answers !== 'object' || Array.isArray(result.answers)) {
|
|
51
|
+
throw new DecisionBackendError('Invalid decision response: expected answers map', undefined);
|
|
52
|
+
}
|
|
53
|
+
const answers = Object.fromEntries(Object.entries(result.answers).map(([name, answer]) => [name, fromSdkAnswer(answer)]));
|
|
54
|
+
validateAnswers(questions, answers);
|
|
55
|
+
return answers;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Neutral question → SDK wire question (R4). `null` is "undescribed" on the wire. */
|
|
60
|
+
function toSdkQuestion(question) {
|
|
61
|
+
switch (question.kind) {
|
|
62
|
+
case 'choice':
|
|
63
|
+
return choice(question.prompt ?? null, question.labels);
|
|
64
|
+
case 'score':
|
|
65
|
+
return score(question.prompt ?? null, question.rubric);
|
|
66
|
+
case 'noul': {
|
|
67
|
+
const outcomes = question.yes === undefined && question.no === undefined
|
|
68
|
+
? undefined
|
|
69
|
+
: { true: question.yes, false: question.no };
|
|
70
|
+
return noul(question.prompt ?? null, outcomes);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** SDK wire response → neutral answer (R5). Noul gets no confidence — the wire has none and none is invented. */
|
|
75
|
+
function fromSdkAnswer(answer) {
|
|
76
|
+
if (!answer || typeof answer !== 'object')
|
|
77
|
+
throw new DecisionBackendError('Invalid decision response: expected answer object', undefined);
|
|
78
|
+
switch (answer.type) {
|
|
79
|
+
case 'choice':
|
|
80
|
+
return {
|
|
81
|
+
kind: 'choice',
|
|
82
|
+
label: answer.choice,
|
|
83
|
+
confidence: answer.confidence,
|
|
84
|
+
probabilities: answer.probabilities,
|
|
85
|
+
};
|
|
86
|
+
case 'score':
|
|
87
|
+
return {
|
|
88
|
+
kind: 'score',
|
|
89
|
+
score: answer.score,
|
|
90
|
+
confidence: answer.confidence,
|
|
91
|
+
legend: answer.legend,
|
|
92
|
+
probabilities: answer.probabilities,
|
|
93
|
+
};
|
|
94
|
+
case 'noul':
|
|
95
|
+
return { kind: 'noul', probability: answer.noul };
|
|
96
|
+
default:
|
|
97
|
+
throw new DecisionBackendError('Invalid decision response: unknown answer kind', undefined);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* R7, per the design doc's error table. Ordered subclass-before-base:
|
|
102
|
+
* `APITimeoutError` extends `APIConnectionError`, and the HTTP subclasses
|
|
103
|
+
* extend `APIError`. Rows the table does not name fall to the nearest home:
|
|
104
|
+
* 4xx (`NotFoundError` included) is a `DecisionRequestError`, and any local
|
|
105
|
+
* `TypeSafeError` (empty questions, invalid client config) lands there too
|
|
106
|
+
* with `status: undefined` — so no SDK class ever escapes. Foreign errors are
|
|
107
|
+
* rethrown untouched; they are not this package's to translate.
|
|
108
|
+
*/
|
|
109
|
+
function translateError(err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
111
|
+
if (err instanceof RateLimitError) {
|
|
112
|
+
throw new DecisionRateLimitError(message, err.status, err.retryAfterMs, { cause: err });
|
|
113
|
+
}
|
|
114
|
+
if (err instanceof AuthenticationError || err instanceof PermissionDeniedError) {
|
|
115
|
+
throw new DecisionAuthError(message, err.status, { cause: err });
|
|
116
|
+
}
|
|
117
|
+
if (err instanceof APITimeoutError) {
|
|
118
|
+
throw new DecisionTimeoutError(message, err.timeoutMs, { cause: err });
|
|
119
|
+
}
|
|
120
|
+
if (err instanceof APIConnectionError) {
|
|
121
|
+
throw new DecisionConnectionError(message, { cause: err });
|
|
122
|
+
}
|
|
123
|
+
if (err instanceof APIError) {
|
|
124
|
+
if (err.status < 500) {
|
|
125
|
+
const raw = typeof err.body === 'string' ? err.body : (JSON.stringify(err.body) ?? '');
|
|
126
|
+
throw new DecisionRequestError(message, err.status, raw.slice(0, 200) || undefined, { cause: err });
|
|
127
|
+
}
|
|
128
|
+
throw new DecisionBackendError(message, err.status, { cause: err });
|
|
129
|
+
}
|
|
130
|
+
if (err instanceof TypeSafeError) {
|
|
131
|
+
throw new DecisionRequestError(message, undefined, message, { cause: err });
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Question } from './types';
|
|
2
|
+
/** Validate caller data before either a custom driver or the SDK consumes it. */
|
|
3
|
+
export declare function validateQuestions(questions: unknown): asserts questions is Record<string, Question>;
|
|
4
|
+
/** Verify the correspondence that permits the facade's typed answer narrowing. */
|
|
5
|
+
export declare function validateAnswers(questions: Record<string, Question>, answers: unknown): void;
|
|
6
|
+
//# sourceMappingURL=validation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/decision/validation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAoBxC,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAoCnG;AAUD,kFAAkF;AAClF,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CA8B3F"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { DecisionBackendError, DecisionRequestError } from './errors.js';
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function json(value, ancestors = new Set()) {
|
|
6
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
7
|
+
return true;
|
|
8
|
+
if (typeof value === 'number')
|
|
9
|
+
return Number.isFinite(value);
|
|
10
|
+
if (typeof value !== 'object' || ancestors.has(value))
|
|
11
|
+
return false;
|
|
12
|
+
ancestors.add(value);
|
|
13
|
+
const valid = Object.values(value).every((entry) => json(entry, ancestors));
|
|
14
|
+
ancestors.delete(value);
|
|
15
|
+
return valid;
|
|
16
|
+
}
|
|
17
|
+
function desc(value) {
|
|
18
|
+
return (value === null || typeof value === 'string' || typeof value === 'object') && json(value);
|
|
19
|
+
}
|
|
20
|
+
/** Validate caller data before either a custom driver or the SDK consumes it. */
|
|
21
|
+
export function validateQuestions(questions) {
|
|
22
|
+
const fail = (name) => {
|
|
23
|
+
throw new DecisionRequestError(`Invalid decision question: ${name}`, undefined, undefined);
|
|
24
|
+
};
|
|
25
|
+
if (!record(questions) || Object.keys(questions).length === 0)
|
|
26
|
+
fail('expected a nonempty question map');
|
|
27
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
28
|
+
if (!record(question))
|
|
29
|
+
fail(name);
|
|
30
|
+
if (question.prompt !== undefined && !desc(question.prompt))
|
|
31
|
+
fail(name);
|
|
32
|
+
switch (question.kind) {
|
|
33
|
+
case 'choice':
|
|
34
|
+
if (!record(question.labels) ||
|
|
35
|
+
Object.keys(question.labels).length === 0 ||
|
|
36
|
+
!Object.values(question.labels).every(desc))
|
|
37
|
+
fail(name);
|
|
38
|
+
break;
|
|
39
|
+
case 'score':
|
|
40
|
+
if (!Array.isArray(question.rubric) ||
|
|
41
|
+
question.rubric.length < 2 ||
|
|
42
|
+
!Array.from(question.rubric).every(desc))
|
|
43
|
+
fail(name);
|
|
44
|
+
break;
|
|
45
|
+
case 'noul':
|
|
46
|
+
if ((question.yes !== undefined && !desc(question.yes)) ||
|
|
47
|
+
(question.no !== undefined && !desc(question.no)))
|
|
48
|
+
fail(name);
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
fail(name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function bounded(value, max = 1) {
|
|
56
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max;
|
|
57
|
+
}
|
|
58
|
+
function keysMatch(value, keys) {
|
|
59
|
+
return record(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
60
|
+
}
|
|
61
|
+
/** Verify the correspondence that permits the facade's typed answer narrowing. */
|
|
62
|
+
export function validateAnswers(questions, answers) {
|
|
63
|
+
const fail = (name) => {
|
|
64
|
+
throw new DecisionBackendError(`Invalid decision response: ${name}`, undefined);
|
|
65
|
+
};
|
|
66
|
+
if (!keysMatch(answers, Object.keys(questions)))
|
|
67
|
+
fail('answer names do not match questions');
|
|
68
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
69
|
+
const answer = answers[name];
|
|
70
|
+
if (!record(answer) || answer.kind !== question.kind)
|
|
71
|
+
fail(name);
|
|
72
|
+
if (question.kind === 'noul') {
|
|
73
|
+
if (!bounded(answer.probability))
|
|
74
|
+
fail(name);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const keys = question.kind === 'choice' ? Object.keys(question.labels) : Array.from(question.rubric.keys(), String);
|
|
78
|
+
if (!bounded(answer.confidence) ||
|
|
79
|
+
!keysMatch(answer.probabilities, keys) ||
|
|
80
|
+
!Object.values(answer.probabilities).every((p) => bounded(p)))
|
|
81
|
+
fail(name);
|
|
82
|
+
if (question.kind === 'choice') {
|
|
83
|
+
if (typeof answer.label !== 'string' || !Object.hasOwn(question.labels, answer.label))
|
|
84
|
+
fail(name);
|
|
85
|
+
}
|
|
86
|
+
else if (!bounded(answer.score, question.rubric.length - 1) ||
|
|
87
|
+
!keysMatch(answer.legend, keys) ||
|
|
88
|
+
!Object.values(answer.legend).every(desc)) {
|
|
89
|
+
fail(name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ export * from './agent-spec';
|
|
|
3
3
|
export * from './agents/auth-shims';
|
|
4
4
|
export * from './agents/shims';
|
|
5
5
|
export * from './ai-runner';
|
|
6
|
+
export * from './decision/decision-maker';
|
|
7
|
+
export * from './decision/errors';
|
|
8
|
+
export * from './decision/types';
|
|
6
9
|
export * from './doctor-runner';
|
|
7
10
|
export * from './events';
|
|
8
11
|
export * from './identity';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,2BAA2B,CAAC;AAC1C,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC;AAC3B,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,9 @@ export * from './agent-spec.js';
|
|
|
3
3
|
export * from './agents/auth-shims.js';
|
|
4
4
|
export * from './agents/shims.js';
|
|
5
5
|
export * from './ai-runner.js';
|
|
6
|
+
export * from './decision/decision-maker.js';
|
|
7
|
+
export * from './decision/errors.js';
|
|
8
|
+
export * from './decision/types.js';
|
|
6
9
|
export * from './doctor-runner.js';
|
|
7
10
|
export * from './events.js';
|
|
8
11
|
export * from './identity.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-ai-runner",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "@gobing-ai/ts-ai-runner — Coding-agent shims, detection, doctor checks, and prompt execution.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -47,12 +47,13 @@
|
|
|
47
47
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-ai-runner-v<version> && git push --tags' && exit 1"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@gobing-ai/ts-infra": "^0.5.
|
|
51
|
-
"@gobing-ai/ts-runtime": "^0.5.
|
|
50
|
+
"@gobing-ai/ts-infra": "^0.5.2",
|
|
51
|
+
"@gobing-ai/ts-runtime": "^0.5.2",
|
|
52
|
+
"@typesafe-ai/sdk": "0.6.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@types/bun": "1.3.14",
|
|
55
|
-
"@gobing-ai/ts-db": "^0.5.
|
|
56
|
+
"@gobing-ai/ts-db": "^0.5.2"
|
|
56
57
|
},
|
|
57
58
|
"publishConfig": {
|
|
58
59
|
"access": "public"
|
package/src/agents/shims.ts
CHANGED
|
@@ -477,14 +477,14 @@ const AGENT_SESSION_CAPABILITY: Readonly<Record<AgentName, AgentSessionCapabilit
|
|
|
477
477
|
supportsSessionDir: true,
|
|
478
478
|
supportsPersistentStdin: true,
|
|
479
479
|
supportsStructuredOutput: true,
|
|
480
|
-
verifiedAgainst: '0.
|
|
480
|
+
verifiedAgainst: '0.87.0',
|
|
481
481
|
},
|
|
482
482
|
claude: {
|
|
483
483
|
supportsResumeById: true,
|
|
484
484
|
supportsSessionDir: false,
|
|
485
485
|
supportsPersistentStdin: true,
|
|
486
486
|
supportsStructuredOutput: true,
|
|
487
|
-
verifiedAgainst: '2.1.
|
|
487
|
+
verifiedAgainst: '2.1.278',
|
|
488
488
|
// Persistent stdin is shim-wired: `-p --input-format stream-json --output-format
|
|
489
489
|
// stream-json` keeps the process alive reading JSONL envelopes (verified --help;
|
|
490
490
|
// --input-format only works with --print).
|
|
@@ -495,7 +495,7 @@ const AGENT_SESSION_CAPABILITY: Readonly<Record<AgentName, AgentSessionCapabilit
|
|
|
495
495
|
supportsSessionDir: false,
|
|
496
496
|
supportsPersistentStdin: false,
|
|
497
497
|
supportsStructuredOutput: true,
|
|
498
|
-
verifiedAgainst: '0.
|
|
498
|
+
verifiedAgainst: '0.155.1',
|
|
499
499
|
// R3 branch 2: `exec resume <id> <prompt>` is the working non-interactive
|
|
500
500
|
// resume (verified 0.154.0) — wired in getPromptCommand below.
|
|
501
501
|
note: 'no session-dir flag — sessionDir is ignored; `exec` carries one prompt arg (stdin `-` is one-shot), so no multi-turn stdin',
|
|
@@ -515,7 +515,7 @@ const AGENT_SESSION_CAPABILITY: Readonly<Record<AgentName, AgentSessionCapabilit
|
|
|
515
515
|
supportsSessionDir: false,
|
|
516
516
|
supportsPersistentStdin: false,
|
|
517
517
|
supportsStructuredOutput: true,
|
|
518
|
-
verifiedAgainst: '1.0.
|
|
518
|
+
verifiedAgainst: '1.0.40',
|
|
519
519
|
note: 'no session-dir flag — sessionDir is ignored (best-effort isolate); no multi-turn stdin input mode',
|
|
520
520
|
},
|
|
521
521
|
gemini: {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public decision surface — the only type callers see. `choice`, `score`,
|
|
3
|
+
* and `noul` are implemented once, here, as `ask()` with a single-entry
|
|
4
|
+
* question map; drivers implement `ask` and nothing else, so the sugar can
|
|
5
|
+
* never drift from the batch path. This file imports no SDK (R9).
|
|
6
|
+
*/
|
|
7
|
+
import { getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
8
|
+
import { DecisionConfigError } from './errors';
|
|
9
|
+
import type {
|
|
10
|
+
AnswersFor,
|
|
11
|
+
ChoiceAnswer,
|
|
12
|
+
DecisionDriver,
|
|
13
|
+
DecisionState,
|
|
14
|
+
Desc,
|
|
15
|
+
NoulAnswer,
|
|
16
|
+
Question,
|
|
17
|
+
ScoreAnswer,
|
|
18
|
+
} from './types';
|
|
19
|
+
import { q } from './types';
|
|
20
|
+
import { createTypesafeDriver } from './typesafe-driver';
|
|
21
|
+
import { validateAnswers, validateQuestions } from './validation';
|
|
22
|
+
|
|
23
|
+
/** Public decision surface: batch `ask` plus the three single-question sugar methods. */
|
|
24
|
+
export interface DecisionMaker {
|
|
25
|
+
/** Name of the backing driver (e.g. `"typesafe"`, or a custom driver's name). */
|
|
26
|
+
readonly driver: string;
|
|
27
|
+
/** Evaluate N named questions against one shared state in a single driver request. */
|
|
28
|
+
ask<const Q extends Record<string, Question>>(req: {
|
|
29
|
+
state: DecisionState;
|
|
30
|
+
questions: Q;
|
|
31
|
+
model?: string;
|
|
32
|
+
}): Promise<AnswersFor<Q>>;
|
|
33
|
+
/** Pick one label. Sugar over `ask` with exactly one choice question. */
|
|
34
|
+
choice<const L extends string>(
|
|
35
|
+
state: DecisionState,
|
|
36
|
+
prompt: Desc,
|
|
37
|
+
labels: Record<L, Desc>,
|
|
38
|
+
): Promise<ChoiceAnswer<L>>;
|
|
39
|
+
/** Score against a rubric. Sugar over `ask` with exactly one score question. */
|
|
40
|
+
score(state: DecisionState, prompt: Desc, rubric: readonly [Desc, Desc, ...Desc[]]): Promise<ScoreAnswer>;
|
|
41
|
+
/** Yes/no judgment. Sugar over `ask` with exactly one noul question. */
|
|
42
|
+
noul(state: DecisionState, prompt?: Desc, outcomes?: { yes?: Desc; no?: Desc }): Promise<NoulAnswer>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Available named decision backends (task 0080). */
|
|
46
|
+
export type DecisionBackend = 'typesafe' | 'laya-local';
|
|
47
|
+
|
|
48
|
+
/** Factory options: driver selection, credential injection, and transport tuning. */
|
|
49
|
+
export interface DecisionMakerOptions {
|
|
50
|
+
/** Custom driver — when supplied, `backend` is ignored, no default driver is constructed and no key is required. */
|
|
51
|
+
driver?: DecisionDriver;
|
|
52
|
+
/** Named backend selector. Default: `'typesafe'`. Resolution order: `driver` → `backend` → `'typesafe'`. */
|
|
53
|
+
backend?: DecisionBackend;
|
|
54
|
+
/** Injected env record; defaults to `getProcessEnv()` (doctor-runner convention). */
|
|
55
|
+
env?: Record<string, string | undefined>;
|
|
56
|
+
/** Explicit key — wins over `env.TYPESAFE_API_KEY`. */
|
|
57
|
+
apiKey?: string;
|
|
58
|
+
/** Forwarded to the TypeSafe driver; default is the SDK's model default. */
|
|
59
|
+
model?: string;
|
|
60
|
+
baseURL?: string;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
maxRetries?: number;
|
|
63
|
+
/** Injected fetch for tests. */
|
|
64
|
+
fetch?: typeof fetch;
|
|
65
|
+
[key: string]: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the key per R7: `options.apiKey ?? (options.env ?? getProcessEnv()).TYPESAFE_API_KEY`.
|
|
70
|
+
* Throws before any driver construction or request.
|
|
71
|
+
*/
|
|
72
|
+
function resolveApiKey(options: DecisionMakerOptions): string {
|
|
73
|
+
const env = options.env ?? getProcessEnv();
|
|
74
|
+
const apiKey = options.apiKey ?? env.TYPESAFE_API_KEY;
|
|
75
|
+
if (!apiKey) {
|
|
76
|
+
throw new DecisionConfigError(
|
|
77
|
+
'Missing TYPESAFE_API_KEY — set it in the environment or pass options.apiKey.',
|
|
78
|
+
'TYPESAFE_API_KEY',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return apiKey;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const LAYA_DRIVER_PACKAGE = '@gobing-ai/ts-laya-mlx';
|
|
85
|
+
|
|
86
|
+
async function resolveLayaDriver(options: DecisionMakerOptions): Promise<DecisionDriver> {
|
|
87
|
+
try {
|
|
88
|
+
const mod = (await import(LAYA_DRIVER_PACKAGE)) as {
|
|
89
|
+
createLayaDriver?: (opts?: unknown) => DecisionDriver;
|
|
90
|
+
};
|
|
91
|
+
if (typeof mod.createLayaDriver !== 'function') {
|
|
92
|
+
throw new Error(`Module '${LAYA_DRIVER_PACKAGE}' does not export createLayaDriver`);
|
|
93
|
+
}
|
|
94
|
+
return mod.createLayaDriver(options);
|
|
95
|
+
} catch (cause) {
|
|
96
|
+
throw new DecisionConfigError(
|
|
97
|
+
`The 'laya-local' backend requires '${LAYA_DRIVER_PACKAGE}' to be installed; install it with 'bun add ${LAYA_DRIVER_PACKAGE}'`,
|
|
98
|
+
'LAYA_BACKEND',
|
|
99
|
+
{ cause },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a DecisionMaker. `options.driver` wins when supplied; otherwise `options.backend`
|
|
106
|
+
* resolves the driver (default `'typesafe'`). The driver is constructed lazily on first use.
|
|
107
|
+
*/
|
|
108
|
+
export function createDecisionMaker(options: DecisionMakerOptions = {}): DecisionMaker {
|
|
109
|
+
let resolvedDriver: DecisionDriver | undefined;
|
|
110
|
+
const getDriver = async (): Promise<DecisionDriver> => {
|
|
111
|
+
if (options.driver) return options.driver;
|
|
112
|
+
if (resolvedDriver) return resolvedDriver;
|
|
113
|
+
const backend = options.backend ?? 'typesafe';
|
|
114
|
+
if (backend === 'typesafe') {
|
|
115
|
+
resolvedDriver = createTypesafeDriver({
|
|
116
|
+
apiKey: resolveApiKey(options),
|
|
117
|
+
model: options.model,
|
|
118
|
+
baseURL: options.baseURL,
|
|
119
|
+
timeoutMs: options.timeoutMs,
|
|
120
|
+
maxRetries: options.maxRetries,
|
|
121
|
+
fetch: options.fetch,
|
|
122
|
+
});
|
|
123
|
+
return resolvedDriver;
|
|
124
|
+
}
|
|
125
|
+
if (backend === 'laya-local') {
|
|
126
|
+
resolvedDriver = await resolveLayaDriver(options);
|
|
127
|
+
return resolvedDriver;
|
|
128
|
+
}
|
|
129
|
+
throw new DecisionConfigError(`Unknown decision backend '${backend}'`, 'backend');
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const ask = async <const Q extends Record<string, Question>>(req: {
|
|
133
|
+
state: DecisionState;
|
|
134
|
+
questions: Q;
|
|
135
|
+
model?: string;
|
|
136
|
+
}): Promise<AnswersFor<Q>> => {
|
|
137
|
+
// R3: the questions map reaches the driver untouched — same reference,
|
|
138
|
+
// no reordering, renaming, or dropping.
|
|
139
|
+
const driver = await getDriver();
|
|
140
|
+
validateQuestions(req.questions);
|
|
141
|
+
const answers = await driver.ask(req);
|
|
142
|
+
validateAnswers(req.questions, answers);
|
|
143
|
+
// Runtime validation above establishes each question/answer correspondence.
|
|
144
|
+
return answers as AnswersFor<Q>;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
driver: options.driver?.name ?? options.backend ?? 'typesafe',
|
|
149
|
+
ask,
|
|
150
|
+
// R4/R5: each sugar method is one `ask` with exactly one `q.*` question,
|
|
151
|
+
// unwrapping the single answer — no second driver call, no duplicated
|
|
152
|
+
// request assembly.
|
|
153
|
+
choice: (state, prompt, labels) =>
|
|
154
|
+
ask({ state, questions: { question: q.choice(prompt, labels) } }).then((a) => a.question),
|
|
155
|
+
score: (state, prompt, rubric) =>
|
|
156
|
+
ask({ state, questions: { question: q.score(prompt, rubric) } }).then((a) => a.question),
|
|
157
|
+
noul: (state, prompt, outcomes) =>
|
|
158
|
+
ask({ state, questions: { question: q.noul(prompt, outcomes) } }).then((a) => a.question),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DecisionError` taxonomy. Both the facade and the driver throw from this set;
|
|
3
|
+
* no raw SDK error class escapes the package. Keeping every class in one file
|
|
4
|
+
* makes that invariant checkable by reading a single file.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Base class for every decision-surface error. */
|
|
8
|
+
export class DecisionError extends Error {
|
|
9
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = new.target.name;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Missing configuration — e.g. no `TYPESAFE_API_KEY` in the injected env. Carries the variable name. */
|
|
16
|
+
export class DecisionConfigError extends DecisionError {
|
|
17
|
+
constructor(
|
|
18
|
+
message: string,
|
|
19
|
+
readonly variable: string,
|
|
20
|
+
options?: { cause?: unknown },
|
|
21
|
+
) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Authentication or permission failure upstream. Carries the HTTP status. */
|
|
27
|
+
export class DecisionAuthError extends DecisionError {
|
|
28
|
+
constructor(
|
|
29
|
+
message: string,
|
|
30
|
+
readonly status: number | undefined,
|
|
31
|
+
options?: { cause?: unknown },
|
|
32
|
+
) {
|
|
33
|
+
super(message, options);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Rate limited upstream. Carries the status and the parsed retry-after hint. */
|
|
38
|
+
export class DecisionRateLimitError extends DecisionError {
|
|
39
|
+
constructor(
|
|
40
|
+
message: string,
|
|
41
|
+
readonly status: number | undefined,
|
|
42
|
+
readonly retryAfterMs: number | undefined,
|
|
43
|
+
options?: { cause?: unknown },
|
|
44
|
+
) {
|
|
45
|
+
super(message, options);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Request timed out. Carries the configured timeout in milliseconds. */
|
|
50
|
+
export class DecisionTimeoutError extends DecisionError {
|
|
51
|
+
constructor(
|
|
52
|
+
message: string,
|
|
53
|
+
readonly timeoutMs: number | undefined,
|
|
54
|
+
options?: { cause?: unknown },
|
|
55
|
+
) {
|
|
56
|
+
super(message, options);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The backend was unreachable. Carries the underlying cause. */
|
|
61
|
+
export class DecisionConnectionError extends DecisionError {
|
|
62
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
63
|
+
super(message, options);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The request was rejected as malformed (4xx other than auth/rate-limit). Carries status and a body summary. */
|
|
68
|
+
export class DecisionRequestError extends DecisionError {
|
|
69
|
+
constructor(
|
|
70
|
+
message: string,
|
|
71
|
+
readonly status: number | undefined,
|
|
72
|
+
readonly bodySummary: string | undefined,
|
|
73
|
+
options?: { cause?: unknown },
|
|
74
|
+
) {
|
|
75
|
+
super(message, options);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Upstream server failure (5xx). Carries the HTTP status. */
|
|
80
|
+
export class DecisionBackendError extends DecisionError {
|
|
81
|
+
constructor(
|
|
82
|
+
message: string,
|
|
83
|
+
readonly status: number | undefined,
|
|
84
|
+
options?: { cause?: unknown },
|
|
85
|
+
) {
|
|
86
|
+
super(message, options);
|
|
87
|
+
}
|
|
88
|
+
}
|