@openshain/core 0.3.1 → 0.4.1
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/NOTICE +4 -0
- package/dist/authority/policy.d.ts +131 -0
- package/dist/authority/policy.js +335 -0
- package/dist/config/load.js +3 -40
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +16 -1
- package/dist/config/yaml.d.ts +12 -0
- package/dist/config/yaml.js +49 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/runtime.d.ts +26 -1
- package/dist/runtime.js +141 -6
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +3 -0
- package/dist/time.d.ts +14 -0
- package/dist/time.js +50 -0
- package/dist/tool/paths.d.ts +1 -1
- package/dist/tool/paths.js +1 -1
- package/dist/tool/types.js +4 -0
- package/dist/work/events.d.ts +132 -1
- package/dist/work/events.js +130 -0
- package/dist/work/history.d.ts +19 -0
- package/dist/work/history.js +11 -0
- package/dist/work/projection.js +18 -6
- package/package.json +3 -2
- package/src/authority/policy.ts +400 -0
- package/src/config/load.ts +3 -42
- package/src/config/schema.ts +17 -2
- package/src/config/yaml.ts +60 -0
- package/src/index.ts +31 -0
- package/src/runtime.ts +177 -8
- package/src/schemas.ts +17 -1
- package/src/time.ts +54 -0
- package/src/tool/paths.ts +1 -1
- package/src/tool/types.ts +4 -0
- package/src/work/events.ts +172 -0
- package/src/work/history.ts +25 -0
- package/src/work/projection.ts +18 -6
package/src/config/load.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { isNode, LineCounter, parseDocument } from "yaml";
|
|
4
3
|
import { OpenshainError } from "../errors.ts";
|
|
5
4
|
import { type Config, ConfigFileSchema, toConfig } from "./schema.ts";
|
|
5
|
+
import { parseYamlFile } from "./yaml.ts";
|
|
6
6
|
|
|
7
7
|
export const CONFIG_FILE_NAME = "openshain.yaml";
|
|
8
8
|
|
|
@@ -31,43 +31,8 @@ export async function loadConfig(
|
|
|
31
31
|
|
|
32
32
|
export function parseConfig(text: string, options: ParseConfigOptions = {}): Config {
|
|
33
33
|
const fileName = options.fileName ?? CONFIG_FILE_NAME;
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
let data: unknown;
|
|
37
|
-
try {
|
|
38
|
-
doc = parseDocument(text, { lineCounter });
|
|
39
|
-
data = doc.errors.length > 0 ? undefined : doc.toJS();
|
|
40
|
-
} catch (cause) {
|
|
41
|
-
// yaml refuses resource-exhaustion documents (alias bombs) with a plain error
|
|
42
|
-
throw new OpenshainError("config", `${fileName}: ${(cause as Error).message}`, { cause });
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (doc.errors.length > 0) {
|
|
46
|
-
const lines = doc.errors.map((error) => {
|
|
47
|
-
const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
|
|
48
|
-
return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
|
|
49
|
-
});
|
|
50
|
-
throw new OpenshainError("config", lines.join("\n"));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const locate = (path: readonly PropertyKey[]): { line: number; col: number } => {
|
|
54
|
-
for (let i = path.length; i >= 0; i--) {
|
|
55
|
-
const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
|
|
56
|
-
if (isNode(node) && node.range) return lineCounter.linePos(node.range[0]);
|
|
57
|
-
}
|
|
58
|
-
return { line: 1, col: 1 };
|
|
59
|
-
};
|
|
60
|
-
const problem = (path: readonly PropertyKey[], message: string): string => {
|
|
61
|
-
const { line, col } = locate(path);
|
|
62
|
-
const where = path.length === 0 ? "<root>" : path.map(String).join(".");
|
|
63
|
-
return `${fileName}:${line}:${col} ${where}: ${message}`;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const result = ConfigFileSchema.safeParse(data);
|
|
67
|
-
if (!result.success) {
|
|
68
|
-
const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
|
|
69
|
-
throw new OpenshainError("config", problems.join("\n"));
|
|
70
|
-
}
|
|
34
|
+
const { data, problem } = parseYamlFile(text, ConfigFileSchema, fileName);
|
|
35
|
+
const result = { data };
|
|
71
36
|
|
|
72
37
|
const known = options.modelProviders;
|
|
73
38
|
if (known && result.data.model && !known.includes(result.data.model.provider)) {
|
|
@@ -82,7 +47,3 @@ export function parseConfig(text: string, options: ParseConfigOptions = {}): Con
|
|
|
82
47
|
|
|
83
48
|
return toConfig(result.data);
|
|
84
49
|
}
|
|
85
|
-
|
|
86
|
-
function firstLine(message: string): string {
|
|
87
|
-
return message.split("\n", 1)[0] ?? message;
|
|
88
|
-
}
|
package/src/config/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { hostTimezone, isTimezone } from "../time.ts";
|
|
2
3
|
|
|
3
4
|
const identifier = z
|
|
4
5
|
.string()
|
|
@@ -43,6 +44,16 @@ export const ConfigFileSchema = z.strictObject({
|
|
|
43
44
|
company: z.strictObject({
|
|
44
45
|
name: z.string().min(1).max(200),
|
|
45
46
|
language: z.enum(LANGUAGES).default("ja"),
|
|
47
|
+
// The company's own clock decides every business date, so a workspace answers the same
|
|
48
|
+
// whether it runs on a laptop in Tokyo or in a container set to UTC.
|
|
49
|
+
// No default in the schema: it would bake the machine that generated it into the published
|
|
50
|
+
// JSON Schema. The fallback is applied when the file is turned into a Config.
|
|
51
|
+
timezone: z
|
|
52
|
+
.string()
|
|
53
|
+
.min(1)
|
|
54
|
+
.max(100)
|
|
55
|
+
.refine(isTimezone, "not a timezone name, such as Asia/Tokyo")
|
|
56
|
+
.optional(),
|
|
46
57
|
}),
|
|
47
58
|
principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
|
|
48
59
|
profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
|
|
@@ -96,7 +107,7 @@ export interface ModelConfig {
|
|
|
96
107
|
|
|
97
108
|
export interface Config {
|
|
98
109
|
version: 1;
|
|
99
|
-
company: { name: string; language: Language };
|
|
110
|
+
company: { name: string; language: Language; timezone: string };
|
|
100
111
|
principal: { id: string; name: string };
|
|
101
112
|
profession: { id: string; instructions: string };
|
|
102
113
|
/** The model the interactive CLI runs on. Absent when the workspace is used from other agents only. */
|
|
@@ -109,7 +120,11 @@ export interface Config {
|
|
|
109
120
|
export function toConfig(file: ConfigFile): Config {
|
|
110
121
|
return {
|
|
111
122
|
version: file.version,
|
|
112
|
-
company: {
|
|
123
|
+
company: {
|
|
124
|
+
name: file.company.name,
|
|
125
|
+
language: file.company.language,
|
|
126
|
+
timezone: file.company.timezone ?? hostTimezone(),
|
|
127
|
+
},
|
|
113
128
|
principal: { id: file.principal.id, name: file.principal.name },
|
|
114
129
|
profession: { id: file.profession.id, instructions: file.profession.instructions },
|
|
115
130
|
...(file.model && {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isNode, LineCounter, parseDocument } from "yaml";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import { OpenshainError } from "../errors.ts";
|
|
4
|
+
|
|
5
|
+
/** Where a problem in a YAML file is, as `file:line:col path: message`. */
|
|
6
|
+
export type Problem = (path: readonly PropertyKey[], message: string) => string;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses a YAML file against a zod schema. Every problem is reported with its line and column
|
|
10
|
+
* and the path of the field, so that a person can fix the file. Returns the data together with
|
|
11
|
+
* `problem`, for checks the caller adds after parsing.
|
|
12
|
+
*/
|
|
13
|
+
export function parseYamlFile<T extends z.ZodType>(
|
|
14
|
+
text: string,
|
|
15
|
+
schema: T,
|
|
16
|
+
fileName: string,
|
|
17
|
+
): { data: z.output<T>; problem: Problem } {
|
|
18
|
+
const lineCounter = new LineCounter();
|
|
19
|
+
let doc: ReturnType<typeof parseDocument>;
|
|
20
|
+
let data: unknown;
|
|
21
|
+
try {
|
|
22
|
+
doc = parseDocument(text, { lineCounter });
|
|
23
|
+
data = doc.errors.length > 0 ? undefined : doc.toJS();
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
// yaml refuses resource-exhaustion documents (alias bombs) with a plain error
|
|
26
|
+
throw new OpenshainError("config", `${fileName}: ${(cause as Error).message}`, { cause });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (doc.errors.length > 0) {
|
|
30
|
+
const lines = doc.errors.map((error) => {
|
|
31
|
+
const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
|
|
32
|
+
return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
|
|
33
|
+
});
|
|
34
|
+
throw new OpenshainError("config", lines.join("\n"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const locate = (path: readonly PropertyKey[]): { line: number; col: number } => {
|
|
38
|
+
for (let i = path.length; i >= 0; i--) {
|
|
39
|
+
const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
|
|
40
|
+
if (isNode(node) && node.range) return lineCounter.linePos(node.range[0]);
|
|
41
|
+
}
|
|
42
|
+
return { line: 1, col: 1 };
|
|
43
|
+
};
|
|
44
|
+
const problem: Problem = (path, message) => {
|
|
45
|
+
const { line, col } = locate(path);
|
|
46
|
+
const where = path.length === 0 ? "<root>" : path.map(String).join(".");
|
|
47
|
+
return `${fileName}:${line}:${col} ${where}: ${message}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = schema.safeParse(data);
|
|
51
|
+
if (!result.success) {
|
|
52
|
+
const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
|
|
53
|
+
throw new OpenshainError("config", problems.join("\n"));
|
|
54
|
+
}
|
|
55
|
+
return { data: result.data, problem };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function firstLine(message: string): string {
|
|
59
|
+
return message.split("\n", 1)[0] ?? message;
|
|
60
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
// @openshain/core: Contracts (provider interfaces), fundamental objects, and the work runtime
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
AUTHORITY_DIR_NAME,
|
|
5
|
+
type Authority,
|
|
6
|
+
type AuthorityRequest,
|
|
7
|
+
DECISION_KINDS,
|
|
8
|
+
DECISIONS_DIR_NAME,
|
|
9
|
+
DELEGATIONS_FILE_NAME,
|
|
10
|
+
type Decision,
|
|
11
|
+
DecisionFileSchema,
|
|
12
|
+
type DecisionKind,
|
|
13
|
+
type DecisionRecord,
|
|
14
|
+
type Delegation,
|
|
15
|
+
DelegationsFileSchema,
|
|
16
|
+
evaluate,
|
|
17
|
+
loadAuthority,
|
|
18
|
+
matchGlob,
|
|
19
|
+
OPEN_AUTHORITY,
|
|
20
|
+
POLICY_FILE_NAME,
|
|
21
|
+
type PolicyFile,
|
|
22
|
+
PolicyFileSchema,
|
|
23
|
+
type Rule,
|
|
24
|
+
writeDecision,
|
|
25
|
+
} from "./authority/policy.ts";
|
|
2
26
|
export {
|
|
3
27
|
CONFIG_FILE_NAME,
|
|
4
28
|
loadConfig,
|
|
@@ -25,16 +49,20 @@ export type {
|
|
|
25
49
|
UserPart,
|
|
26
50
|
} from "./model/types.ts";
|
|
27
51
|
export {
|
|
52
|
+
type CallOptions,
|
|
28
53
|
type CreateRuntimeOptions,
|
|
29
54
|
createRuntime,
|
|
30
55
|
createToolCaller,
|
|
31
56
|
createToolRegistry,
|
|
32
57
|
MAX_TOOL_TEXT_CHARS,
|
|
58
|
+
type PendingApprovalResult,
|
|
59
|
+
REVIEW_DIR_NAME,
|
|
33
60
|
type Runtime,
|
|
34
61
|
type RuntimeProviders,
|
|
35
62
|
type ToolSummary,
|
|
36
63
|
} from "./runtime.ts";
|
|
37
64
|
export { jsonSchemas, type SchemaName } from "./schemas.ts";
|
|
65
|
+
export { businessDate, companyTime, hostTimezone, isTimezone } from "./time.ts";
|
|
38
66
|
export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.ts";
|
|
39
67
|
export { loadToolModule } from "./tool/load-module.ts";
|
|
40
68
|
export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
|
|
@@ -76,6 +104,7 @@ export {
|
|
|
76
104
|
type ModelUsage,
|
|
77
105
|
parsePayloadFile,
|
|
78
106
|
payloadFileSchemas,
|
|
107
|
+
type ReviewPackage,
|
|
79
108
|
type StopReason,
|
|
80
109
|
TOOL_REJECTION_CODES,
|
|
81
110
|
type ToolContent,
|
|
@@ -86,7 +115,9 @@ export {
|
|
|
86
115
|
countToolCalls,
|
|
87
116
|
type FailureReason,
|
|
88
117
|
type HistoryCall,
|
|
118
|
+
type PendingApproval,
|
|
89
119
|
type PendingQuestion,
|
|
120
|
+
pendingApprovals,
|
|
90
121
|
pendingQuestions,
|
|
91
122
|
type WorkHistory,
|
|
92
123
|
workHistory,
|
package/src/runtime.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type Authority,
|
|
5
|
+
evaluate,
|
|
6
|
+
loadAuthority,
|
|
7
|
+
OPEN_AUTHORITY,
|
|
8
|
+
type Rule,
|
|
9
|
+
} from "./authority/policy.ts";
|
|
1
10
|
import { loadConfig } from "./config/load.ts";
|
|
2
11
|
import type { Config, ModelConfig } from "./config/schema.ts";
|
|
3
12
|
import { isOpenshainError, OpenshainError } from "./errors.ts";
|
|
4
13
|
import type { ModelProvider } from "./model/types.ts";
|
|
14
|
+
import { businessDate } from "./time.ts";
|
|
5
15
|
import { loadToolModule } from "./tool/load-module.ts";
|
|
6
16
|
import type { HiddenTool } from "./tool/registry.ts";
|
|
7
17
|
import { type RegisteredTool, ToolRegistry } from "./tool/registry.ts";
|
|
8
18
|
import type { ToolCall, ToolDefinition, ToolProvider, ToolResult } from "./tool/types.ts";
|
|
9
|
-
import
|
|
19
|
+
import { uuidv7 } from "./uuid.ts";
|
|
20
|
+
import type { Event, ReviewPackage, ToolContent } from "./work/events.ts";
|
|
10
21
|
import { TOOL_REJECTION_CODES, type ToolRejectionCode } from "./work/events.ts";
|
|
11
|
-
import { type WorkHandle, WorkStore } from "./work/store.ts";
|
|
22
|
+
import { WORK_DIR_NAME, type WorkHandle, WorkStore } from "./work/store.ts";
|
|
12
23
|
|
|
13
24
|
export interface RuntimeProviders {
|
|
14
25
|
/** Model providers by the id used in openshain.yaml. */
|
|
@@ -31,6 +42,9 @@ export interface ToolSummary {
|
|
|
31
42
|
/** Longer tool output is cut here so that one tool cannot flood the model's context. */
|
|
32
43
|
export const MAX_TOOL_TEXT_CHARS = 50_000;
|
|
33
44
|
|
|
45
|
+
/** Where a work keeps the review packages a person sends to a reviewer. */
|
|
46
|
+
export const REVIEW_DIR_NAME = "review";
|
|
47
|
+
|
|
34
48
|
export interface Runtime {
|
|
35
49
|
readonly workspaceRoot: string;
|
|
36
50
|
readonly config: Config;
|
|
@@ -72,6 +86,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
|
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
const registry = await createToolRegistry(workspaceRoot, config, providers.tools);
|
|
89
|
+
const authority = await loadAuthority(workspaceRoot);
|
|
75
90
|
const works = new WorkStore(workspaceRoot);
|
|
76
91
|
return {
|
|
77
92
|
workspaceRoot,
|
|
@@ -81,7 +96,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
|
|
|
81
96
|
tools: {
|
|
82
97
|
list: () => registry.list().map(({ definition, providerId }) => ({ definition, providerId })),
|
|
83
98
|
hidden: () => registry.hiddenTools(),
|
|
84
|
-
call: createToolCaller({ registry, config, workspaceRoot }),
|
|
99
|
+
call: createToolCaller({ registry, config, workspaceRoot, authority }),
|
|
85
100
|
},
|
|
86
101
|
};
|
|
87
102
|
}
|
|
@@ -91,8 +106,32 @@ export function createToolCaller(input: {
|
|
|
91
106
|
registry: ToolRegistry;
|
|
92
107
|
config: Config;
|
|
93
108
|
workspaceRoot: string;
|
|
94
|
-
|
|
95
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Who may do what. Pass a function when it can change while the server runs, as it does when
|
|
111
|
+
* a reviewer writes a decision. Omitted: the workspace is open, as one without authority/ is.
|
|
112
|
+
*/
|
|
113
|
+
authority?: Authority | (() => Authority);
|
|
114
|
+
}): (work: WorkHandle, call: ToolCall, options?: CallOptions) => Promise<ToolResult> {
|
|
115
|
+
const given = input.authority;
|
|
116
|
+
const current = typeof given === "function" ? given : () => given ?? OPEN_AUTHORITY;
|
|
117
|
+
return (work, call, options) =>
|
|
118
|
+
callTool({ ...input, authority: current(), work, call, ...options });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CallOptions {
|
|
122
|
+
/** The approval that lets this call run: the policy is not consulted again. */
|
|
123
|
+
approvedBy?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What a held call answers with: the client shows it to the person and the turn stops there. */
|
|
127
|
+
export interface PendingApprovalResult {
|
|
128
|
+
pending: "approval" | "review";
|
|
129
|
+
approval_id: string;
|
|
130
|
+
rule_id: string;
|
|
131
|
+
approvers?: string[];
|
|
132
|
+
reviewer?: { role: string; name?: string };
|
|
133
|
+
/** For a review: why the policy asks for one, when a cited decision did not cover the call. */
|
|
134
|
+
why?: string;
|
|
96
135
|
}
|
|
97
136
|
|
|
98
137
|
/** Registers the tool providers the config names: the caller's factories by id, and modules from the workspace. Needs no model. */
|
|
@@ -121,8 +160,8 @@ export async function createToolRegistry(
|
|
|
121
160
|
}
|
|
122
161
|
|
|
123
162
|
/**
|
|
124
|
-
* The one place that allows or refuses a call before it runs
|
|
125
|
-
*
|
|
163
|
+
* The one place that allows or refuses a call before it runs by name alone. What the workspace
|
|
164
|
+
* allows a call to do is decided after this, by the policy in `authority/`.
|
|
126
165
|
*/
|
|
127
166
|
function authorize(
|
|
128
167
|
registry: ToolRegistry,
|
|
@@ -143,10 +182,12 @@ async function callTool(input: {
|
|
|
143
182
|
registry: ToolRegistry;
|
|
144
183
|
config: Config;
|
|
145
184
|
workspaceRoot: string;
|
|
185
|
+
authority: Authority;
|
|
146
186
|
work: WorkHandle;
|
|
147
187
|
call: ToolCall;
|
|
188
|
+
approvedBy?: string;
|
|
148
189
|
}): Promise<ToolResult> {
|
|
149
|
-
const { registry, config, workspaceRoot, work, call } = input;
|
|
190
|
+
const { registry, config, workspaceRoot, authority, work, call } = input;
|
|
150
191
|
const reject = async (code: ToolRejectionCode, reason: string): Promise<ToolResult> => {
|
|
151
192
|
await work.append({
|
|
152
193
|
type: "tool.rejected",
|
|
@@ -165,6 +206,72 @@ async function callTool(input: {
|
|
|
165
206
|
`input does not match the schema of ${call.name}: ${validation.reason}`,
|
|
166
207
|
);
|
|
167
208
|
}
|
|
209
|
+
// The policy judges after the allow list, unless a person already approved this very call.
|
|
210
|
+
if (input.approvedBy === undefined) {
|
|
211
|
+
const path = pathOf(call.input);
|
|
212
|
+
const judged = evaluate(authority, {
|
|
213
|
+
tool: call.name,
|
|
214
|
+
effect: tool.definition.effect,
|
|
215
|
+
...(path !== undefined && { path }),
|
|
216
|
+
principal: config.principal.id,
|
|
217
|
+
profession: config.profession.id,
|
|
218
|
+
workType: (await work.current()).type,
|
|
219
|
+
businessDate: businessDate(config.company.timezone),
|
|
220
|
+
});
|
|
221
|
+
if (judged.kind === "deny") return reject("denied", judged.reason);
|
|
222
|
+
if (judged.kind === "approval_required" || judged.kind === "review_required") {
|
|
223
|
+
const review = judged.kind === "review_required";
|
|
224
|
+
const approvalId = `apr_${uuidv7()}`;
|
|
225
|
+
const approvers = judged.rule.approvers ?? [config.principal.id];
|
|
226
|
+
const declared = judged.rule.reviewer;
|
|
227
|
+
const reviewer = declared
|
|
228
|
+
? { role: declared.role, ...(declared.name !== undefined && { name: declared.name }) }
|
|
229
|
+
: undefined;
|
|
230
|
+
await work.append({
|
|
231
|
+
type: "approval.requested",
|
|
232
|
+
payload: {
|
|
233
|
+
approvalId,
|
|
234
|
+
call: { callId: call.id, name: call.name, input: call.input },
|
|
235
|
+
ruleId: judged.rule.id,
|
|
236
|
+
kind: review ? "review" : "approval",
|
|
237
|
+
...(review ? reviewer && { reviewer } : { approvers }),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
if (review) {
|
|
241
|
+
const built = await reviewPackage(work, {
|
|
242
|
+
approvalId,
|
|
243
|
+
call,
|
|
244
|
+
rule: judged.rule,
|
|
245
|
+
principal: config.principal.id,
|
|
246
|
+
});
|
|
247
|
+
await work.append({ type: "review.requested", payload: { approvalId, package: built } });
|
|
248
|
+
// A copy the person can send to the reviewer, next to the work's own record.
|
|
249
|
+
const dir = join(workspaceRoot, WORK_DIR_NAME, work.id, REVIEW_DIR_NAME);
|
|
250
|
+
await mkdir(dir, { recursive: true });
|
|
251
|
+
await writeFile(join(dir, `${approvalId}.json`), `${JSON.stringify(built, null, 2)}\n`, {
|
|
252
|
+
flag: "wx",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
await work.transition(
|
|
256
|
+
"waiting_approval",
|
|
257
|
+
`rule ${judged.rule.id} needs ${review ? "a review" : "approval"}`,
|
|
258
|
+
);
|
|
259
|
+
const held: PendingApprovalResult = {
|
|
260
|
+
pending: review ? "review" : "approval",
|
|
261
|
+
approval_id: approvalId,
|
|
262
|
+
rule_id: judged.rule.id,
|
|
263
|
+
...(review ? reviewer && { reviewer } : { approvers }),
|
|
264
|
+
...(review && judged.why !== undefined && { why: judged.why }),
|
|
265
|
+
};
|
|
266
|
+
return { content: [{ type: "json", value: held }] };
|
|
267
|
+
}
|
|
268
|
+
if (judged.kind === "allow" && judged.decision) {
|
|
269
|
+
await work.append({
|
|
270
|
+
type: "decision.applied",
|
|
271
|
+
payload: { callId: call.id, decisionId: judged.decision.id },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
168
275
|
|
|
169
276
|
await work.append({
|
|
170
277
|
type: "tool.called",
|
|
@@ -203,6 +310,68 @@ async function callTool(input: {
|
|
|
203
310
|
return result;
|
|
204
311
|
}
|
|
205
312
|
|
|
313
|
+
/**
|
|
314
|
+
* What the reviewer is asked to decide on, from the work's own record: the call, the tool calls
|
|
315
|
+
* that came before it, and the agent's last words as the proposal. Sources and company rules stay
|
|
316
|
+
* empty until knowledge is in.
|
|
317
|
+
*/
|
|
318
|
+
async function reviewPackage(
|
|
319
|
+
work: WorkHandle,
|
|
320
|
+
input: { approvalId: string; call: ToolCall; rule: Rule; principal: string },
|
|
321
|
+
): Promise<ReviewPackage> {
|
|
322
|
+
const events = await work.events();
|
|
323
|
+
const facts: string[] = [];
|
|
324
|
+
let proposal = "";
|
|
325
|
+
for (const event of events) {
|
|
326
|
+
if (event.type === "tool.called") {
|
|
327
|
+
const { name, input: called } = (event as Event<"tool.called">).payload;
|
|
328
|
+
const path = pathOf(called);
|
|
329
|
+
facts.push(path === undefined ? name : `${name} ${path}`);
|
|
330
|
+
} else if (event.type === "model.completed") {
|
|
331
|
+
const text = (event as Event<"model.completed">).payload.content
|
|
332
|
+
.filter((part) => part.type === "text")
|
|
333
|
+
.map((part) => (part as { text: string }).text)
|
|
334
|
+
.join("\n")
|
|
335
|
+
.trim();
|
|
336
|
+
if (text !== "") proposal = text;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const current = await work.current();
|
|
340
|
+
return {
|
|
341
|
+
approvalId: input.approvalId,
|
|
342
|
+
workId: work.id,
|
|
343
|
+
action: {
|
|
344
|
+
name: input.rule.match.action?.toString() ?? input.call.name,
|
|
345
|
+
tool: input.call.name,
|
|
346
|
+
input: input.call.input,
|
|
347
|
+
},
|
|
348
|
+
facts,
|
|
349
|
+
sources: [],
|
|
350
|
+
companyRules: [],
|
|
351
|
+
proposal,
|
|
352
|
+
question:
|
|
353
|
+
input.rule.reason ??
|
|
354
|
+
`${current.objective} のために ${input.call.name} を実行してよいか、判断をお願いします。`,
|
|
355
|
+
requestedBy: input.principal,
|
|
356
|
+
requestedAt: new Date().toISOString(),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The path a call names, normalized to a workspace-relative posix path, when its input has one. */
|
|
361
|
+
function pathOf(input: unknown): string | undefined {
|
|
362
|
+
const path = (input as { path?: unknown } | null)?.path;
|
|
363
|
+
if (typeof path !== "string" || path === "") return undefined;
|
|
364
|
+
const segments: string[] = [];
|
|
365
|
+
for (const segment of path.replaceAll("\\", "/").split("/")) {
|
|
366
|
+
if (segment === "" || segment === ".") continue;
|
|
367
|
+
if (segment === "..") segments.pop();
|
|
368
|
+
else segments.push(segment);
|
|
369
|
+
}
|
|
370
|
+
return segments.join("/");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Today's date on this machine's clock, YYYY-MM-DD. */
|
|
374
|
+
|
|
206
375
|
function isRejectionCode(code: string): code is ToolRejectionCode {
|
|
207
376
|
return (TOOL_REJECTION_CODES as readonly string[]).includes(code);
|
|
208
377
|
}
|
package/src/schemas.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { DelegationsFileSchema, PolicyFileSchema } from "./authority/policy.ts";
|
|
2
3
|
import { ConfigFileSchema } from "./config/schema.ts";
|
|
3
4
|
import type { JsonSchema } from "./tool/types.ts";
|
|
4
5
|
import { EventFileSchema, payloadFileSchemas } from "./work/events.ts";
|
|
5
6
|
import { WorkFileSchema } from "./work/work.ts";
|
|
6
7
|
|
|
7
|
-
export type SchemaName =
|
|
8
|
+
export type SchemaName =
|
|
9
|
+
| "config.v1"
|
|
10
|
+
| "events.v1"
|
|
11
|
+
| "work.v1"
|
|
12
|
+
| "authority-policy.v1"
|
|
13
|
+
| "authority-delegations.v1";
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* The JSON Schemas (draft 2020-12) of the files openshain reads and writes, derived from the zod
|
|
@@ -25,6 +31,16 @@ export function jsonSchemas(): Record<SchemaName, JsonSchema> {
|
|
|
25
31
|
"work.json",
|
|
26
32
|
"The state of a work as projected from its event log. Never the source of truth.",
|
|
27
33
|
),
|
|
34
|
+
"authority-policy.v1": describe(
|
|
35
|
+
PolicyFileSchema,
|
|
36
|
+
"authority/policy.yaml",
|
|
37
|
+
"The rules that judge tool calls: the first matching rule decides, else the default.",
|
|
38
|
+
),
|
|
39
|
+
"authority-delegations.v1": describe(
|
|
40
|
+
DelegationsFileSchema,
|
|
41
|
+
"authority/delegations.yaml",
|
|
42
|
+
"Who the agent may act for, as which profession, and when.",
|
|
43
|
+
),
|
|
28
44
|
};
|
|
29
45
|
}
|
|
30
46
|
|
package/src/time.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The company's clock. Every date the runtime judges against — a decision's effective days, a
|
|
3
|
+
* delegation's validity — is a date in the company's timezone, not in the host's. A workspace
|
|
4
|
+
* carried between a laptop in Tokyo and a container in UTC has to answer the same question the
|
|
5
|
+
* same way, so the timezone is part of the configuration rather than the environment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** True when the name is a timezone this runtime knows (`Asia/Tokyo`, `UTC`, ...). */
|
|
9
|
+
export function isTimezone(name: string): boolean {
|
|
10
|
+
try {
|
|
11
|
+
new Intl.DateTimeFormat("en-US", { timeZone: name });
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The timezone of the machine, used when the configuration names none. */
|
|
19
|
+
export function hostTimezone(): string {
|
|
20
|
+
const name = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
21
|
+
return name && isTimezone(name) ? name : "UTC";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The business date (`YYYY-MM-DD`) in the company's timezone. */
|
|
25
|
+
export function businessDate(timezone: string, at: Date = new Date()): string {
|
|
26
|
+
// en-CA writes a date as YYYY-MM-DD.
|
|
27
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
28
|
+
timeZone: timezone,
|
|
29
|
+
year: "numeric",
|
|
30
|
+
month: "2-digit",
|
|
31
|
+
day: "2-digit",
|
|
32
|
+
}).format(at);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The moment as the company reads it: `2026-09-09T14:03:11+09:00`. */
|
|
36
|
+
export function companyTime(timezone: string, at: Date = new Date()): string {
|
|
37
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
38
|
+
timeZone: timezone,
|
|
39
|
+
hour12: false,
|
|
40
|
+
year: "numeric",
|
|
41
|
+
month: "2-digit",
|
|
42
|
+
day: "2-digit",
|
|
43
|
+
hour: "2-digit",
|
|
44
|
+
minute: "2-digit",
|
|
45
|
+
second: "2-digit",
|
|
46
|
+
timeZoneName: "longOffset",
|
|
47
|
+
}).formatToParts(at);
|
|
48
|
+
const of = (type: Intl.DateTimeFormatPartTypes) =>
|
|
49
|
+
parts.find((part) => part.type === type)?.value ?? "";
|
|
50
|
+
// "GMT+09:00" for a zone with an offset, "GMT" for UTC itself.
|
|
51
|
+
const zone = of("timeZoneName").replace("GMT", "");
|
|
52
|
+
const hour = of("hour") === "24" ? "00" : of("hour");
|
|
53
|
+
return `${of("year")}-${of("month")}-${of("day")}T${hour}:${of("minute")}:${of("second")}${zone === "" ? "+00:00" : zone}`;
|
|
54
|
+
}
|
package/src/tool/paths.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "no
|
|
|
3
3
|
import { OpenshainError } from "../errors.ts";
|
|
4
4
|
|
|
5
5
|
/** Paths the runtime keeps for itself. Tools may not read or write them. */
|
|
6
|
-
export const RESERVED_PATHS = ["openshain.yaml", "work"] as const;
|
|
6
|
+
export const RESERVED_PATHS = ["openshain.yaml", "work", "principals", "authority"] as const;
|
|
7
7
|
|
|
8
8
|
const MAX_SYMLINK_HOPS = 32;
|
|
9
9
|
|