@openshain/core 0.1.0 → 0.2.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/dist/config/load.d.ts +10 -0
- package/dist/config/load.js +66 -0
- package/dist/config/schema.d.ts +84 -0
- package/dist/config/schema.js +96 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +30 -0
- package/dist/ids.d.ts +11 -0
- package/dist/ids.js +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +20 -0
- package/dist/model/types.d.ts +57 -0
- package/dist/model/types.js +0 -0
- package/dist/runtime.d.ts +46 -0
- package/dist/runtime.js +144 -0
- package/dist/schemas.d.ts +9 -0
- package/dist/schemas.js +44 -0
- package/dist/tool/load-module.d.ts +6 -0
- package/dist/tool/load-module.js +42 -0
- package/dist/tool/paths.d.ts +17 -0
- package/dist/tool/paths.js +82 -0
- package/dist/tool/registry.d.ts +30 -0
- package/dist/tool/registry.js +68 -0
- package/dist/tool/types.d.ts +44 -0
- package/dist/tool/types.js +15 -0
- package/dist/tool/validate.d.ts +14 -0
- package/dist/tool/validate.js +68 -0
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +33 -0
- package/dist/work/artifacts.d.ts +7 -0
- package/dist/work/artifacts.js +20 -0
- package/dist/work/event-log.d.ts +28 -0
- package/dist/work/event-log.js +140 -0
- package/dist/work/events.d.ts +311 -0
- package/dist/work/events.js +344 -0
- package/dist/work/lock.d.ts +13 -0
- package/dist/work/lock.js +80 -0
- package/dist/work/projection.d.ts +31 -0
- package/dist/work/projection.js +130 -0
- package/dist/work/store.d.ts +58 -0
- package/dist/work/store.js +174 -0
- package/dist/work/work.d.ts +86 -0
- package/dist/work/work.js +149 -0
- package/package.json +16 -5
- package/src/ids.ts +3 -2
- package/src/index.ts +1 -0
- package/src/uuid.ts +33 -0
- package/src/work/projection.ts +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Config } from "./schema.ts";
|
|
2
|
+
export declare const CONFIG_FILE_NAME = "openshain.yaml";
|
|
3
|
+
export interface ParseConfigOptions {
|
|
4
|
+
/** Provider ids the runtime can construct. When given, other names are rejected. */
|
|
5
|
+
modelProviders?: readonly string[];
|
|
6
|
+
/** Name used in error messages. Defaults to openshain.yaml. */
|
|
7
|
+
fileName?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function loadConfig(workspaceRoot: string, options?: Omit<ParseConfigOptions, "fileName">): Promise<Config>;
|
|
10
|
+
export declare function parseConfig(text: string, options?: ParseConfigOptions): Config;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isNode, LineCounter, parseDocument } from "yaml";
|
|
4
|
+
import { OpenshainError } from "../errors.js";
|
|
5
|
+
import { ConfigFileSchema, toConfig } from "./schema.js";
|
|
6
|
+
export const CONFIG_FILE_NAME = "openshain.yaml";
|
|
7
|
+
export async function loadConfig(workspaceRoot, options = {}) {
|
|
8
|
+
const fileName = join(workspaceRoot, CONFIG_FILE_NAME);
|
|
9
|
+
let text;
|
|
10
|
+
try {
|
|
11
|
+
text = await readFile(fileName, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch (cause) {
|
|
14
|
+
throw new OpenshainError("config", `${CONFIG_FILE_NAME} not found in ${workspaceRoot}`, {
|
|
15
|
+
cause,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return parseConfig(text, { ...options, fileName });
|
|
19
|
+
}
|
|
20
|
+
export function parseConfig(text, options = {}) {
|
|
21
|
+
const fileName = options.fileName ?? CONFIG_FILE_NAME;
|
|
22
|
+
const lineCounter = new LineCounter();
|
|
23
|
+
let doc;
|
|
24
|
+
let data;
|
|
25
|
+
try {
|
|
26
|
+
doc = parseDocument(text, { lineCounter });
|
|
27
|
+
data = doc.errors.length > 0 ? undefined : doc.toJS();
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
// yaml refuses resource-exhaustion documents (alias bombs) with a plain error
|
|
31
|
+
throw new OpenshainError("config", `${fileName}: ${cause.message}`, { cause });
|
|
32
|
+
}
|
|
33
|
+
if (doc.errors.length > 0) {
|
|
34
|
+
const lines = doc.errors.map((error) => {
|
|
35
|
+
const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
|
|
36
|
+
return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
|
|
37
|
+
});
|
|
38
|
+
throw new OpenshainError("config", lines.join("\n"));
|
|
39
|
+
}
|
|
40
|
+
const locate = (path) => {
|
|
41
|
+
for (let i = path.length; i >= 0; i--) {
|
|
42
|
+
const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
|
|
43
|
+
if (isNode(node) && node.range)
|
|
44
|
+
return lineCounter.linePos(node.range[0]);
|
|
45
|
+
}
|
|
46
|
+
return { line: 1, col: 1 };
|
|
47
|
+
};
|
|
48
|
+
const problem = (path, message) => {
|
|
49
|
+
const { line, col } = locate(path);
|
|
50
|
+
const where = path.length === 0 ? "<root>" : path.map(String).join(".");
|
|
51
|
+
return `${fileName}:${line}:${col} ${where}: ${message}`;
|
|
52
|
+
};
|
|
53
|
+
const result = ConfigFileSchema.safeParse(data);
|
|
54
|
+
if (!result.success) {
|
|
55
|
+
const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
|
|
56
|
+
throw new OpenshainError("config", problems.join("\n"));
|
|
57
|
+
}
|
|
58
|
+
const known = options.modelProviders;
|
|
59
|
+
if (known && !known.includes(result.data.model.provider)) {
|
|
60
|
+
throw new OpenshainError("config", problem(["model", "provider"], `unknown provider "${result.data.model.provider}"; known providers: ${known.length > 0 ? known.join(", ") : "none"}`));
|
|
61
|
+
}
|
|
62
|
+
return toConfig(result.data);
|
|
63
|
+
}
|
|
64
|
+
function firstLine(message) {
|
|
65
|
+
return message.split("\n", 1)[0] ?? message;
|
|
66
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Shape of openshain.yaml as written on disk (snake_case). */
|
|
3
|
+
/** The languages the product has words and names for. */
|
|
4
|
+
export declare const LANGUAGES: readonly ["ja", "en"];
|
|
5
|
+
export type Language = (typeof LANGUAGES)[number];
|
|
6
|
+
export declare const ConfigFileSchema: z.ZodObject<{
|
|
7
|
+
version: z.ZodLiteral<1>;
|
|
8
|
+
company: z.ZodObject<{
|
|
9
|
+
name: z.ZodString;
|
|
10
|
+
language: z.ZodDefault<z.ZodEnum<{
|
|
11
|
+
en: "en";
|
|
12
|
+
ja: "ja";
|
|
13
|
+
}>>;
|
|
14
|
+
}, z.core.$strict>;
|
|
15
|
+
principal: z.ZodObject<{
|
|
16
|
+
id: z.ZodString;
|
|
17
|
+
name: z.ZodString;
|
|
18
|
+
}, z.core.$strict>;
|
|
19
|
+
profession: z.ZodObject<{
|
|
20
|
+
id: z.ZodString;
|
|
21
|
+
instructions: z.ZodString;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
model: z.ZodObject<{
|
|
24
|
+
provider: z.ZodString;
|
|
25
|
+
model: z.ZodString;
|
|
26
|
+
api_key_env: z.ZodString;
|
|
27
|
+
base_url: z.ZodOptional<z.ZodURL>;
|
|
28
|
+
options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
29
|
+
}, z.core.$strict>;
|
|
30
|
+
tools: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
31
|
+
provider: z.ZodOptional<z.ZodString>;
|
|
32
|
+
module: z.ZodOptional<z.ZodString>;
|
|
33
|
+
allow: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
34
|
+
}, z.core.$strict>>>;
|
|
35
|
+
limits: z.ZodPrefault<z.ZodObject<{
|
|
36
|
+
max_model_calls: z.ZodDefault<z.ZodInt>;
|
|
37
|
+
max_tool_calls: z.ZodDefault<z.ZodInt>;
|
|
38
|
+
max_output_tokens: z.ZodDefault<z.ZodInt>;
|
|
39
|
+
}, z.core.$strict>>;
|
|
40
|
+
debug: z.ZodPrefault<z.ZodObject<{
|
|
41
|
+
persist_raw: z.ZodDefault<z.ZodBoolean>;
|
|
42
|
+
}, z.core.$strict>>;
|
|
43
|
+
}, z.core.$strict>;
|
|
44
|
+
export type ConfigFile = z.infer<typeof ConfigFileSchema>;
|
|
45
|
+
export type ToolProviderRef = {
|
|
46
|
+
provider: string;
|
|
47
|
+
allow: readonly string[] | undefined;
|
|
48
|
+
} | {
|
|
49
|
+
module: string;
|
|
50
|
+
allow: readonly string[] | undefined;
|
|
51
|
+
};
|
|
52
|
+
/** Configuration as used in code (camelCase). */
|
|
53
|
+
export interface Config {
|
|
54
|
+
version: 1;
|
|
55
|
+
company: {
|
|
56
|
+
name: string;
|
|
57
|
+
language: Language;
|
|
58
|
+
};
|
|
59
|
+
principal: {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
};
|
|
63
|
+
profession: {
|
|
64
|
+
id: string;
|
|
65
|
+
instructions: string;
|
|
66
|
+
};
|
|
67
|
+
model: {
|
|
68
|
+
provider: string;
|
|
69
|
+
model: string;
|
|
70
|
+
apiKeyEnv: string;
|
|
71
|
+
baseUrl: string | undefined;
|
|
72
|
+
options: Record<string, unknown> | undefined;
|
|
73
|
+
};
|
|
74
|
+
tools: ToolProviderRef[];
|
|
75
|
+
limits: {
|
|
76
|
+
maxModelCalls: number;
|
|
77
|
+
maxToolCalls: number;
|
|
78
|
+
maxOutputTokens: number;
|
|
79
|
+
};
|
|
80
|
+
debug: {
|
|
81
|
+
persistRaw: boolean;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export declare function toConfig(file: ConfigFile): Config;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const identifier = z
|
|
3
|
+
.string()
|
|
4
|
+
.regex(/^[a-z][a-z0-9_-]*$/, "use lowercase letters, digits, _ or -, starting with a letter");
|
|
5
|
+
const envVarName = z
|
|
6
|
+
.string()
|
|
7
|
+
.regex(/^[A-Z][A-Z0-9_]*$/, "environment variable names are UPPER_SNAKE_CASE");
|
|
8
|
+
const toolName = z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(/^[a-z][a-z0-9_]*$/, "tool names use lowercase letters, digits and _");
|
|
11
|
+
// One strict object instead of a union: zod reports union failures at the union
|
|
12
|
+
// itself, which would hide the exact line of a bad `allow` entry.
|
|
13
|
+
const toolProviderRef = z
|
|
14
|
+
.strictObject({
|
|
15
|
+
provider: identifier.optional(),
|
|
16
|
+
module: z.string().min(1).optional(),
|
|
17
|
+
allow: z.array(toolName).optional(),
|
|
18
|
+
})
|
|
19
|
+
.refine((entry) => (entry.provider === undefined) !== (entry.module === undefined), {
|
|
20
|
+
message: "name exactly one of provider or module",
|
|
21
|
+
});
|
|
22
|
+
/** Hosts that stay on this machine, where an API key may travel without TLS. */
|
|
23
|
+
function isLoopback(hostname) {
|
|
24
|
+
return (hostname === "localhost" ||
|
|
25
|
+
hostname.endsWith(".localhost") ||
|
|
26
|
+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
|
|
27
|
+
hostname === "[::1]" ||
|
|
28
|
+
hostname === "::1");
|
|
29
|
+
}
|
|
30
|
+
/** Shape of openshain.yaml as written on disk (snake_case). */
|
|
31
|
+
/** The languages the product has words and names for. */
|
|
32
|
+
export const LANGUAGES = ["ja", "en"];
|
|
33
|
+
export const ConfigFileSchema = z.strictObject({
|
|
34
|
+
version: z.literal(1),
|
|
35
|
+
company: z.strictObject({
|
|
36
|
+
name: z.string().min(1).max(200),
|
|
37
|
+
language: z.enum(LANGUAGES).default("ja"),
|
|
38
|
+
}),
|
|
39
|
+
principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
|
|
40
|
+
profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
|
|
41
|
+
model: z.strictObject({
|
|
42
|
+
provider: identifier,
|
|
43
|
+
model: z.string().min(1).max(200),
|
|
44
|
+
api_key_env: envVarName,
|
|
45
|
+
base_url: z
|
|
46
|
+
.url()
|
|
47
|
+
.refine((value) => {
|
|
48
|
+
const url = new URL(value);
|
|
49
|
+
return url.username === "" && url.password === "";
|
|
50
|
+
}, "base_url must not carry credentials; use api_key_env")
|
|
51
|
+
.refine((value) => {
|
|
52
|
+
const url = new URL(value);
|
|
53
|
+
return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname));
|
|
54
|
+
}, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
|
|
55
|
+
.optional(),
|
|
56
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
57
|
+
}),
|
|
58
|
+
tools: z.array(toolProviderRef).default([{ provider: "standard" }]),
|
|
59
|
+
limits: z
|
|
60
|
+
.strictObject({
|
|
61
|
+
max_model_calls: z.int().positive().default(30),
|
|
62
|
+
max_tool_calls: z.int().positive().default(100),
|
|
63
|
+
max_output_tokens: z.int().positive().default(16000),
|
|
64
|
+
})
|
|
65
|
+
.prefault({}),
|
|
66
|
+
debug: z.strictObject({ persist_raw: z.boolean().default(false) }).prefault({}),
|
|
67
|
+
});
|
|
68
|
+
export function toConfig(file) {
|
|
69
|
+
return {
|
|
70
|
+
version: file.version,
|
|
71
|
+
company: { name: file.company.name, language: file.company.language },
|
|
72
|
+
principal: { id: file.principal.id, name: file.principal.name },
|
|
73
|
+
profession: { id: file.profession.id, instructions: file.profession.instructions },
|
|
74
|
+
model: {
|
|
75
|
+
provider: file.model.provider,
|
|
76
|
+
model: file.model.model,
|
|
77
|
+
apiKeyEnv: file.model.api_key_env,
|
|
78
|
+
baseUrl: file.model.base_url,
|
|
79
|
+
options: file.model.options,
|
|
80
|
+
},
|
|
81
|
+
tools: file.tools.map(toToolProviderRef),
|
|
82
|
+
limits: {
|
|
83
|
+
maxModelCalls: file.limits.max_model_calls,
|
|
84
|
+
maxToolCalls: file.limits.max_tool_calls,
|
|
85
|
+
maxOutputTokens: file.limits.max_output_tokens,
|
|
86
|
+
},
|
|
87
|
+
debug: { persistRaw: file.debug.persist_raw },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function toToolProviderRef(entry) {
|
|
91
|
+
if (entry.provider !== undefined)
|
|
92
|
+
return { provider: entry.provider, allow: entry.allow };
|
|
93
|
+
if (entry.module !== undefined)
|
|
94
|
+
return { module: entry.module, allow: entry.allow };
|
|
95
|
+
throw new Error("unreachable: the schema requires provider or module");
|
|
96
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const ERROR_CODES: readonly ["auth", "network", "rate_limit", "invalid_response", "config", "corrupt_log", "invalid_transition", "duplicate_tool", "invalid_id", "invalid_tool", "invalid_path", "lock_held", "not_found", "reserved_path", "outside_workspace", "concurrent_write", "invalid_event"];
|
|
2
|
+
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
3
|
+
export declare class OpenshainError extends Error {
|
|
4
|
+
readonly name = "OpenshainError";
|
|
5
|
+
readonly code: ErrorCode;
|
|
6
|
+
constructor(code: ErrorCode, message: string, options?: {
|
|
7
|
+
cause?: unknown;
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export declare function isOpenshainError(value: unknown): value is OpenshainError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const ERROR_CODES = [
|
|
2
|
+
"auth",
|
|
3
|
+
"network",
|
|
4
|
+
"rate_limit",
|
|
5
|
+
"invalid_response",
|
|
6
|
+
"config",
|
|
7
|
+
"corrupt_log",
|
|
8
|
+
"invalid_transition",
|
|
9
|
+
"duplicate_tool",
|
|
10
|
+
"invalid_id",
|
|
11
|
+
"invalid_tool",
|
|
12
|
+
"invalid_path",
|
|
13
|
+
"lock_held",
|
|
14
|
+
"not_found",
|
|
15
|
+
"reserved_path",
|
|
16
|
+
"outside_workspace",
|
|
17
|
+
"concurrent_write",
|
|
18
|
+
"invalid_event",
|
|
19
|
+
];
|
|
20
|
+
export class OpenshainError extends Error {
|
|
21
|
+
name = "OpenshainError";
|
|
22
|
+
code;
|
|
23
|
+
constructor(code, message, options) {
|
|
24
|
+
super(message, options);
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function isOpenshainError(value) {
|
|
29
|
+
return value instanceof OpenshainError;
|
|
30
|
+
}
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare const brand: unique symbol;
|
|
2
|
+
type Brand<T, Name extends string> = T & {
|
|
3
|
+
readonly [brand]: Name;
|
|
4
|
+
};
|
|
5
|
+
export type WorkId = Brand<string, "WorkId">;
|
|
6
|
+
export type EventId = Brand<string, "EventId">;
|
|
7
|
+
export declare function newWorkId(): WorkId;
|
|
8
|
+
export declare function newEventId(): EventId;
|
|
9
|
+
export declare function parseWorkId(value: string): WorkId;
|
|
10
|
+
export declare function parseEventId(value: string): EventId;
|
|
11
|
+
export {};
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { OpenshainError } from "./errors.js";
|
|
2
|
+
import { uuidv7 } from "./uuid.js";
|
|
3
|
+
const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
4
|
+
export function newWorkId() {
|
|
5
|
+
return `work_${uuidv7()}`;
|
|
6
|
+
}
|
|
7
|
+
export function newEventId() {
|
|
8
|
+
return `evt_${uuidv7()}`;
|
|
9
|
+
}
|
|
10
|
+
export function parseWorkId(value) {
|
|
11
|
+
return parseId(value, "work_");
|
|
12
|
+
}
|
|
13
|
+
export function parseEventId(value) {
|
|
14
|
+
return parseId(value, "evt_");
|
|
15
|
+
}
|
|
16
|
+
function parseId(value, prefix) {
|
|
17
|
+
if (!value.startsWith(prefix) || !UUID_V7.test(value.slice(prefix.length))) {
|
|
18
|
+
throw new OpenshainError("invalid_id", `expected ${prefix}<uuid v7>, got "${value}"`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { CONFIG_FILE_NAME, loadConfig, type ParseConfigOptions, parseConfig, } from "./config/load.ts";
|
|
2
|
+
export type { Config, ToolProviderRef } from "./config/schema.ts";
|
|
3
|
+
export { LANGUAGES, type Language } from "./config/schema.ts";
|
|
4
|
+
export { ERROR_CODES, type ErrorCode, isOpenshainError, OpenshainError } from "./errors.ts";
|
|
5
|
+
export { type EventId, newEventId, newWorkId, parseEventId, parseWorkId, type WorkId, } from "./ids.ts";
|
|
6
|
+
export type { ModelDescription, ModelMessage, ModelProvider, ModelRequest, ModelResponse, UserPart, } from "./model/types.ts";
|
|
7
|
+
export { type CreateRuntimeOptions, createRuntime, createToolCaller, createToolRegistry, MAX_TOOL_TEXT_CHARS, type Runtime, type RuntimeProviders, type ToolSummary, } from "./runtime.ts";
|
|
8
|
+
export { jsonSchemas, type SchemaName } from "./schemas.ts";
|
|
9
|
+
export { loadToolModule } from "./tool/load-module.ts";
|
|
10
|
+
export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
|
|
11
|
+
export { type HiddenTool, type RegisteredTool, type RegisterOptions, ToolRegistry, } from "./tool/registry.ts";
|
|
12
|
+
export { ASK_USER_TOOL_NAME, type JsonSchema, RESERVED_TOOL_NAMES, TOOL_NAME_PATTERN, type ToolCall, type ToolContext, type ToolDefinition, type ToolEffect, type ToolProvider, type ToolResult, } from "./tool/types.ts";
|
|
13
|
+
export { compileInputValidator, type InputValidation } from "./tool/validate.ts";
|
|
14
|
+
export { uuidv7 } from "./uuid.ts";
|
|
15
|
+
export { verifyArtifact } from "./work/artifacts.ts";
|
|
16
|
+
export { EVENTS_FILE_NAME, EventLog, type NewEvent } from "./work/event-log.ts";
|
|
17
|
+
export { type AnyEvent, type Artifact, type AssistantPart, canonical, type Event, type EventFile, EventFileSchema, type EventPayloads, type EventType, eventFromFile, eventToFile, type ModelUsage, payloadFileSchemas, type StopReason, TOOL_REJECTION_CODES, type ToolContent, type ToolRejectionCode, type UnknownEvent, } from "./work/events.ts";
|
|
18
|
+
export { acquireLock, LOCK_FILE_NAME, type Lock } from "./work/lock.ts";
|
|
19
|
+
export { buildProjection, type Projection, type ProjectionInput } from "./work/projection.ts";
|
|
20
|
+
export { type CreateWorkInput, type ListResult, WORK_DIR_NAME, WORK_FILE_NAME, type WorkHandle, WorkStore, } from "./work/store.ts";
|
|
21
|
+
export { isTerminal, reduceWork, SESSION_WORK_TYPE, transition, WORK_STATUSES, type Work, type WorkFile, WorkFileSchema, WorkStatus, workToFile, } from "./work/work.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// @openshain/core: Contracts (provider interfaces), fundamental objects, and the work runtime
|
|
2
|
+
export { CONFIG_FILE_NAME, loadConfig, parseConfig, } from "./config/load.js";
|
|
3
|
+
export { LANGUAGES } from "./config/schema.js";
|
|
4
|
+
export { ERROR_CODES, isOpenshainError, OpenshainError } from "./errors.js";
|
|
5
|
+
export { newEventId, newWorkId, parseEventId, parseWorkId, } from "./ids.js";
|
|
6
|
+
export { createRuntime, createToolCaller, createToolRegistry, MAX_TOOL_TEXT_CHARS, } from "./runtime.js";
|
|
7
|
+
export { jsonSchemas } from "./schemas.js";
|
|
8
|
+
export { loadToolModule } from "./tool/load-module.js";
|
|
9
|
+
export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.js";
|
|
10
|
+
export { ToolRegistry, } from "./tool/registry.js";
|
|
11
|
+
export { ASK_USER_TOOL_NAME, RESERVED_TOOL_NAMES, TOOL_NAME_PATTERN, } from "./tool/types.js";
|
|
12
|
+
export { compileInputValidator } from "./tool/validate.js";
|
|
13
|
+
export { uuidv7 } from "./uuid.js";
|
|
14
|
+
export { verifyArtifact } from "./work/artifacts.js";
|
|
15
|
+
export { EVENTS_FILE_NAME, EventLog } from "./work/event-log.js";
|
|
16
|
+
export { canonical, EventFileSchema, eventFromFile, eventToFile, payloadFileSchemas, TOOL_REJECTION_CODES, } from "./work/events.js";
|
|
17
|
+
export { acquireLock, LOCK_FILE_NAME } from "./work/lock.js";
|
|
18
|
+
export { buildProjection } from "./work/projection.js";
|
|
19
|
+
export { WORK_DIR_NAME, WORK_FILE_NAME, WorkStore, } from "./work/store.js";
|
|
20
|
+
export { isTerminal, reduceWork, SESSION_WORK_TYPE, transition, WORK_STATUSES, WorkFileSchema, WorkStatus, workToFile, } from "./work/work.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ToolDefinition } from "../tool/types.ts";
|
|
2
|
+
import type { AssistantPart, ModelUsage, StopReason } from "../work/events.ts";
|
|
3
|
+
export type UserPart = {
|
|
4
|
+
type: "text";
|
|
5
|
+
text: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: "tool_result";
|
|
8
|
+
callId: string;
|
|
9
|
+
content: string;
|
|
10
|
+
isError?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type ModelMessage = {
|
|
13
|
+
role: "user";
|
|
14
|
+
content: UserPart[];
|
|
15
|
+
} | {
|
|
16
|
+
role: "assistant";
|
|
17
|
+
content: AssistantPart[];
|
|
18
|
+
};
|
|
19
|
+
export interface ModelRequest {
|
|
20
|
+
system?: string;
|
|
21
|
+
messages: ModelMessage[];
|
|
22
|
+
tools?: ToolDefinition[];
|
|
23
|
+
maxOutputTokens?: number;
|
|
24
|
+
/** Passed to the provider as is. The contract does not interpret it. */
|
|
25
|
+
providerOptions?: Record<string, unknown>;
|
|
26
|
+
/** How many model and tool calls the work may still make. Providers may ignore it. */
|
|
27
|
+
budget?: {
|
|
28
|
+
modelCallsLeft: number;
|
|
29
|
+
toolCallsLeft: number;
|
|
30
|
+
};
|
|
31
|
+
/** How many leading messages will be sent unchanged next turn. A provider may anchor a prompt cache after them. */
|
|
32
|
+
stableMessages?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface ModelResponse {
|
|
35
|
+
message: {
|
|
36
|
+
role: "assistant";
|
|
37
|
+
content: AssistantPart[];
|
|
38
|
+
};
|
|
39
|
+
stopReason: StopReason;
|
|
40
|
+
usage: ModelUsage;
|
|
41
|
+
/** The provider's native response, for debugging only. Not persisted by default. */
|
|
42
|
+
raw?: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface ModelDescription {
|
|
45
|
+
/** For display and error messages. The id the log records is ModelProvider.id. */
|
|
46
|
+
provider: string;
|
|
47
|
+
model: string;
|
|
48
|
+
capabilities: {
|
|
49
|
+
tools: boolean;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface ModelProvider {
|
|
53
|
+
/** Recorded in the log and used to route opaque parts back. Stable across the provider's models. */
|
|
54
|
+
readonly id: string;
|
|
55
|
+
describe(): ModelDescription;
|
|
56
|
+
generate(request: ModelRequest, signal?: AbortSignal): Promise<ModelResponse>;
|
|
57
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Config } from "./config/schema.ts";
|
|
2
|
+
import type { ModelProvider } from "./model/types.ts";
|
|
3
|
+
import type { HiddenTool } from "./tool/registry.ts";
|
|
4
|
+
import { ToolRegistry } from "./tool/registry.ts";
|
|
5
|
+
import type { ToolCall, ToolDefinition, ToolProvider, ToolResult } from "./tool/types.ts";
|
|
6
|
+
import { type WorkHandle, WorkStore } from "./work/store.ts";
|
|
7
|
+
export interface RuntimeProviders {
|
|
8
|
+
/** Model providers by the id used in openshain.yaml. */
|
|
9
|
+
models: Record<string, (model: Config["model"]) => ModelProvider>;
|
|
10
|
+
/** Tool providers by the id used in openshain.yaml. Modules are loaded from the config directly. */
|
|
11
|
+
tools: Record<string, () => ToolProvider>;
|
|
12
|
+
}
|
|
13
|
+
export interface CreateRuntimeOptions {
|
|
14
|
+
workspaceRoot: string;
|
|
15
|
+
providers: RuntimeProviders;
|
|
16
|
+
}
|
|
17
|
+
/** What the outside world learns about a registered tool. Calls go through runtime.tools.call. */
|
|
18
|
+
export interface ToolSummary {
|
|
19
|
+
definition: ToolDefinition;
|
|
20
|
+
providerId: string;
|
|
21
|
+
}
|
|
22
|
+
/** Longer tool output is cut here so that one tool cannot flood the model's context. */
|
|
23
|
+
export declare const MAX_TOOL_TEXT_CHARS = 50000;
|
|
24
|
+
export interface Runtime {
|
|
25
|
+
readonly workspaceRoot: string;
|
|
26
|
+
readonly config: Config;
|
|
27
|
+
readonly model: ModelProvider;
|
|
28
|
+
readonly works: WorkStore;
|
|
29
|
+
readonly tools: {
|
|
30
|
+
list(): ToolSummary[];
|
|
31
|
+
/** Tools the providers offer but the allow lists in the config left out. */
|
|
32
|
+
hidden(): HiddenTool[];
|
|
33
|
+
/** Validates, runs and records one tool call for the given work. Never throws for a tool's own failure. */
|
|
34
|
+
call(work: WorkHandle, call: ToolCall): Promise<ToolResult>;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Builds a runtime for one workspace from its config and the providers the caller knows. */
|
|
38
|
+
export declare function createRuntime(options: CreateRuntimeOptions): Promise<Runtime>;
|
|
39
|
+
/** The tool call pipeline on its own: authorize, validate, run, record. For callers that need no model, such as the MCP server. */
|
|
40
|
+
export declare function createToolCaller(input: {
|
|
41
|
+
registry: ToolRegistry;
|
|
42
|
+
config: Config;
|
|
43
|
+
workspaceRoot: string;
|
|
44
|
+
}): (work: WorkHandle, call: ToolCall) => Promise<ToolResult>;
|
|
45
|
+
/** Registers the tool providers the config names: the caller's factories by id, and modules from the workspace. Needs no model. */
|
|
46
|
+
export declare function createToolRegistry(workspaceRoot: string, config: Config, tools: RuntimeProviders["tools"]): Promise<ToolRegistry>;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { loadConfig } from "./config/load.js";
|
|
2
|
+
import { isOpenshainError, OpenshainError } from "./errors.js";
|
|
3
|
+
import { loadToolModule } from "./tool/load-module.js";
|
|
4
|
+
import { ToolRegistry } from "./tool/registry.js";
|
|
5
|
+
import { TOOL_REJECTION_CODES } from "./work/events.js";
|
|
6
|
+
import { WorkStore } from "./work/store.js";
|
|
7
|
+
/** Longer tool output is cut here so that one tool cannot flood the model's context. */
|
|
8
|
+
export const MAX_TOOL_TEXT_CHARS = 50_000;
|
|
9
|
+
/** Builds a runtime for one workspace from its config and the providers the caller knows. */
|
|
10
|
+
export async function createRuntime(options) {
|
|
11
|
+
const { workspaceRoot, providers } = options;
|
|
12
|
+
const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
|
|
13
|
+
const modelFactory = Object.hasOwn(providers.models, config.model.provider)
|
|
14
|
+
? providers.models[config.model.provider]
|
|
15
|
+
: undefined;
|
|
16
|
+
if (!modelFactory) {
|
|
17
|
+
throw new OpenshainError("config", `unknown model provider "${config.model.provider}"`);
|
|
18
|
+
}
|
|
19
|
+
const model = modelFactory(config.model);
|
|
20
|
+
const description = model.describe();
|
|
21
|
+
if (!description.capabilities.tools) {
|
|
22
|
+
throw new OpenshainError("config", `model ${description.provider}/${description.model} cannot call tools; openshain needs a model with tool support`);
|
|
23
|
+
}
|
|
24
|
+
const registry = await createToolRegistry(workspaceRoot, config, providers.tools);
|
|
25
|
+
const works = new WorkStore(workspaceRoot);
|
|
26
|
+
return {
|
|
27
|
+
workspaceRoot,
|
|
28
|
+
config,
|
|
29
|
+
model,
|
|
30
|
+
works,
|
|
31
|
+
tools: {
|
|
32
|
+
list: () => registry.list().map(({ definition, providerId }) => ({ definition, providerId })),
|
|
33
|
+
hidden: () => registry.hiddenTools(),
|
|
34
|
+
call: createToolCaller({ registry, config, workspaceRoot }),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** The tool call pipeline on its own: authorize, validate, run, record. For callers that need no model, such as the MCP server. */
|
|
39
|
+
export function createToolCaller(input) {
|
|
40
|
+
return (work, call) => callTool({ ...input, work, call });
|
|
41
|
+
}
|
|
42
|
+
/** Registers the tool providers the config names: the caller's factories by id, and modules from the workspace. Needs no model. */
|
|
43
|
+
export async function createToolRegistry(workspaceRoot, config, tools) {
|
|
44
|
+
const registry = new ToolRegistry();
|
|
45
|
+
for (const entry of config.tools) {
|
|
46
|
+
const registerOptions = entry.allow ? { allow: entry.allow } : {};
|
|
47
|
+
if ("provider" in entry) {
|
|
48
|
+
const factory = Object.hasOwn(tools, entry.provider) ? tools[entry.provider] : undefined;
|
|
49
|
+
if (!factory) {
|
|
50
|
+
throw new OpenshainError("config", `unknown tool provider "${entry.provider}"; known providers: ${Object.keys(tools).join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
await registry.register(factory(), registerOptions);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
await registry.register(await loadToolModule(workspaceRoot, entry.module), registerOptions);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return registry;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The one place that allows or refuses a call before it runs. At this stage it knows only
|
|
62
|
+
* the allow lists; a later authority engine plugs in here.
|
|
63
|
+
*/
|
|
64
|
+
function authorize(registry, call) {
|
|
65
|
+
const tool = registry.get(call.name);
|
|
66
|
+
if (tool)
|
|
67
|
+
return { ok: true, tool };
|
|
68
|
+
return registry.isHidden(call.name)
|
|
69
|
+
? {
|
|
70
|
+
ok: false,
|
|
71
|
+
code: "not_allowed",
|
|
72
|
+
reason: `tool "${call.name}" is not allowed in this workspace`,
|
|
73
|
+
}
|
|
74
|
+
: { ok: false, code: "unknown_tool", reason: `unknown tool "${call.name}"` };
|
|
75
|
+
}
|
|
76
|
+
async function callTool(input) {
|
|
77
|
+
const { registry, config, workspaceRoot, work, call } = input;
|
|
78
|
+
const reject = async (code, reason) => {
|
|
79
|
+
await work.append({
|
|
80
|
+
type: "tool.rejected",
|
|
81
|
+
payload: { callId: call.id, name: call.name, code, reason },
|
|
82
|
+
});
|
|
83
|
+
return { content: [{ type: "text", text: reason }], isError: true };
|
|
84
|
+
};
|
|
85
|
+
const decision = authorize(registry, call);
|
|
86
|
+
if (!decision.ok)
|
|
87
|
+
return reject(decision.code, decision.reason);
|
|
88
|
+
const { tool } = decision;
|
|
89
|
+
const validation = tool.validate(call.input);
|
|
90
|
+
if (!validation.ok) {
|
|
91
|
+
return reject("schema_mismatch", `input does not match the schema of ${call.name}: ${validation.reason}`);
|
|
92
|
+
}
|
|
93
|
+
await work.append({
|
|
94
|
+
type: "tool.called",
|
|
95
|
+
payload: { callId: call.id, provider: tool.providerId, name: call.name, input: call.input },
|
|
96
|
+
});
|
|
97
|
+
const started = performance.now();
|
|
98
|
+
let result;
|
|
99
|
+
try {
|
|
100
|
+
result = await tool.provider.call(call, {
|
|
101
|
+
workId: work.id,
|
|
102
|
+
principalId: config.principal.id,
|
|
103
|
+
workspaceRoot,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (isOpenshainError(err) && isRejectionCode(err.code))
|
|
108
|
+
return reject(err.code, err.message);
|
|
109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
110
|
+
result = { content: [{ type: "text", text: message }], isError: true };
|
|
111
|
+
}
|
|
112
|
+
const durationMs = Math.max(0, Math.round(performance.now() - started));
|
|
113
|
+
result = { ...result, content: result.content.map(capContent) };
|
|
114
|
+
await work.append({
|
|
115
|
+
type: "tool.completed",
|
|
116
|
+
payload: {
|
|
117
|
+
callId: call.id,
|
|
118
|
+
content: result.content,
|
|
119
|
+
isError: result.isError ?? false,
|
|
120
|
+
...(result.observation && { observation: result.observation }),
|
|
121
|
+
...(result.after && { after: result.after }),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
await work.append({
|
|
125
|
+
type: "usage.recorded",
|
|
126
|
+
payload: { kind: "tool_execution", provider: tool.providerId, usage: { durationMs } },
|
|
127
|
+
});
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
function isRejectionCode(code) {
|
|
131
|
+
return TOOL_REJECTION_CODES.includes(code);
|
|
132
|
+
}
|
|
133
|
+
/** Cuts a content part down to MAX_TOOL_TEXT_CHARS and says so at the end. */
|
|
134
|
+
function capContent(part) {
|
|
135
|
+
const text = part.type === "text" ? part.text : JSON.stringify(part.value);
|
|
136
|
+
const chars = [...text];
|
|
137
|
+
if (chars.length <= MAX_TOOL_TEXT_CHARS)
|
|
138
|
+
return part;
|
|
139
|
+
const cut = chars.length - MAX_TOOL_TEXT_CHARS;
|
|
140
|
+
return {
|
|
141
|
+
type: "text",
|
|
142
|
+
text: `${chars.slice(0, MAX_TOOL_TEXT_CHARS).join("")}\n…[${cut} characters cut by the runtime]`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { JsonSchema } from "./tool/types.ts";
|
|
2
|
+
export type SchemaName = "config.v1" | "events.v1" | "work.v1";
|
|
3
|
+
/**
|
|
4
|
+
* The JSON Schemas (draft 2020-12) of the files openshain reads and writes, derived from the zod
|
|
5
|
+
* schemas that validate them. `spec/schemas/` holds this output; `bun run schemas` regenerates it.
|
|
6
|
+
* Conditions zod expresses as refinements, such as "provider or module, not both", have no JSON
|
|
7
|
+
* Schema form and are absent here.
|
|
8
|
+
*/
|
|
9
|
+
export declare function jsonSchemas(): Record<SchemaName, JsonSchema>;
|