@openshain/core 0.1.1 → 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.
Files changed (47) hide show
  1. package/dist/config/load.d.ts +10 -0
  2. package/dist/config/load.js +66 -0
  3. package/dist/config/schema.d.ts +84 -0
  4. package/dist/config/schema.js +96 -0
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.js +30 -0
  7. package/dist/ids.d.ts +11 -0
  8. package/dist/ids.js +21 -0
  9. package/dist/index.d.ts +21 -0
  10. package/dist/index.js +20 -0
  11. package/dist/model/types.d.ts +57 -0
  12. package/dist/model/types.js +0 -0
  13. package/dist/runtime.d.ts +46 -0
  14. package/dist/runtime.js +144 -0
  15. package/dist/schemas.d.ts +9 -0
  16. package/dist/schemas.js +44 -0
  17. package/dist/tool/load-module.d.ts +6 -0
  18. package/dist/tool/load-module.js +42 -0
  19. package/dist/tool/paths.d.ts +17 -0
  20. package/dist/tool/paths.js +82 -0
  21. package/dist/tool/registry.d.ts +30 -0
  22. package/dist/tool/registry.js +68 -0
  23. package/dist/tool/types.d.ts +44 -0
  24. package/dist/tool/types.js +15 -0
  25. package/dist/tool/validate.d.ts +14 -0
  26. package/dist/tool/validate.js +68 -0
  27. package/dist/uuid.d.ts +1 -0
  28. package/dist/uuid.js +33 -0
  29. package/dist/work/artifacts.d.ts +7 -0
  30. package/dist/work/artifacts.js +20 -0
  31. package/dist/work/event-log.d.ts +28 -0
  32. package/dist/work/event-log.js +140 -0
  33. package/dist/work/events.d.ts +311 -0
  34. package/dist/work/events.js +344 -0
  35. package/dist/work/lock.d.ts +13 -0
  36. package/dist/work/lock.js +80 -0
  37. package/dist/work/projection.d.ts +31 -0
  38. package/dist/work/projection.js +130 -0
  39. package/dist/work/store.d.ts +58 -0
  40. package/dist/work/store.js +174 -0
  41. package/dist/work/work.d.ts +86 -0
  42. package/dist/work/work.js +149 -0
  43. package/package.json +15 -4
  44. package/src/ids.ts +3 -2
  45. package/src/index.ts +1 -0
  46. package/src/uuid.ts +33 -0
  47. package/src/work/projection.ts +1 -1
