@openshain/core 0.3.1 → 0.4.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/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/yaml.d.ts +12 -0
- package/dist/config/yaml.js +49 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/runtime.d.ts +26 -1
- package/dist/runtime.js +145 -6
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +3 -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/yaml.ts +60 -0
- package/src/index.ts +30 -0
- package/src/runtime.ts +181 -8
- package/src/schemas.ts +17 -1
- 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/runtime.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
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";
|
|
@@ -6,9 +15,10 @@ import { loadToolModule } from "./tool/load-module.ts";
|
|
|
6
15
|
import type { HiddenTool } from "./tool/registry.ts";
|
|
7
16
|
import { type RegisteredTool, ToolRegistry } from "./tool/registry.ts";
|
|
8
17
|
import type { ToolCall, ToolDefinition, ToolProvider, ToolResult } from "./tool/types.ts";
|
|
9
|
-
import
|
|
18
|
+
import { uuidv7 } from "./uuid.ts";
|
|
19
|
+
import type { Event, ReviewPackage, ToolContent } from "./work/events.ts";
|
|
10
20
|
import { TOOL_REJECTION_CODES, type ToolRejectionCode } from "./work/events.ts";
|
|
11
|
-
import { type WorkHandle, WorkStore } from "./work/store.ts";
|
|
21
|
+
import { WORK_DIR_NAME, type WorkHandle, WorkStore } from "./work/store.ts";
|
|
12
22
|
|
|
13
23
|
export interface RuntimeProviders {
|
|
14
24
|
/** Model providers by the id used in openshain.yaml. */
|
|
@@ -31,6 +41,9 @@ export interface ToolSummary {
|
|
|
31
41
|
/** Longer tool output is cut here so that one tool cannot flood the model's context. */
|
|
32
42
|
export const MAX_TOOL_TEXT_CHARS = 50_000;
|
|
33
43
|
|
|
44
|
+
/** Where a work keeps the review packages a person sends to a reviewer. */
|
|
45
|
+
export const REVIEW_DIR_NAME = "review";
|
|
46
|
+
|
|
34
47
|
export interface Runtime {
|
|
35
48
|
readonly workspaceRoot: string;
|
|
36
49
|
readonly config: Config;
|
|
@@ -72,6 +85,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
|
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
const registry = await createToolRegistry(workspaceRoot, config, providers.tools);
|
|
88
|
+
const authority = await loadAuthority(workspaceRoot);
|
|
75
89
|
const works = new WorkStore(workspaceRoot);
|
|
76
90
|
return {
|
|
77
91
|
workspaceRoot,
|
|
@@ -81,7 +95,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
|
|
|
81
95
|
tools: {
|
|
82
96
|
list: () => registry.list().map(({ definition, providerId }) => ({ definition, providerId })),
|
|
83
97
|
hidden: () => registry.hiddenTools(),
|
|
84
|
-
call: createToolCaller({ registry, config, workspaceRoot }),
|
|
98
|
+
call: createToolCaller({ registry, config, workspaceRoot, authority }),
|
|
85
99
|
},
|
|
86
100
|
};
|
|
87
101
|
}
|
|
@@ -91,8 +105,32 @@ export function createToolCaller(input: {
|
|
|
91
105
|
registry: ToolRegistry;
|
|
92
106
|
config: Config;
|
|
93
107
|
workspaceRoot: string;
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Who may do what. Pass a function when it can change while the server runs, as it does when
|
|
110
|
+
* a reviewer writes a decision. Omitted: the workspace is open, as one without authority/ is.
|
|
111
|
+
*/
|
|
112
|
+
authority?: Authority | (() => Authority);
|
|
113
|
+
}): (work: WorkHandle, call: ToolCall, options?: CallOptions) => Promise<ToolResult> {
|
|
114
|
+
const given = input.authority;
|
|
115
|
+
const current = typeof given === "function" ? given : () => given ?? OPEN_AUTHORITY;
|
|
116
|
+
return (work, call, options) =>
|
|
117
|
+
callTool({ ...input, authority: current(), work, call, ...options });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface CallOptions {
|
|
121
|
+
/** The approval that lets this call run: the policy is not consulted again. */
|
|
122
|
+
approvedBy?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What a held call answers with: the client shows it to the person and the turn stops there. */
|
|
126
|
+
export interface PendingApprovalResult {
|
|
127
|
+
pending: "approval" | "review";
|
|
128
|
+
approval_id: string;
|
|
129
|
+
rule_id: string;
|
|
130
|
+
approvers?: string[];
|
|
131
|
+
reviewer?: { role: string; name?: string };
|
|
132
|
+
/** For a review: why the policy asks for one, when a cited decision did not cover the call. */
|
|
133
|
+
why?: string;
|
|
96
134
|
}
|
|
97
135
|
|
|
98
136
|
/** Registers the tool providers the config names: the caller's factories by id, and modules from the workspace. Needs no model. */
|
|
@@ -121,8 +159,8 @@ export async function createToolRegistry(
|
|
|
121
159
|
}
|
|
122
160
|
|
|
123
161
|
/**
|
|
124
|
-
* The one place that allows or refuses a call before it runs
|
|
125
|
-
*
|
|
162
|
+
* The one place that allows or refuses a call before it runs by name alone. What the workspace
|
|
163
|
+
* allows a call to do is decided after this, by the policy in `authority/`.
|
|
126
164
|
*/
|
|
127
165
|
function authorize(
|
|
128
166
|
registry: ToolRegistry,
|
|
@@ -143,10 +181,12 @@ async function callTool(input: {
|
|
|
143
181
|
registry: ToolRegistry;
|
|
144
182
|
config: Config;
|
|
145
183
|
workspaceRoot: string;
|
|
184
|
+
authority: Authority;
|
|
146
185
|
work: WorkHandle;
|
|
147
186
|
call: ToolCall;
|
|
187
|
+
approvedBy?: string;
|
|
148
188
|
}): Promise<ToolResult> {
|
|
149
|
-
const { registry, config, workspaceRoot, work, call } = input;
|
|
189
|
+
const { registry, config, workspaceRoot, authority, work, call } = input;
|
|
150
190
|
const reject = async (code: ToolRejectionCode, reason: string): Promise<ToolResult> => {
|
|
151
191
|
await work.append({
|
|
152
192
|
type: "tool.rejected",
|
|
@@ -165,6 +205,72 @@ async function callTool(input: {
|
|
|
165
205
|
`input does not match the schema of ${call.name}: ${validation.reason}`,
|
|
166
206
|
);
|
|
167
207
|
}
|
|
208
|
+
// The policy judges after the allow list, unless a person already approved this very call.
|
|
209
|
+
if (input.approvedBy === undefined) {
|
|
210
|
+
const path = pathOf(call.input);
|
|
211
|
+
const judged = evaluate(authority, {
|
|
212
|
+
tool: call.name,
|
|
213
|
+
effect: tool.definition.effect,
|
|
214
|
+
...(path !== undefined && { path }),
|
|
215
|
+
principal: config.principal.id,
|
|
216
|
+
profession: config.profession.id,
|
|
217
|
+
workType: (await work.current()).type,
|
|
218
|
+
businessDate: businessDate(),
|
|
219
|
+
});
|
|
220
|
+
if (judged.kind === "deny") return reject("denied", judged.reason);
|
|
221
|
+
if (judged.kind === "approval_required" || judged.kind === "review_required") {
|
|
222
|
+
const review = judged.kind === "review_required";
|
|
223
|
+
const approvalId = `apr_${uuidv7()}`;
|
|
224
|
+
const approvers = judged.rule.approvers ?? [config.principal.id];
|
|
225
|
+
const declared = judged.rule.reviewer;
|
|
226
|
+
const reviewer = declared
|
|
227
|
+
? { role: declared.role, ...(declared.name !== undefined && { name: declared.name }) }
|
|
228
|
+
: undefined;
|
|
229
|
+
await work.append({
|
|
230
|
+
type: "approval.requested",
|
|
231
|
+
payload: {
|
|
232
|
+
approvalId,
|
|
233
|
+
call: { callId: call.id, name: call.name, input: call.input },
|
|
234
|
+
ruleId: judged.rule.id,
|
|
235
|
+
kind: review ? "review" : "approval",
|
|
236
|
+
...(review ? reviewer && { reviewer } : { approvers }),
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
if (review) {
|
|
240
|
+
const built = await reviewPackage(work, {
|
|
241
|
+
approvalId,
|
|
242
|
+
call,
|
|
243
|
+
rule: judged.rule,
|
|
244
|
+
principal: config.principal.id,
|
|
245
|
+
});
|
|
246
|
+
await work.append({ type: "review.requested", payload: { approvalId, package: built } });
|
|
247
|
+
// A copy the person can send to the reviewer, next to the work's own record.
|
|
248
|
+
const dir = join(workspaceRoot, WORK_DIR_NAME, work.id, REVIEW_DIR_NAME);
|
|
249
|
+
await mkdir(dir, { recursive: true });
|
|
250
|
+
await writeFile(join(dir, `${approvalId}.json`), `${JSON.stringify(built, null, 2)}\n`, {
|
|
251
|
+
flag: "wx",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
await work.transition(
|
|
255
|
+
"waiting_approval",
|
|
256
|
+
`rule ${judged.rule.id} needs ${review ? "a review" : "approval"}`,
|
|
257
|
+
);
|
|
258
|
+
const held: PendingApprovalResult = {
|
|
259
|
+
pending: review ? "review" : "approval",
|
|
260
|
+
approval_id: approvalId,
|
|
261
|
+
rule_id: judged.rule.id,
|
|
262
|
+
...(review ? reviewer && { reviewer } : { approvers }),
|
|
263
|
+
...(review && judged.why !== undefined && { why: judged.why }),
|
|
264
|
+
};
|
|
265
|
+
return { content: [{ type: "json", value: held }] };
|
|
266
|
+
}
|
|
267
|
+
if (judged.kind === "allow" && judged.decision) {
|
|
268
|
+
await work.append({
|
|
269
|
+
type: "decision.applied",
|
|
270
|
+
payload: { callId: call.id, decisionId: judged.decision.id },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
168
274
|
|
|
169
275
|
await work.append({
|
|
170
276
|
type: "tool.called",
|
|
@@ -203,6 +309,73 @@ async function callTool(input: {
|
|
|
203
309
|
return result;
|
|
204
310
|
}
|
|
205
311
|
|
|
312
|
+
/**
|
|
313
|
+
* What the reviewer is asked to decide on, from the work's own record: the call, the tool calls
|
|
314
|
+
* that came before it, and the agent's last words as the proposal. Sources and company rules stay
|
|
315
|
+
* empty until knowledge is in.
|
|
316
|
+
*/
|
|
317
|
+
async function reviewPackage(
|
|
318
|
+
work: WorkHandle,
|
|
319
|
+
input: { approvalId: string; call: ToolCall; rule: Rule; principal: string },
|
|
320
|
+
): Promise<ReviewPackage> {
|
|
321
|
+
const events = await work.events();
|
|
322
|
+
const facts: string[] = [];
|
|
323
|
+
let proposal = "";
|
|
324
|
+
for (const event of events) {
|
|
325
|
+
if (event.type === "tool.called") {
|
|
326
|
+
const { name, input: called } = (event as Event<"tool.called">).payload;
|
|
327
|
+
const path = pathOf(called);
|
|
328
|
+
facts.push(path === undefined ? name : `${name} ${path}`);
|
|
329
|
+
} else if (event.type === "model.completed") {
|
|
330
|
+
const text = (event as Event<"model.completed">).payload.content
|
|
331
|
+
.filter((part) => part.type === "text")
|
|
332
|
+
.map((part) => (part as { text: string }).text)
|
|
333
|
+
.join("\n")
|
|
334
|
+
.trim();
|
|
335
|
+
if (text !== "") proposal = text;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const current = await work.current();
|
|
339
|
+
return {
|
|
340
|
+
approvalId: input.approvalId,
|
|
341
|
+
workId: work.id,
|
|
342
|
+
action: {
|
|
343
|
+
name: input.rule.match.action?.toString() ?? input.call.name,
|
|
344
|
+
tool: input.call.name,
|
|
345
|
+
input: input.call.input,
|
|
346
|
+
},
|
|
347
|
+
facts,
|
|
348
|
+
sources: [],
|
|
349
|
+
companyRules: [],
|
|
350
|
+
proposal,
|
|
351
|
+
question:
|
|
352
|
+
input.rule.reason ??
|
|
353
|
+
`${current.objective} のために ${input.call.name} を実行してよいか、判断をお願いします。`,
|
|
354
|
+
requestedBy: input.principal,
|
|
355
|
+
requestedAt: new Date().toISOString(),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The path a call names, normalized to a workspace-relative posix path, when its input has one. */
|
|
360
|
+
function pathOf(input: unknown): string | undefined {
|
|
361
|
+
const path = (input as { path?: unknown } | null)?.path;
|
|
362
|
+
if (typeof path !== "string" || path === "") return undefined;
|
|
363
|
+
const segments: string[] = [];
|
|
364
|
+
for (const segment of path.replaceAll("\\", "/").split("/")) {
|
|
365
|
+
if (segment === "" || segment === ".") continue;
|
|
366
|
+
if (segment === "..") segments.pop();
|
|
367
|
+
else segments.push(segment);
|
|
368
|
+
}
|
|
369
|
+
return segments.join("/");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Today's date on this machine's clock, YYYY-MM-DD. */
|
|
373
|
+
function businessDate(): string {
|
|
374
|
+
const now = new Date();
|
|
375
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
376
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
206
379
|
function isRejectionCode(code: string): code is ToolRejectionCode {
|
|
207
380
|
return (TOOL_REJECTION_CODES as readonly string[]).includes(code);
|
|
208
381
|
}
|
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/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
|
|
package/src/tool/types.ts
CHANGED
package/src/work/events.ts
CHANGED
|
@@ -42,6 +42,8 @@ export const TOOL_REJECTION_CODES = [
|
|
|
42
42
|
"outside_workspace",
|
|
43
43
|
"invalid_path",
|
|
44
44
|
"limit_reached",
|
|
45
|
+
"denied",
|
|
46
|
+
"rejected_by_person",
|
|
45
47
|
] as const;
|
|
46
48
|
|
|
47
49
|
export type ToolRejectionCode = (typeof TOOL_REJECTION_CODES)[number];
|
|
@@ -72,6 +74,29 @@ export interface EventPayloads {
|
|
|
72
74
|
"tool.rejected": { callId: string; name: string; code: ToolRejectionCode; reason: string };
|
|
73
75
|
"human.input_requested": { callId: string; question: string };
|
|
74
76
|
"human.input_provided": { callId: string; answer: string };
|
|
77
|
+
/** A tool call the policy holds for a person's or a reviewer's approval. The work waits. */
|
|
78
|
+
"approval.requested": {
|
|
79
|
+
approvalId: string;
|
|
80
|
+
call: { callId: string; name: string; input: unknown };
|
|
81
|
+
ruleId: string;
|
|
82
|
+
kind: "approval" | "review";
|
|
83
|
+
approvers?: string[];
|
|
84
|
+
reviewer?: { role: string; name?: string };
|
|
85
|
+
};
|
|
86
|
+
/** The package handed to the reviewer: the call, what the work established, and the question. */
|
|
87
|
+
"review.requested": { approvalId: string; package: ReviewPackage };
|
|
88
|
+
/** The reviewer's answer. A decision id when they wrote one; absent when they refused. */
|
|
89
|
+
"review.decided": { approvalId: string; decisionId?: string };
|
|
90
|
+
/** A call that ran because an approved decision covers it. */
|
|
91
|
+
"decision.applied": { callId: string; decisionId: string };
|
|
92
|
+
/** The answer to an approval: approve runs the call, reject refuses it, modify runs it with the reviewer's input. */
|
|
93
|
+
"approval.decided": {
|
|
94
|
+
approvalId: string;
|
|
95
|
+
decision: "approve" | "reject" | "modify";
|
|
96
|
+
by: string;
|
|
97
|
+
comment?: string;
|
|
98
|
+
modifiedInput?: unknown;
|
|
99
|
+
};
|
|
75
100
|
/** What the person said in a session. Becomes a user message in the projection. */
|
|
76
101
|
"human.message": { text: string };
|
|
77
102
|
/** A prompt command expanded for the model: its name, where it came from, and the text handed over. */
|
|
@@ -84,6 +109,23 @@ export interface EventPayloads {
|
|
|
84
109
|
"work.failed": { reason: string; detail: string };
|
|
85
110
|
}
|
|
86
111
|
|
|
112
|
+
/** What a reviewer is asked to decide on, built from the work's own record. */
|
|
113
|
+
export interface ReviewPackage {
|
|
114
|
+
approvalId: string;
|
|
115
|
+
workId: string;
|
|
116
|
+
action: { name: string; tool: string; input: unknown };
|
|
117
|
+
/** What the work established before this call: the tool calls it made. */
|
|
118
|
+
facts: string[];
|
|
119
|
+
/** Sources and company rules the work cited. Empty until knowledge is in. */
|
|
120
|
+
sources: { id: string; locator?: string; version?: string }[];
|
|
121
|
+
companyRules: { id: string; statement: string }[];
|
|
122
|
+
/** What the agent proposes, in its own words. */
|
|
123
|
+
proposal: string;
|
|
124
|
+
question: string;
|
|
125
|
+
requestedBy: string;
|
|
126
|
+
requestedAt: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
export type EventType = keyof EventPayloads;
|
|
88
130
|
|
|
89
131
|
interface Envelope {
|
|
@@ -181,6 +223,47 @@ export const payloadFileSchemas = {
|
|
|
181
223
|
}),
|
|
182
224
|
"human.input_requested": z.looseObject({ call_id: z.string(), question: z.string() }),
|
|
183
225
|
"human.input_provided": z.looseObject({ call_id: z.string(), answer: z.string() }),
|
|
226
|
+
"approval.requested": z.looseObject({
|
|
227
|
+
approval_id: z.string(),
|
|
228
|
+
call: z.looseObject({ call_id: z.string(), name: z.string(), input: z.unknown() }),
|
|
229
|
+
rule_id: z.string(),
|
|
230
|
+
kind: z.enum(["approval", "review"]),
|
|
231
|
+
approvers: z.array(z.string()).optional(),
|
|
232
|
+
reviewer: z.looseObject({ role: z.string(), name: z.string().optional() }).optional(),
|
|
233
|
+
}),
|
|
234
|
+
"review.requested": z.looseObject({
|
|
235
|
+
approval_id: z.string(),
|
|
236
|
+
package: z.looseObject({
|
|
237
|
+
approval_id: z.string(),
|
|
238
|
+
work_id: z.string(),
|
|
239
|
+
action: z.looseObject({ name: z.string(), tool: z.string(), input: z.unknown() }),
|
|
240
|
+
facts: z.array(z.string()),
|
|
241
|
+
sources: z.array(
|
|
242
|
+
z.looseObject({
|
|
243
|
+
id: z.string(),
|
|
244
|
+
locator: z.string().optional(),
|
|
245
|
+
version: z.string().optional(),
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
company_rules: z.array(z.looseObject({ id: z.string(), statement: z.string() })),
|
|
249
|
+
proposal: z.string(),
|
|
250
|
+
question: z.string(),
|
|
251
|
+
requested_by: z.string(),
|
|
252
|
+
requested_at: z.iso.datetime(),
|
|
253
|
+
}),
|
|
254
|
+
}),
|
|
255
|
+
"review.decided": z.looseObject({
|
|
256
|
+
approval_id: z.string(),
|
|
257
|
+
decision_id: z.string().optional(),
|
|
258
|
+
}),
|
|
259
|
+
"decision.applied": z.looseObject({ call_id: z.string(), decision_id: z.string() }),
|
|
260
|
+
"approval.decided": z.looseObject({
|
|
261
|
+
approval_id: z.string(),
|
|
262
|
+
decision: z.enum(["approve", "reject", "modify"]),
|
|
263
|
+
by: z.string(),
|
|
264
|
+
comment: z.string().optional(),
|
|
265
|
+
modified_input: z.unknown().optional(),
|
|
266
|
+
}),
|
|
184
267
|
"human.message": z.looseObject({ text: z.string() }),
|
|
185
268
|
"prompt.expanded": z.looseObject({ name: z.string(), source: z.string(), text: z.string() }),
|
|
186
269
|
"usage.recorded": z.discriminatedUnion("kind", [
|
|
@@ -455,6 +538,95 @@ const codecs: { [T in EventType]?: Codec<T> } = {
|
|
|
455
538
|
toFile: (p) => ({ call_id: p.callId, answer: p.answer }),
|
|
456
539
|
fromFile: (p) => ({ callId: p.call_id, answer: p.answer }),
|
|
457
540
|
},
|
|
541
|
+
"approval.requested": {
|
|
542
|
+
toFile: (p) => ({
|
|
543
|
+
approval_id: p.approvalId,
|
|
544
|
+
call: { call_id: p.call.callId, name: p.call.name, input: p.call.input },
|
|
545
|
+
rule_id: p.ruleId,
|
|
546
|
+
kind: p.kind,
|
|
547
|
+
...(p.approvers && { approvers: p.approvers }),
|
|
548
|
+
...(p.reviewer && { reviewer: p.reviewer }),
|
|
549
|
+
}),
|
|
550
|
+
fromFile: (p) => ({
|
|
551
|
+
approvalId: p.approval_id,
|
|
552
|
+
call: { callId: p.call.call_id, name: p.call.name, input: p.call.input },
|
|
553
|
+
ruleId: p.rule_id,
|
|
554
|
+
kind: p.kind,
|
|
555
|
+
...(p.approvers && { approvers: p.approvers }),
|
|
556
|
+
...(p.reviewer && {
|
|
557
|
+
reviewer: {
|
|
558
|
+
role: p.reviewer.role,
|
|
559
|
+
...(p.reviewer.name !== undefined && { name: p.reviewer.name }),
|
|
560
|
+
},
|
|
561
|
+
}),
|
|
562
|
+
}),
|
|
563
|
+
},
|
|
564
|
+
"review.requested": {
|
|
565
|
+
toFile: (p) => ({
|
|
566
|
+
approval_id: p.approvalId,
|
|
567
|
+
package: {
|
|
568
|
+
approval_id: p.package.approvalId,
|
|
569
|
+
work_id: p.package.workId,
|
|
570
|
+
action: p.package.action,
|
|
571
|
+
facts: p.package.facts,
|
|
572
|
+
sources: p.package.sources,
|
|
573
|
+
company_rules: p.package.companyRules,
|
|
574
|
+
proposal: p.package.proposal,
|
|
575
|
+
question: p.package.question,
|
|
576
|
+
requested_by: p.package.requestedBy,
|
|
577
|
+
requested_at: p.package.requestedAt,
|
|
578
|
+
},
|
|
579
|
+
}),
|
|
580
|
+
fromFile: (p) => ({
|
|
581
|
+
approvalId: p.approval_id,
|
|
582
|
+
package: {
|
|
583
|
+
approvalId: p.package.approval_id,
|
|
584
|
+
workId: p.package.work_id,
|
|
585
|
+
action: p.package.action,
|
|
586
|
+
facts: p.package.facts,
|
|
587
|
+
sources: p.package.sources.map((source) => ({
|
|
588
|
+
id: source.id,
|
|
589
|
+
...(source.locator !== undefined && { locator: source.locator }),
|
|
590
|
+
...(source.version !== undefined && { version: source.version }),
|
|
591
|
+
})),
|
|
592
|
+
companyRules: p.package.company_rules,
|
|
593
|
+
proposal: p.package.proposal,
|
|
594
|
+
question: p.package.question,
|
|
595
|
+
requestedBy: p.package.requested_by,
|
|
596
|
+
requestedAt: p.package.requested_at,
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
599
|
+
},
|
|
600
|
+
"review.decided": {
|
|
601
|
+
toFile: (p) => ({
|
|
602
|
+
approval_id: p.approvalId,
|
|
603
|
+
...(p.decisionId !== undefined && { decision_id: p.decisionId }),
|
|
604
|
+
}),
|
|
605
|
+
fromFile: (p) => ({
|
|
606
|
+
approvalId: p.approval_id,
|
|
607
|
+
...(p.decision_id !== undefined && { decisionId: p.decision_id }),
|
|
608
|
+
}),
|
|
609
|
+
},
|
|
610
|
+
"decision.applied": {
|
|
611
|
+
toFile: (p) => ({ call_id: p.callId, decision_id: p.decisionId }),
|
|
612
|
+
fromFile: (p) => ({ callId: p.call_id, decisionId: p.decision_id }),
|
|
613
|
+
},
|
|
614
|
+
"approval.decided": {
|
|
615
|
+
toFile: (p) => ({
|
|
616
|
+
approval_id: p.approvalId,
|
|
617
|
+
decision: p.decision,
|
|
618
|
+
by: p.by,
|
|
619
|
+
...(p.comment !== undefined && { comment: p.comment }),
|
|
620
|
+
...(p.modifiedInput !== undefined && { modified_input: p.modifiedInput }),
|
|
621
|
+
}),
|
|
622
|
+
fromFile: (p) => ({
|
|
623
|
+
approvalId: p.approval_id,
|
|
624
|
+
decision: p.decision,
|
|
625
|
+
by: p.by,
|
|
626
|
+
...(p.comment !== undefined && { comment: p.comment }),
|
|
627
|
+
...(p.modified_input !== undefined && { modifiedInput: p.modified_input }),
|
|
628
|
+
}),
|
|
629
|
+
},
|
|
458
630
|
"usage.recorded": {
|
|
459
631
|
toFile: (p) =>
|
|
460
632
|
p.kind === "tool_execution"
|
package/src/work/history.ts
CHANGED
|
@@ -44,6 +44,28 @@ export function pendingQuestions(events: readonly AnyEvent[]): PendingQuestion[]
|
|
|
44
44
|
.map((e) => ({ callId: e.payload.callId, question: e.payload.question }));
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export interface PendingApproval {
|
|
48
|
+
approvalId: string;
|
|
49
|
+
call: { callId: string; name: string; input: unknown };
|
|
50
|
+
ruleId: string;
|
|
51
|
+
kind: "approval" | "review";
|
|
52
|
+
approvers?: string[];
|
|
53
|
+
reviewer?: { role: string; name?: string };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The approvals of the work that have no decision yet, oldest first. */
|
|
57
|
+
export function pendingApprovals(events: readonly AnyEvent[]): PendingApproval[] {
|
|
58
|
+
const decided = new Set(
|
|
59
|
+
events
|
|
60
|
+
.filter((e): e is Event<"approval.decided"> => e.type === "approval.decided")
|
|
61
|
+
.map((e) => e.payload.approvalId),
|
|
62
|
+
);
|
|
63
|
+
return events
|
|
64
|
+
.filter((e): e is Event<"approval.requested"> => e.type === "approval.requested")
|
|
65
|
+
.filter((e) => !decided.has(e.payload.approvalId))
|
|
66
|
+
.map((e) => ({ ...e.payload }));
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
export interface HistoryCall {
|
|
48
70
|
callId: string;
|
|
49
71
|
name: string;
|
|
@@ -59,6 +81,8 @@ export interface WorkHistory {
|
|
|
59
81
|
/** Calls that were started but have no result: the work stopped while they ran. */
|
|
60
82
|
unfinished: HistoryCall[];
|
|
61
83
|
pending: PendingQuestion[];
|
|
84
|
+
/** Calls held for approval that nobody has decided on yet. */
|
|
85
|
+
approvals: PendingApproval[];
|
|
62
86
|
toolCalls: number;
|
|
63
87
|
/** Model calls recorded on the work, for a client that counts them against a limit. */
|
|
64
88
|
modelCalls: number;
|
|
@@ -90,6 +114,7 @@ export function workHistory(events: readonly AnyEvent[]): WorkHistory {
|
|
|
90
114
|
calls,
|
|
91
115
|
unfinished: calls.filter((c) => c.isError === undefined && c.rejected === undefined),
|
|
92
116
|
pending: pendingQuestions(events),
|
|
117
|
+
approvals: pendingApprovals(events),
|
|
93
118
|
toolCalls: countToolCalls(events),
|
|
94
119
|
modelCalls: events.filter((e) => e.type === "model.requested").length,
|
|
95
120
|
};
|
package/src/work/projection.ts
CHANGED
|
@@ -35,12 +35,24 @@ export function buildProjection(input: ProjectionInput): Projection {
|
|
|
35
35
|
first?.type === "work.created" ? (first as Event<"work.created">).payload.agentName : undefined;
|
|
36
36
|
const system = [
|
|
37
37
|
config.profession.instructions.trim(),
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
[
|
|
39
|
+
"# 立場",
|
|
40
|
+
`この会社は ${config.company.name}。依頼する人は ${config.principal.name}(${config.principal.id})。あなたはこの人の代理として働き、この人と話す。あなた自身は ${config.principal.name} ではなく、この会社で働く社員エージェント。`,
|
|
41
|
+
...(agentName
|
|
42
|
+
? [
|
|
43
|
+
`あなたの名前は ${agentName}。名乗るときはこの名前と、社員エージェントであることを言う。`,
|
|
44
|
+
]
|
|
45
|
+
: []),
|
|
46
|
+
"",
|
|
47
|
+
"# 数字と事実",
|
|
48
|
+
"件数、合計、検索の結果は Tool が返した値をそのまま使う。自分で数え直したり足し直したりしない。日付と時刻は context を呼んで確かめ、推測しない。",
|
|
49
|
+
"",
|
|
50
|
+
"# 残り回数",
|
|
51
|
+
"各ターンの最後に「残り model 呼び出し N 回、Tool 呼び出し M 回」という 1 行が user message として届く。残量の通知なので、返事は要らない。",
|
|
52
|
+
"",
|
|
53
|
+
"# 終わり方",
|
|
54
|
+
"依頼が終わったら、何をしたかと結果の数字を書いて終える。",
|
|
55
|
+
].join("\n"),
|
|
44
56
|
].join("\n\n");
|
|
45
57
|
|
|
46
58
|
const messages: ModelMessage[] = [];
|