@@ -0,0 +1,344 @@
1
+ import { z } from "zod";
2
+ import { OpenshainError } from "../errors.js";
3
+ export const TOOL_REJECTION_CODES = [
4
+ "schema_mismatch",
5
+ "unknown_tool",
6
+ "not_allowed",
7
+ "reserved_path",
8
+ "outside_workspace",
9
+ "invalid_path",
10
+ ];
11
+ // ---------------------------------------------------------------------------
12
+ // File-side schemas (snake_case). These are the on-disk contract.
13
+ // ---------------------------------------------------------------------------
14
+ // Payload schemas are loose: a field added by a newer runtime must not make an
15
+ // older runtime refuse the log. Only the envelope is strict; envelope changes bump `v`.
16
+ const textPart = z.looseObject({ type: z.literal("text"), text: z.string() });
17
+ const toolCallPart = z.looseObject({
18
+ type: z.literal("tool_call"),
19
+ id: z.string(),
20
+ name: z.string(),
21
+ input: z.unknown(),
22
+ });
23
+ const opaquePart = z.looseObject({
24
+ type: z.literal("opaque"),
25
+ provider: z.string(),
26
+ data: z.unknown(),
27
+ });
28
+ const jsonPart = z.looseObject({ type: z.literal("json"), value: z.unknown() });
29
+ const artifact = z.looseObject({
30
+ path: z.string(),
31
+ sha256: z.string(),
32
+ missing: z.literal(true).optional(),
33
+ claimed: z.literal(true).optional(),
34
+ });
35
+ const modelUsageFile = z.looseObject({
36
+ input_tokens: z.int().nonnegative(),
37
+ output_tokens: z.int().nonnegative(),
38
+ cached_input_tokens: z.int().nonnegative().optional(),
39
+ cache_write_tokens: z.int().nonnegative().optional(),
40
+ reasoning_tokens: z.int().nonnegative().optional(),
41
+ });
42
+ export const payloadFileSchemas = {
43
+ "work.created": z.looseObject({
44
+ objective: z.string(),
45
+ principal: z.string(),
46
+ profession: z.string(),
47
+ type: z.string(),
48
+ parent: z.string().optional(),
49
+ agent_name: z.string().optional(),
50
+ }),
51
+ "work.status_changed": z.looseObject({ from: z.string(), to: z.string(), reason: z.string() }),
52
+ "model.requested": z.looseObject({
53
+ provider: z.string(),
54
+ model: z.string(),
55
+ message_count: z.int().nonnegative(),
56
+ tool_names: z.array(z.string()),
57
+ }),
58
+ "model.completed": z.looseObject({
59
+ stop_reason: z.enum(["end_turn", "tool_call", "max_tokens", "refusal", "other"]),
60
+ content: z.array(z.discriminatedUnion("type", [textPart, toolCallPart, opaquePart])),
61
+ raw: z.unknown().optional(),
62
+ }),
63
+ "model.failed": z.looseObject({ code: z.string(), message: z.string() }),
64
+ "tool.called": z.looseObject({
65
+ call_id: z.string(),
66
+ provider: z.string(),
67
+ name: z.string(),
68
+ input: z.unknown(),
69
+ }),
70
+ "tool.completed": z.looseObject({
71
+ call_id: z.string(),
72
+ content: z.array(z.discriminatedUnion("type", [textPart, jsonPart])),
73
+ is_error: z.boolean(),
74
+ observation: z.looseObject({ source: z.string(), retrieved_at: z.iso.datetime() }).optional(),
75
+ after: z.array(artifact).optional(),
76
+ }),
77
+ "tool.rejected": z.looseObject({
78
+ call_id: z.string(),
79
+ name: z.string(),
80
+ code: z.enum(TOOL_REJECTION_CODES),
81
+ reason: z.string(),
82
+ }),
83
+ "human.input_requested": z.looseObject({ call_id: z.string(), question: z.string() }),
84
+ "human.input_provided": z.looseObject({ call_id: z.string(), answer: z.string() }),
85
+ "human.message": z.looseObject({ text: z.string() }),
86
+ "usage.recorded": z.discriminatedUnion("kind", [
87
+ z.looseObject({
88
+ kind: z.literal("model_inference"),
89
+ provider: z.string(),
90
+ model: z.string(),
91
+ usage: modelUsageFile,
92
+ }),
93
+ z.looseObject({
94
+ kind: z.literal("tool_execution"),
95
+ provider: z.string(),
96
+ usage: z.looseObject({ duration_ms: z.int().nonnegative() }),
97
+ }),
98
+ ]),
99
+ "evidence.recorded": z.looseObject({
100
+ claim: z.string(),
101
+ refs: z.array(z.string()),
102
+ artifacts: z.array(artifact),
103
+ }),
104
+ "work.completed": z.looseObject({ summary: z.string() }),
105
+ "work.failed": z.looseObject({ reason: z.string(), detail: z.string() }),
106
+ };
107
+ export const EventFileSchema = z.strictObject({
108
+ v: z.literal(1),
109
+ id: z.string(),
110
+ work_id: z.string(),
111
+ seq: z.int().positive(),
112
+ type: z.string(),
113
+ occurred_at: z.iso.datetime(),
114
+ recorded_at: z.iso.datetime(),
115
+ payload: z.unknown(),
116
+ });
117
+ // ---------------------------------------------------------------------------
118
+ // Mapping. The only place where snake_case and camelCase meet.
119
+ // ---------------------------------------------------------------------------
120
+ export function eventToFile(event) {
121
+ const payload = isKnownType(event.type)
122
+ ? payloadToFile(event.type, event.payload)
123
+ : event.payload;
124
+ return {
125
+ v: event.v,
126
+ id: event.id,
127
+ work_id: event.workId,
128
+ seq: event.seq,
129
+ type: event.type,
130
+ occurred_at: event.occurredAt,
131
+ recorded_at: event.recordedAt,
132
+ payload: canonical(payload),
133
+ };
134
+ }
135
+ const DATA_KEYS = new Set(["input", "data", "value", "raw"]);
136
+ /**
137
+ * Canonical JSON form: object keys sorted recursively so that equal data is
138
+ * written as equal bytes. Inside the free-form fields (`input`, `data`, `value`,
139
+ * `raw`) `undefined` becomes `null`, because JSON has no `undefined` and a
140
+ * dropped key would make the line unreadable.
141
+ */
142
+ export function canonical(value, insideData = false, seen = new WeakSet()) {
143
+ if (value === undefined)
144
+ return insideData ? null : undefined;
145
+ if (value === null || typeof value !== "object")
146
+ return value;
147
+ if (seen.has(value)) {
148
+ throw new OpenshainError("invalid_event", "a value that refers to itself cannot be recorded");
149
+ }
150
+ seen.add(value);
151
+ try {
152
+ if (Array.isArray(value))
153
+ return value.map((item) => canonical(item, insideData, seen) ?? null);
154
+ const out = {};
155
+ for (const key of Object.keys(value).sort()) {
156
+ const inner = insideData || DATA_KEYS.has(key);
157
+ const item = canonical(value[key], inner, seen);
158
+ if (item !== undefined)
159
+ define(out, key, item);
160
+ else if (inner)
161
+ define(out, key, null);
162
+ }
163
+ return out;
164
+ }
165
+ finally {
166
+ seen.delete(value);
167
+ }
168
+ }
169
+ /** Sets an own property even for a key such as `__proto__`, which plain assignment would treat as the prototype. */
170
+ function define(target, key, item) {
171
+ Object.defineProperty(target, key, {
172
+ value: item,
173
+ enumerable: true,
174
+ writable: true,
175
+ configurable: true,
176
+ });
177
+ }
178
+ export function eventFromFile(input) {
179
+ const parsed = EventFileSchema.safeParse(input);
180
+ if (!parsed.success) {
181
+ throw new OpenshainError("corrupt_log", `event envelope: ${describeIssues(parsed.error)}`);
182
+ }
183
+ const file = parsed.data;
184
+ const envelope = {
185
+ v: file.v,
186
+ id: file.id,
187
+ workId: file.work_id,
188
+ seq: file.seq,
189
+ occurredAt: file.occurred_at,
190
+ recordedAt: file.recorded_at,
191
+ };
192
+ if (!isKnownType(file.type)) {
193
+ return { ...envelope, type: file.type, payload: file.payload };
194
+ }
195
+ const payload = payloadFileSchemas[file.type].safeParse(file.payload);
196
+ if (!payload.success) {
197
+ throw new OpenshainError("corrupt_log", `${file.type} payload: ${describeIssues(payload.error)}`);
198
+ }
199
+ return {
200
+ ...envelope,
201
+ type: file.type,
202
+ payload: payloadFromFile(file.type, payload.data),
203
+ };
204
+ }
205
+ function isKnownType(type) {
206
+ return Object.hasOwn(payloadFileSchemas, type);
207
+ }
208
+ function describeIssues(error) {
209
+ return error.issues
210
+ .map((issue) => (issue.path.length ? `${issue.path.map(String).join(".")}: ` : "") + issue.message)
211
+ .join("; ");
212
+ }
213
+ function usageToFile(usage) {
214
+ const out = {
215
+ input_tokens: usage.inputTokens,
216
+ output_tokens: usage.outputTokens,
217
+ };
218
+ if (usage.cachedInputTokens !== undefined)
219
+ out.cached_input_tokens = usage.cachedInputTokens;
220
+ if (usage.cacheWriteTokens !== undefined)
221
+ out.cache_write_tokens = usage.cacheWriteTokens;
222
+ if (usage.reasoningTokens !== undefined)
223
+ out.reasoning_tokens = usage.reasoningTokens;
224
+ return out;
225
+ }
226
+ function usageFromFile(usage) {
227
+ const out = { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens };
228
+ if (usage.cached_input_tokens !== undefined)
229
+ out.cachedInputTokens = usage.cached_input_tokens;
230
+ if (usage.cache_write_tokens !== undefined)
231
+ out.cacheWriteTokens = usage.cache_write_tokens;
232
+ if (usage.reasoning_tokens !== undefined)
233
+ out.reasoningTokens = usage.reasoning_tokens;
234
+ return out;
235
+ }
236
+ /**
237
+ * Event types whose field names differ between code and file. Every other
238
+ * payload uses the same names on both sides and needs no codec.
239
+ */
240
+ const codecs = {
241
+ "work.created": {
242
+ // Only agent_name changes its name; every other field, known or not, passes through.
243
+ toFile: ({ agentName, ...rest }) => ({
244
+ ...rest,
245
+ ...(agentName !== undefined && { agent_name: agentName }),
246
+ }),
247
+ fromFile: ({ agent_name, parent, ...rest }) => ({
248
+ ...rest,
249
+ ...(parent !== undefined && { parent }),
250
+ ...(agent_name !== undefined && { agentName: agent_name }),
251
+ }),
252
+ },
253
+ "model.requested": {
254
+ toFile: (p) => ({
255
+ provider: p.provider,
256
+ model: p.model,
257
+ message_count: p.messageCount,
258
+ tool_names: p.toolNames,
259
+ }),
260
+ fromFile: (p) => ({
261
+ provider: p.provider,
262
+ model: p.model,
263
+ messageCount: p.message_count,
264
+ toolNames: p.tool_names,
265
+ }),
266
+ },
267
+ "model.completed": {
268
+ toFile: (p) => {
269
+ const out = { stop_reason: p.stopReason, content: p.content };
270
+ if (p.raw !== undefined)
271
+ out.raw = p.raw;
272
+ return out;
273
+ },
274
+ fromFile: (p) => {
275
+ const out = {
276
+ stopReason: p.stop_reason,
277
+ content: p.content,
278
+ };
279
+ if (p.raw !== undefined)
280
+ out.raw = p.raw;
281
+ return out;
282
+ },
283
+ },
284
+ "tool.called": {
285
+ toFile: (p) => ({ call_id: p.callId, provider: p.provider, name: p.name, input: p.input }),
286
+ fromFile: (p) => ({ callId: p.call_id, provider: p.provider, name: p.name, input: p.input }),
287
+ },
288
+ "tool.completed": {
289
+ toFile: (p) => {
290
+ const out = {
291
+ call_id: p.callId,
292
+ content: p.content,
293
+ is_error: p.isError,
294
+ };
295
+ if (p.observation) {
296
+ out.observation = { source: p.observation.source, retrieved_at: p.observation.retrievedAt };
297
+ }
298
+ if (p.after)
299
+ out.after = p.after;
300
+ return out;
301
+ },
302
+ fromFile: (p) => {
303
+ const out = {
304
+ callId: p.call_id,
305
+ content: p.content,
306
+ isError: p.is_error,
307
+ };
308
+ if (p.observation) {
309
+ out.observation = { source: p.observation.source, retrievedAt: p.observation.retrieved_at };
310
+ }
311
+ if (p.after)
312
+ out.after = p.after.map((a) => ({ path: a.path, sha256: a.sha256 }));
313
+ return out;
314
+ },
315
+ },
316
+ "tool.rejected": {
317
+ toFile: (p) => ({ call_id: p.callId, name: p.name, code: p.code, reason: p.reason }),
318
+ fromFile: (p) => ({ callId: p.call_id, name: p.name, code: p.code, reason: p.reason }),
319
+ },
320
+ "human.input_requested": {
321
+ toFile: (p) => ({ call_id: p.callId, question: p.question }),
322
+ fromFile: (p) => ({ callId: p.call_id, question: p.question }),
323
+ },
324
+ "human.input_provided": {
325
+ toFile: (p) => ({ call_id: p.callId, answer: p.answer }),
326
+ fromFile: (p) => ({ callId: p.call_id, answer: p.answer }),
327
+ },
328
+ "usage.recorded": {
329
+ toFile: (p) => p.kind === "tool_execution"
330
+ ? { kind: p.kind, provider: p.provider, usage: { duration_ms: p.usage.durationMs } }
331
+ : { kind: p.kind, provider: p.provider, model: p.model, usage: usageToFile(p.usage) },
332
+ fromFile: (p) => p.kind === "tool_execution"
333
+ ? { kind: p.kind, provider: p.provider, usage: { durationMs: p.usage.duration_ms } }
334
+ : { kind: p.kind, provider: p.provider, model: p.model, usage: usageFromFile(p.usage) },
335
+ },
336
+ };
337
+ function payloadToFile(type, payload) {
338
+ const codec = codecs[type];
339
+ return codec ? codec.toFile(payload) : payload;
340
+ }
341
+ function payloadFromFile(type, payload) {
342
+ const codec = codecs[type];
343
+ return codec ? codec.fromFile(payload) : payload;
344
+ }
@@ -0,0 +1,13 @@
1
+ export declare const LOCK_FILE_NAME = "lock";
2
+ export interface Lock {
3
+ release(): Promise<void>;
4
+ }
5
+ /**
6
+ * Takes the single-writer lock of a work directory. A lock left behind by a
7
+ * process that no longer exists, or one that cannot be read, is taken over.
8
+ * Release only removes the file while it still records this holder.
9
+ *
10
+ * Known limit: liveness is judged by pid. A pid reused by an unrelated process
11
+ * keeps the lock held until that process ends or the file is removed by hand.
12
+ */
13
+ export declare function acquireLock(dir: string): Promise<Lock>;
@@ -0,0 +1,80 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { OpenshainError } from "../errors.js";
4
+ export const LOCK_FILE_NAME = "lock";
5
+ /**
6
+ * Takes the single-writer lock of a work directory. A lock left behind by a
7
+ * process that no longer exists, or one that cannot be read, is taken over.
8
+ * Release only removes the file while it still records this holder.
9
+ *
10
+ * Known limit: liveness is judged by pid. A pid reused by an unrelated process
11
+ * keeps the lock held until that process ends or the file is removed by hand.
12
+ */
13
+ export async function acquireLock(dir) {
14
+ try {
15
+ await mkdir(dir, { recursive: true });
16
+ }
17
+ catch (cause) {
18
+ throw new OpenshainError("invalid_path", `cannot create the work directory ${dir}`, { cause });
19
+ }
20
+ const path = join(dir, LOCK_FILE_NAME);
21
+ const holder = { pid: process.pid, startedAt: new Date().toISOString() };
22
+ const content = JSON.stringify({ pid: holder.pid, started_at: holder.startedAt });
23
+ for (let attempt = 0; attempt < 3; attempt++) {
24
+ try {
25
+ await writeFile(path, content, { flag: "wx" });
26
+ return lockHandle(path, holder);
27
+ }
28
+ catch (err) {
29
+ if (err.code !== "EEXIST") {
30
+ throw new OpenshainError("invalid_path", `cannot write the lock file ${path}`, {
31
+ cause: err,
32
+ });
33
+ }
34
+ }
35
+ const current = await readHolder(path);
36
+ if (current && isAlive(current.pid)) {
37
+ throw new OpenshainError("lock_held", `${dir} is locked by process ${current.pid} since ${current.startedAt}`);
38
+ }
39
+ await rm(path, { force: true });
40
+ }
41
+ throw new OpenshainError("lock_held", `${dir}: could not take the lock`);
42
+ }
43
+ async function readHolder(path) {
44
+ try {
45
+ const parsed = JSON.parse(await readFile(path, "utf8"));
46
+ if (typeof parsed.pid !== "number")
47
+ return undefined;
48
+ return { pid: parsed.pid, startedAt: String(parsed.started_at ?? "unknown") };
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /** Only real process ids count. 0 and negatives address process groups and would always "exist". */
55
+ function isAlive(pid) {
56
+ if (!Number.isInteger(pid) || pid <= 1)
57
+ return false;
58
+ try {
59
+ process.kill(pid, 0);
60
+ return true;
61
+ }
62
+ catch (err) {
63
+ return err.code === "EPERM";
64
+ }
65
+ }
66
+ function lockHandle(path, holder) {
67
+ let released = false;
68
+ return {
69
+ async release() {
70
+ if (released)
71
+ return;
72
+ released = true;
73
+ const current = await readHolder(path);
74
+ if (current && (current.pid !== holder.pid || current.startedAt !== holder.startedAt)) {
75
+ return; // someone else holds it now; not ours to remove
76
+ }
77
+ await rm(path, { force: true });
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,31 @@
1
+ import type { Config } from "../config/schema.ts";
2
+ import type { ModelMessage } from "../model/types.ts";
3
+ import type { ToolDefinition } from "../tool/types.ts";
4
+ import { type AnyEvent } from "./events.ts";
5
+ export interface ProjectionInput {
6
+ events: readonly AnyEvent[];
7
+ config: Pick<Config, "company" | "principal" | "profession">;
8
+ /** Tool definitions the model may call. Already filtered by the allow lists. */
9
+ tools: ToolDefinition[];
10
+ /** Opaque parts are returned only to the provider that produced them. */
11
+ providerId: string;
12
+ budget: {
13
+ modelCallsLeft: number;
14
+ toolCallsLeft: number;
15
+ };
16
+ }
17
+ export interface Projection {
18
+ system: string;
19
+ messages: ModelMessage[];
20
+ tools: ToolDefinition[];
21
+ budget: {
22
+ modelCallsLeft: number;
23
+ toolCallsLeft: number;
24
+ };
25
+ }
26
+ /**
27
+ * What the model sees. Built from the event log alone, in order, and therefore
28
+ * the same bytes every time for the same events. Nothing is rewritten: the
29
+ * budget line is a user message of its own at the end.
30
+ */
31
+ export declare function buildProjection(input: ProjectionInput): Projection;
@@ -0,0 +1,130 @@
1
+ import { OpenshainError } from "../errors.js";
2
+ import { canonical } from "./events.js";
3
+ import { SESSION_WORK_TYPE } from "./work.js";
4
+ /**
5
+ * What the model sees. Built from the event log alone, in order, and therefore
6
+ * the same bytes every time for the same events. Nothing is rewritten: the
7
+ * budget line is a user message of its own at the end.
8
+ */
9
+ export function buildProjection(input) {
10
+ const { config } = input;
11
+ const first = input.events[0];
12
+ const agentName = first?.type === "work.created" ? first.payload.agentName : undefined;
13
+ const system = [
14
+ config.profession.instructions.trim(),
15
+ `この会社は ${config.company.name}。`,
16
+ `依頼する人は ${config.principal.name}(${config.principal.id})。あなたはこの人の代理として働き、この人と話す。あなた自身は ${config.principal.name} ではなく、この会社で働く社員エージェントで、名乗るならそう名乗る。`,
17
+ ...(agentName
18
+ ? [`あなたの名前は ${agentName}。名乗るときはこの名前と、社員エージェントであることを言う。`]
19
+ : []),
20
+ "件数、合計、検索の結果は Tool が返した値をそのまま使い、自分で数えたり合計したりしない。各ターンの最後に Runtime が「残り model 呼び出し N 回、Tool 呼び出し M 回」という 1 行を user message として追加する。これは残量の通知で、返事は要らない。依頼が終わったら、何をしたかを要約して終える。",
21
+ ].join("\n\n");
22
+ const messages = [];
23
+ const pushUserPart = (part) => {
24
+ const last = messages.at(-1);
25
+ if (last?.role === "user")
26
+ last.content.push(part);
27
+ else
28
+ messages.push({ role: "user", content: [part] });
29
+ };
30
+ for (const event of input.events) {
31
+ switch (event.type) {
32
+ case "work.created": {
33
+ // A session's objective is a label; the conversation starts with what the person says.
34
+ const { objective, type } = event.payload;
35
+ if (type !== SESSION_WORK_TYPE)
36
+ pushUserPart({ type: "text", text: objective });
37
+ break;
38
+ }
39
+ case "human.message":
40
+ pushUserPart({ type: "text", text: event.payload.text });
41
+ break;
42
+ case "model.completed": {
43
+ const content = event.payload.content
44
+ .filter((part) => part.type !== "opaque" || part.provider === input.providerId)
45
+ .map((part) => canonical(part));
46
+ if (content.length > 0)
47
+ messages.push({ role: "assistant", content });
48
+ break;
49
+ }
50
+ case "tool.completed": {
51
+ const { payload } = event;
52
+ pushUserPart({
53
+ type: "tool_result",
54
+ callId: payload.callId,
55
+ content: renderContent(payload.content),
56
+ isError: payload.isError,
57
+ });
58
+ break;
59
+ }
60
+ case "tool.rejected": {
61
+ const { payload } = event;
62
+ pushUserPart({
63
+ type: "tool_result",
64
+ callId: payload.callId,
65
+ content: payload.reason,
66
+ isError: true,
67
+ });
68
+ break;
69
+ }
70
+ default:
71
+ break;
72
+ }
73
+ }
74
+ checkToolPairs(messages);
75
+ // The budget is a message of its own, so the messages before it keep their bytes from turn to
76
+ // turn and a provider's prompt cache can cover them.
77
+ messages.push({
78
+ role: "user",
79
+ content: [
80
+ {
81
+ type: "text",
82
+ text: `残り model 呼び出し ${input.budget.modelCallsLeft} 回、Tool 呼び出し ${input.budget.toolCallsLeft} 回`,
83
+ },
84
+ ],
85
+ });
86
+ return { system, messages, tools: input.tools, budget: { ...input.budget } };
87
+ }
88
+ /**
89
+ * Every tool_result must answer a tool_call in the assistant message right
90
+ * before it, and every tool_call must be answered before the conversation goes
91
+ * on. Providers reject anything else, so the log is treated as corrupt.
92
+ */
93
+ function checkToolPairs(messages) {
94
+ for (let i = 0; i < messages.length; i++) {
95
+ const message = messages[i];
96
+ if (!message)
97
+ continue;
98
+ if (message.role === "assistant") {
99
+ const calls = message.content.filter((p) => p.type === "tool_call").map((p) => p.id);
100
+ if (calls.length === 0)
101
+ continue;
102
+ const next = messages[i + 1];
103
+ const answered = new Set(next?.role === "user"
104
+ ? next.content.filter((p) => p.type === "tool_result").map((p) => p.callId)
105
+ : []);
106
+ const missing = calls.filter((id) => !answered.has(id));
107
+ if (missing.length > 0) {
108
+ throw new OpenshainError("corrupt_log", `tool calls without a result before the conversation continues: ${missing.join(", ")}`);
109
+ }
110
+ }
111
+ else {
112
+ const results = message.content.filter((p) => p.type === "tool_result").map((p) => p.callId);
113
+ if (results.length === 0)
114
+ continue;
115
+ const previous = messages[i - 1];
116
+ const known = new Set(previous?.role === "assistant"
117
+ ? previous.content.filter((p) => p.type === "tool_call").map((p) => p.id)
118
+ : []);
119
+ const orphans = results.filter((id) => !known.has(id));
120
+ if (orphans.length > 0) {
121
+ throw new OpenshainError("corrupt_log", `tool results that answer no call in the preceding assistant message: ${orphans.join(", ")}`);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ function renderContent(content) {
127
+ return content
128
+ .map((part) => (part.type === "text" ? part.text : JSON.stringify(part.value)))
129
+ .join("\n");
130
+ }
@@ -0,0 +1,58 @@
1
+ import { OpenshainError } from "../errors.ts";
2
+ import { type WorkId } from "../ids.ts";
3
+ import { type NewEvent } from "./event-log.ts";
4
+ import type { AnyEvent, Event, EventType } from "./events.ts";
5
+ import { type Work, type WorkStatus } from "./work.ts";
6
+ export declare const WORK_DIR_NAME = "work";
7
+ export declare const WORK_FILE_NAME = "work.json";
8
+ export interface CreateWorkInput {
9
+ objective: string;
10
+ principal: string;
11
+ profession: string;
12
+ /** Kind of work, for example "request" or "month_end_close". Defaults to "request". */
13
+ type?: string;
14
+ /** The work this one is started from, such as a session. */
15
+ parent?: string;
16
+ /** The name the model goes by in this work. */
17
+ agentName?: string;
18
+ }
19
+ export interface ListResult {
20
+ works: Work[];
21
+ /** Work directories that could not be read. Reported, never hidden. */
22
+ problems: {
23
+ id: string;
24
+ error: OpenshainError;
25
+ }[];
26
+ }
27
+ /**
28
+ * Write access to one work. Holds the work's lock from open() until close(),
29
+ * so there is exactly one writer at a time.
30
+ */
31
+ export interface WorkHandle {
32
+ readonly id: WorkId;
33
+ current(): Promise<Work>;
34
+ events(): Promise<AnyEvent[]>;
35
+ append<T extends EventType>(event: NewEvent<T>): Promise<Event<T>>;
36
+ /** Records a status change after checking it is allowed. Completion and failure go through their own events. */
37
+ transition(to: WorkStatus, reason: string): Promise<Event<"work.status_changed">>;
38
+ close(): Promise<void>;
39
+ }
40
+ /** Works of one workspace, stored under work/<id>/. Reads need no lock; writes go through a handle. */
41
+ export declare class WorkStore {
42
+ private readonly root;
43
+ constructor(root: string);
44
+ create(input: CreateWorkInput): Promise<Work>;
45
+ get(id: WorkId): Promise<Work>;
46
+ list(): Promise<ListResult>;
47
+ events(id: WorkId): Promise<AnyEvent[]>;
48
+ /** Takes the work's lock. Call close() when done, or use append()/transition() for a single write. */
49
+ open(id: WorkId): Promise<WorkHandle>;
50
+ /** Opens, appends one event, refreshes work.json and closes. */
51
+ append<T extends EventType>(id: WorkId, event: NewEvent<T>): Promise<Event<T>>;
52
+ /** Opens, records one status change and closes. */
53
+ transition(id: WorkId, to: WorkStatus, reason: string): Promise<Event<"work.status_changed">>;
54
+ private handle;
55
+ private dir;
56
+ private existingDir;
57
+ private snapshot;
58
+ }