@openshain/core 0.2.0 → 0.3.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.
@@ -56,7 +56,7 @@ export function parseConfig(text, options = {}) {
56
56
  throw new OpenshainError("config", problems.join("\n"));
57
57
  }
58
58
  const known = options.modelProviders;
59
- if (known && !known.includes(result.data.model.provider)) {
59
+ if (known && result.data.model && !known.includes(result.data.model.provider)) {
60
60
  throw new OpenshainError("config", problem(["model", "provider"], `unknown provider "${result.data.model.provider}"; known providers: ${known.length > 0 ? known.join(", ") : "none"}`));
61
61
  }
62
62
  return toConfig(result.data);
@@ -20,13 +20,13 @@ export declare const ConfigFileSchema: z.ZodObject<{
20
20
  id: z.ZodString;
21
21
  instructions: z.ZodString;
22
22
  }, z.core.$strict>;
23
- model: z.ZodObject<{
23
+ model: z.ZodOptional<z.ZodObject<{
24
24
  provider: z.ZodString;
25
25
  model: z.ZodString;
26
26
  api_key_env: z.ZodString;
27
27
  base_url: z.ZodOptional<z.ZodURL>;
28
28
  options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
29
- }, z.core.$strict>;
29
+ }, z.core.$strict>>;
30
30
  tools: z.ZodDefault<z.ZodArray<z.ZodObject<{
31
31
  provider: z.ZodOptional<z.ZodString>;
32
32
  module: z.ZodOptional<z.ZodString>;
@@ -50,6 +50,14 @@ export type ToolProviderRef = {
50
50
  allow: readonly string[] | undefined;
51
51
  };
52
52
  /** Configuration as used in code (camelCase). */
53
+ /** The model section of openshain.yaml, as the model providers take it. */
54
+ export interface ModelConfig {
55
+ provider: string;
56
+ model: string;
57
+ apiKeyEnv: string;
58
+ baseUrl: string | undefined;
59
+ options: Record<string, unknown> | undefined;
60
+ }
53
61
  export interface Config {
54
62
  version: 1;
55
63
  company: {
@@ -64,13 +72,8 @@ export interface Config {
64
72
  id: string;
65
73
  instructions: string;
66
74
  };
67
- model: {
68
- provider: string;
69
- model: string;
70
- apiKeyEnv: string;
71
- baseUrl: string | undefined;
72
- options: Record<string, unknown> | undefined;
73
- };
75
+ /** The model the interactive CLI runs on. Absent when the workspace is used from other agents only. */
76
+ model?: ModelConfig;
74
77
  tools: ToolProviderRef[];
75
78
  limits: {
76
79
  maxModelCalls: number;
@@ -38,7 +38,8 @@ export const ConfigFileSchema = z.strictObject({
38
38
  }),
39
39
  principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
40
40
  profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
41
- model: z.strictObject({
41
+ model: z
42
+ .strictObject({
42
43
  provider: identifier,
43
44
  model: z.string().min(1).max(200),
44
45
  api_key_env: envVarName,
@@ -50,11 +51,12 @@ export const ConfigFileSchema = z.strictObject({
50
51
  }, "base_url must not carry credentials; use api_key_env")
51
52
  .refine((value) => {
52
53
  const url = new URL(value);
53
- return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname));
54
+ return (url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname)));
54
55
  }, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
55
56
  .optional(),
56
57
  options: z.record(z.string(), z.unknown()).optional(),
57
- }),
58
+ })
59
+ .optional(),
58
60
  tools: z.array(toolProviderRef).default([{ provider: "standard" }]),
59
61
  limits: z
60
62
  .strictObject({
@@ -71,13 +73,15 @@ export function toConfig(file) {
71
73
  company: { name: file.company.name, language: file.company.language },
72
74
  principal: { id: file.principal.id, name: file.principal.name },
73
75
  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
- },
76
+ ...(file.model && {
77
+ model: {
78
+ provider: file.model.provider,
79
+ model: file.model.model,
80
+ apiKeyEnv: file.model.api_key_env,
81
+ baseUrl: file.model.base_url,
82
+ options: file.model.options,
83
+ },
84
+ }),
81
85
  tools: file.tools.map(toToolProviderRef),
82
86
  limits: {
83
87
  maxModelCalls: file.limits.max_model_calls,
package/dist/index.d.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  export { CONFIG_FILE_NAME, loadConfig, type ParseConfigOptions, parseConfig, } from "./config/load.ts";
2
- export type { Config, ToolProviderRef } from "./config/schema.ts";
2
+ export type { Config, ModelConfig, ToolProviderRef } from "./config/schema.ts";
3
3
  export { LANGUAGES, type Language } from "./config/schema.ts";
4
4
  export { ERROR_CODES, type ErrorCode, isOpenshainError, OpenshainError } from "./errors.ts";
5
5
  export { type EventId, newEventId, newWorkId, parseEventId, parseWorkId, type WorkId, } from "./ids.ts";
6
6
  export type { ModelDescription, ModelMessage, ModelProvider, ModelRequest, ModelResponse, UserPart, } from "./model/types.ts";
7
7
  export { type CreateRuntimeOptions, createRuntime, createToolCaller, createToolRegistry, MAX_TOOL_TEXT_CHARS, type Runtime, type RuntimeProviders, type ToolSummary, } from "./runtime.ts";
8
8
  export { jsonSchemas, type SchemaName } from "./schemas.ts";
9
+ export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.ts";
9
10
  export { loadToolModule } from "./tool/load-module.ts";
10
11
  export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
11
12
  export { type HiddenTool, type RegisteredTool, type RegisterOptions, ToolRegistry, } from "./tool/registry.ts";
@@ -14,7 +15,8 @@ export { compileInputValidator, type InputValidation } from "./tool/validate.ts"
14
15
  export { uuidv7 } from "./uuid.ts";
15
16
  export { verifyArtifact } from "./work/artifacts.ts";
16
17
  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 { type AnyEvent, type Artifact, type AssistantPart, canonical, type Event, type EventFile, EventFileSchema, type EventPayloads, type EventType, eventFromFile, eventToFile, isKnownEventType, type ModelUsage, parsePayloadFile, payloadFileSchemas, type StopReason, TOOL_REJECTION_CODES, type ToolContent, type ToolRejectionCode, type UnknownEvent, } from "./work/events.ts";
19
+ export { countToolCalls, type FailureReason, type HistoryCall, type PendingQuestion, pendingQuestions, type WorkHistory, workHistory, } from "./work/history.ts";
18
20
  export { acquireLock, LOCK_FILE_NAME, type Lock } from "./work/lock.ts";
19
21
  export { buildProjection, type Projection, type ProjectionInput } from "./work/projection.ts";
20
22
  export { type CreateWorkInput, type ListResult, WORK_DIR_NAME, WORK_FILE_NAME, type WorkHandle, WorkStore, } from "./work/store.ts";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { ERROR_CODES, isOpenshainError, OpenshainError } from "./errors.js";
5
5
  export { newEventId, newWorkId, parseEventId, parseWorkId, } from "./ids.js";
6
6
  export { createRuntime, createToolCaller, createToolRegistry, MAX_TOOL_TEXT_CHARS, } from "./runtime.js";
7
7
  export { jsonSchemas } from "./schemas.js";
8
+ export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.js";
8
9
  export { loadToolModule } from "./tool/load-module.js";
9
10
  export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.js";
10
11
  export { ToolRegistry, } from "./tool/registry.js";
@@ -13,7 +14,8 @@ export { compileInputValidator } from "./tool/validate.js";
13
14
  export { uuidv7 } from "./uuid.js";
14
15
  export { verifyArtifact } from "./work/artifacts.js";
15
16
  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 { canonical, EventFileSchema, eventFromFile, eventToFile, isKnownEventType, parsePayloadFile, payloadFileSchemas, TOOL_REJECTION_CODES, } from "./work/events.js";
18
+ export { countToolCalls, pendingQuestions, workHistory, } from "./work/history.js";
17
19
  export { acquireLock, LOCK_FILE_NAME } from "./work/lock.js";
18
20
  export { buildProjection } from "./work/projection.js";
19
21
  export { WORK_DIR_NAME, WORK_FILE_NAME, WorkStore, } from "./work/store.js";
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Config } from "./config/schema.ts";
1
+ import type { Config, ModelConfig } from "./config/schema.ts";
2
2
  import type { ModelProvider } from "./model/types.ts";
3
3
  import type { HiddenTool } from "./tool/registry.ts";
4
4
  import { ToolRegistry } from "./tool/registry.ts";
@@ -6,7 +6,7 @@ import type { ToolCall, ToolDefinition, ToolProvider, ToolResult } from "./tool/
6
6
  import { type WorkHandle, WorkStore } from "./work/store.ts";
7
7
  export interface RuntimeProviders {
8
8
  /** Model providers by the id used in openshain.yaml. */
9
- models: Record<string, (model: Config["model"]) => ModelProvider>;
9
+ models: Record<string, (model: ModelConfig) => ModelProvider>;
10
10
  /** Tool providers by the id used in openshain.yaml. Modules are loaded from the config directly. */
11
11
  tools: Record<string, () => ToolProvider>;
12
12
  }
package/dist/runtime.js CHANGED
@@ -10,6 +10,9 @@ export const MAX_TOOL_TEXT_CHARS = 50_000;
10
10
  export async function createRuntime(options) {
11
11
  const { workspaceRoot, providers } = options;
12
12
  const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
13
+ if (!config.model) {
14
+ throw new OpenshainError("config", "this needs a model: add a model section to openshain.yaml (only the interactive CLI needs one)");
15
+ }
13
16
  const modelFactory = Object.hasOwn(providers.models, config.model.provider)
14
17
  ? providers.models[config.model.provider]
15
18
  : undefined;
@@ -0,0 +1,5 @@
1
+ import { type ToolDefinition } from "./types.ts";
2
+ /** The provider id the runtime records for the tools it runs itself. */
3
+ export declare const RUNTIME_PROVIDER_ID = "runtime";
4
+ /** The one tool the runtime itself provides: stop and ask the person. */
5
+ export declare const ASK_USER: Readonly<ToolDefinition>;
@@ -0,0 +1,22 @@
1
+ import { ASK_USER_TOOL_NAME } from "./types.js";
2
+ /** The provider id the runtime records for the tools it runs itself. */
3
+ export const RUNTIME_PROVIDER_ID = "runtime";
4
+ /** The one tool the runtime itself provides: stop and ask the person. */
5
+ export const ASK_USER = Object.freeze({
6
+ name: ASK_USER_TOOL_NAME,
7
+ description: "Ask the person you work for a question when you cannot proceed without their answer. Use it sparingly; prefer the workspace over guessing. The work waits until the answer is recorded with work_answer.",
8
+ inputSchema: {
9
+ type: "object",
10
+ properties: {
11
+ question: {
12
+ type: "string",
13
+ minLength: 1,
14
+ maxLength: 10_000,
15
+ description: "The question, in the person's language.",
16
+ },
17
+ },
18
+ required: ["question"],
19
+ additionalProperties: false,
20
+ },
21
+ effect: "observe",
22
+ });
@@ -10,6 +10,8 @@ export const RESERVED_TOOL_NAMES = [
10
10
  "work_list",
11
11
  "work_complete",
12
12
  "work_fail",
13
+ "work_answer",
14
+ "work_record",
13
15
  "work_run",
14
16
  "work_show",
15
17
  ];
@@ -43,7 +43,7 @@ export interface ModelUsage {
43
43
  /** The part of outputTokens spent on reasoning. */
44
44
  reasoningTokens?: number;
45
45
  }
46
- export declare const TOOL_REJECTION_CODES: readonly ["schema_mismatch", "unknown_tool", "not_allowed", "reserved_path", "outside_workspace", "invalid_path"];
46
+ export declare const TOOL_REJECTION_CODES: readonly ["schema_mismatch", "unknown_tool", "not_allowed", "reserved_path", "outside_workspace", "invalid_path", "limit_reached"];
47
47
  export type ToolRejectionCode = (typeof TOOL_REJECTION_CODES)[number];
48
48
  export interface EventPayloads {
49
49
  "work.created": {
@@ -110,6 +110,12 @@ export interface EventPayloads {
110
110
  "human.message": {
111
111
  text: string;
112
112
  };
113
+ /** A prompt command expanded for the model: its name, where it came from, and the text handed over. */
114
+ "prompt.expanded": {
115
+ name: string;
116
+ source: string;
117
+ text: string;
118
+ };
113
119
  "usage.recorded": {
114
120
  kind: "model_inference";
115
121
  provider: string;
@@ -233,6 +239,7 @@ export declare const payloadFileSchemas: {
233
239
  name: z.ZodString;
234
240
  code: z.ZodEnum<{
235
241
  invalid_path: "invalid_path";
242
+ limit_reached: "limit_reached";
236
243
  not_allowed: "not_allowed";
237
244
  outside_workspace: "outside_workspace";
238
245
  reserved_path: "reserved_path";
@@ -252,6 +259,11 @@ export declare const payloadFileSchemas: {
252
259
  "human.message": z.ZodObject<{
253
260
  text: z.ZodString;
254
261
  }, z.core.$loose>;
262
+ "prompt.expanded": z.ZodObject<{
263
+ name: z.ZodString;
264
+ source: z.ZodString;
265
+ text: z.ZodString;
266
+ }, z.core.$loose>;
255
267
  "usage.recorded": z.ZodDiscriminatedUnion<[z.ZodObject<{
256
268
  kind: z.ZodLiteral<"model_inference">;
257
269
  provider: z.ZodString;
@@ -308,4 +320,10 @@ export declare function eventToFile(event: AnyEvent): EventFile;
308
320
  */
309
321
  export declare function canonical(value: unknown, insideData?: boolean, seen?: WeakSet<object>): unknown;
310
322
  export declare function eventFromFile(input: unknown): AnyEvent;
323
+ /**
324
+ * Validates a payload given in the file form (snake_case, as in spec/schemas/events.v1.json) and
325
+ * returns it in the in-memory form. For events a client hands the runtime to record.
326
+ */
327
+ export declare function parsePayloadFile<T extends EventType>(type: T, payload: unknown): EventPayloads[T];
328
+ export declare function isKnownEventType(type: string): type is EventType;
311
329
  export {};
@@ -7,6 +7,7 @@ export const TOOL_REJECTION_CODES = [
7
7
  "reserved_path",
8
8
  "outside_workspace",
9
9
  "invalid_path",
10
+ "limit_reached",
10
11
  ];
11
12
  // ---------------------------------------------------------------------------
12
13
  // File-side schemas (snake_case). These are the on-disk contract.
@@ -83,6 +84,7 @@ export const payloadFileSchemas = {
83
84
  "human.input_requested": z.looseObject({ call_id: z.string(), question: z.string() }),
84
85
  "human.input_provided": z.looseObject({ call_id: z.string(), answer: z.string() }),
85
86
  "human.message": z.looseObject({ text: z.string() }),
87
+ "prompt.expanded": z.looseObject({ name: z.string(), source: z.string(), text: z.string() }),
86
88
  "usage.recorded": z.discriminatedUnion("kind", [
87
89
  z.looseObject({
88
90
  kind: z.literal("model_inference"),
@@ -202,6 +204,20 @@ export function eventFromFile(input) {
202
204
  payload: payloadFromFile(file.type, payload.data),
203
205
  };
204
206
  }
207
+ /**
208
+ * Validates a payload given in the file form (snake_case, as in spec/schemas/events.v1.json) and
209
+ * returns it in the in-memory form. For events a client hands the runtime to record.
210
+ */
211
+ export function parsePayloadFile(type, payload) {
212
+ const parsed = payloadFileSchemas[type].safeParse(payload);
213
+ if (!parsed.success) {
214
+ throw new OpenshainError("invalid_event", `${type} payload: ${describeIssues(parsed.error)}`);
215
+ }
216
+ return payloadFromFile(type, parsed.data);
217
+ }
218
+ export function isKnownEventType(type) {
219
+ return isKnownType(type);
220
+ }
205
221
  function isKnownType(type) {
206
222
  return Object.hasOwn(payloadFileSchemas, type);
207
223
  }
@@ -0,0 +1,38 @@
1
+ import type { AnyEvent } from "./events.ts";
2
+ /** Why a client gives up on a work, as recorded in `work.failed`. */
3
+ export type FailureReason = "limit_reached" | "model_refusal" | "model_error";
4
+ /**
5
+ * Counts the tool calls of a work the way the limits do: every call the runtime started, plus
6
+ * every rejection that never became a call. A rejection of a started call is not a second call.
7
+ */
8
+ export declare function countToolCalls(events: readonly AnyEvent[]): number;
9
+ export interface PendingQuestion {
10
+ callId: string;
11
+ question: string;
12
+ }
13
+ /**
14
+ * The questions of the work that have no answer yet, oldest first. Call ids of questions are
15
+ * minted by the runtime, so the whole log is searched: a client recording its own model turns
16
+ * must not hide a question.
17
+ */
18
+ export declare function pendingQuestions(events: readonly AnyEvent[]): PendingQuestion[];
19
+ export interface HistoryCall {
20
+ callId: string;
21
+ name: string;
22
+ /** The path the call named, when its input had one. */
23
+ path?: string;
24
+ /** Present once the call has a result; absent while it is still open. */
25
+ isError?: boolean;
26
+ rejected?: string;
27
+ }
28
+ export interface WorkHistory {
29
+ calls: HistoryCall[];
30
+ /** Calls that were started but have no result: the work stopped while they ran. */
31
+ unfinished: HistoryCall[];
32
+ pending: PendingQuestion[];
33
+ toolCalls: number;
34
+ /** Model calls recorded on the work, for a client that counts them against a limit. */
35
+ modelCalls: number;
36
+ }
37
+ /** What a client needs to pick a work up where it stopped. Built from the log alone. */
38
+ export declare function workHistory(events: readonly AnyEvent[]): WorkHistory;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Counts the tool calls of a work the way the limits do: every call the runtime started, plus
3
+ * every rejection that never became a call. A rejection of a started call is not a second call.
4
+ */
5
+ export function countToolCalls(events) {
6
+ let count = 0;
7
+ let started = new Set();
8
+ for (const event of events) {
9
+ if (event.type === "model.completed")
10
+ started = new Set();
11
+ else if (event.type === "tool.called") {
12
+ started.add(event.payload.callId);
13
+ count += 1;
14
+ }
15
+ else if (event.type === "tool.rejected") {
16
+ if (!started.has(event.payload.callId))
17
+ count += 1;
18
+ }
19
+ }
20
+ return count;
21
+ }
22
+ /**
23
+ * The questions of the work that have no answer yet, oldest first. Call ids of questions are
24
+ * minted by the runtime, so the whole log is searched: a client recording its own model turns
25
+ * must not hide a question.
26
+ */
27
+ export function pendingQuestions(events) {
28
+ const answered = new Set(events
29
+ .filter((e) => e.type === "human.input_provided")
30
+ .map((e) => e.payload.callId));
31
+ return events
32
+ .filter((e) => e.type === "human.input_requested")
33
+ .filter((e) => !answered.has(e.payload.callId))
34
+ .map((e) => ({ callId: e.payload.callId, question: e.payload.question }));
35
+ }
36
+ /** What a client needs to pick a work up where it stopped. Built from the log alone. */
37
+ export function workHistory(events) {
38
+ const calls = [];
39
+ const byId = new Map();
40
+ for (const event of events) {
41
+ if (event.type === "tool.called") {
42
+ const { callId, name, input } = event.payload;
43
+ const path = input?.path;
44
+ const call = { callId, name, ...(typeof path === "string" && { path }) };
45
+ calls.push(call);
46
+ byId.set(callId, call);
47
+ }
48
+ else if (event.type === "tool.completed") {
49
+ const { callId, isError } = event.payload;
50
+ const call = byId.get(callId);
51
+ if (call)
52
+ call.isError = isError;
53
+ }
54
+ else if (event.type === "tool.rejected") {
55
+ const { callId, name, code } = event.payload;
56
+ const call = byId.get(callId);
57
+ if (call)
58
+ call.rejected = code;
59
+ else
60
+ calls.push({ callId, name, rejected: code });
61
+ }
62
+ }
63
+ return {
64
+ calls,
65
+ unfinished: calls.filter((c) => c.isError === undefined && c.rejected === undefined),
66
+ pending: pendingQuestions(events),
67
+ toolCalls: countToolCalls(events),
68
+ modelCalls: events.filter((e) => e.type === "model.requested").length,
69
+ };
70
+ }
@@ -39,6 +39,9 @@ export function buildProjection(input) {
39
39
  case "human.message":
40
40
  pushUserPart({ type: "text", text: event.payload.text });
41
41
  break;
42
+ case "prompt.expanded":
43
+ pushUserPart({ type: "text", text: event.payload.text });
44
+ break;
42
45
  case "model.completed": {
43
46
  const content = event.payload.content
44
47
  .filter((part) => part.type !== "opaque" || part.provider === input.providerId)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openshain/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Contracts (provider interfaces), fundamental objects, and the work runtime",
5
5
  "keywords": [
6
6
  "openshain",
@@ -70,7 +70,7 @@ export function parseConfig(text: string, options: ParseConfigOptions = {}): Con
70
70
  }
71
71
 
72
72
  const known = options.modelProviders;
73
- if (known && !known.includes(result.data.model.provider)) {
73
+ if (known && result.data.model && !known.includes(result.data.model.provider)) {
74
74
  throw new OpenshainError(
75
75
  "config",
76
76
  problem(
@@ -46,23 +46,27 @@ export const ConfigFileSchema = z.strictObject({
46
46
  }),
47
47
  principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
48
48
  profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
49
- model: z.strictObject({
50
- provider: identifier,
51
- model: z.string().min(1).max(200),
52
- api_key_env: envVarName,
53
- base_url: z
54
- .url()
55
- .refine((value) => {
56
- const url = new URL(value);
57
- return url.username === "" && url.password === "";
58
- }, "base_url must not carry credentials; use api_key_env")
59
- .refine((value) => {
60
- const url = new URL(value);
61
- return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname));
62
- }, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
63
- .optional(),
64
- options: z.record(z.string(), z.unknown()).optional(),
65
- }),
49
+ model: z
50
+ .strictObject({
51
+ provider: identifier,
52
+ model: z.string().min(1).max(200),
53
+ api_key_env: envVarName,
54
+ base_url: z
55
+ .url()
56
+ .refine((value) => {
57
+ const url = new URL(value);
58
+ return url.username === "" && url.password === "";
59
+ }, "base_url must not carry credentials; use api_key_env")
60
+ .refine((value) => {
61
+ const url = new URL(value);
62
+ return (
63
+ url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname))
64
+ );
65
+ }, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
66
+ .optional(),
67
+ options: z.record(z.string(), z.unknown()).optional(),
68
+ })
69
+ .optional(),
66
70
  tools: z.array(toolProviderRef).default([{ provider: "standard" }]),
67
71
  limits: z
68
72
  .strictObject({
@@ -81,18 +85,22 @@ export type ToolProviderRef =
81
85
  | { module: string; allow: readonly string[] | undefined };
82
86
 
83
87
  /** Configuration as used in code (camelCase). */
88
+ /** The model section of openshain.yaml, as the model providers take it. */
89
+ export interface ModelConfig {
90
+ provider: string;
91
+ model: string;
92
+ apiKeyEnv: string;
93
+ baseUrl: string | undefined;
94
+ options: Record<string, unknown> | undefined;
95
+ }
96
+
84
97
  export interface Config {
85
98
  version: 1;
86
99
  company: { name: string; language: Language };
87
100
  principal: { id: string; name: string };
88
101
  profession: { id: string; instructions: string };
89
- model: {
90
- provider: string;
91
- model: string;
92
- apiKeyEnv: string;
93
- baseUrl: string | undefined;
94
- options: Record<string, unknown> | undefined;
95
- };
102
+ /** The model the interactive CLI runs on. Absent when the workspace is used from other agents only. */
103
+ model?: ModelConfig;
96
104
  tools: ToolProviderRef[];
97
105
  limits: { maxModelCalls: number; maxToolCalls: number; maxOutputTokens: number };
98
106
  debug: { persistRaw: boolean };
@@ -104,13 +112,15 @@ export function toConfig(file: ConfigFile): Config {
104
112
  company: { name: file.company.name, language: file.company.language },
105
113
  principal: { id: file.principal.id, name: file.principal.name },
106
114
  profession: { id: file.profession.id, instructions: file.profession.instructions },
107
- model: {
108
- provider: file.model.provider,
109
- model: file.model.model,
110
- apiKeyEnv: file.model.api_key_env,
111
- baseUrl: file.model.base_url,
112
- options: file.model.options,
113
- },
115
+ ...(file.model && {
116
+ model: {
117
+ provider: file.model.provider,
118
+ model: file.model.model,
119
+ apiKeyEnv: file.model.api_key_env,
120
+ baseUrl: file.model.base_url,
121
+ options: file.model.options,
122
+ },
123
+ }),
114
124
  tools: file.tools.map(toToolProviderRef),
115
125
  limits: {
116
126
  maxModelCalls: file.limits.max_model_calls,
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ export {
5
5
  type ParseConfigOptions,
6
6
  parseConfig,
7
7
  } from "./config/load.ts";
8
- export type { Config, ToolProviderRef } from "./config/schema.ts";
8
+ export type { Config, ModelConfig, ToolProviderRef } from "./config/schema.ts";
9
9
  export { LANGUAGES, type Language } from "./config/schema.ts";
10
10
  export { ERROR_CODES, type ErrorCode, isOpenshainError, OpenshainError } from "./errors.ts";
11
11
  export {
@@ -35,6 +35,7 @@ export {
35
35
  type ToolSummary,
36
36
  } from "./runtime.ts";
37
37
  export { jsonSchemas, type SchemaName } from "./schemas.ts";
38
+ export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.ts";
38
39
  export { loadToolModule } from "./tool/load-module.ts";
39
40
  export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
40
41
  export {
@@ -71,7 +72,9 @@ export {
71
72
  type EventType,
72
73
  eventFromFile,
73
74
  eventToFile,
75
+ isKnownEventType,
74
76
  type ModelUsage,
77
+ parsePayloadFile,
75
78
  payloadFileSchemas,
76
79
  type StopReason,
77
80
  TOOL_REJECTION_CODES,
@@ -79,6 +82,15 @@ export {
79
82
  type ToolRejectionCode,
80
83
  type UnknownEvent,
81
84
  } from "./work/events.ts";
85
+ export {
86
+ countToolCalls,
87
+ type FailureReason,
88
+ type HistoryCall,
89
+ type PendingQuestion,
90
+ pendingQuestions,
91
+ type WorkHistory,
92
+ workHistory,
93
+ } from "./work/history.ts";
82
94
  export { acquireLock, LOCK_FILE_NAME, type Lock } from "./work/lock.ts";
83
95
  export { buildProjection, type Projection, type ProjectionInput } from "./work/projection.ts";
84
96
  export {
package/src/runtime.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "./config/load.ts";
2
- import type { Config } from "./config/schema.ts";
2
+ import type { Config, ModelConfig } from "./config/schema.ts";
3
3
  import { isOpenshainError, OpenshainError } from "./errors.ts";
4
4
  import type { ModelProvider } from "./model/types.ts";
5
5
  import { loadToolModule } from "./tool/load-module.ts";
@@ -12,7 +12,7 @@ import { type WorkHandle, WorkStore } from "./work/store.ts";
12
12
 
13
13
  export interface RuntimeProviders {
14
14
  /** Model providers by the id used in openshain.yaml. */
15
- models: Record<string, (model: Config["model"]) => ModelProvider>;
15
+ models: Record<string, (model: ModelConfig) => ModelProvider>;
16
16
  /** Tool providers by the id used in openshain.yaml. Modules are loaded from the config directly. */
17
17
  tools: Record<string, () => ToolProvider>;
18
18
  }
@@ -50,6 +50,12 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
50
50
  const { workspaceRoot, providers } = options;
51
51
  const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
52
52
 
53
+ if (!config.model) {
54
+ throw new OpenshainError(
55
+ "config",
56
+ "this needs a model: add a model section to openshain.yaml (only the interactive CLI needs one)",
57
+ );
58
+ }
53
59
  const modelFactory = Object.hasOwn(providers.models, config.model.provider)
54
60
  ? providers.models[config.model.provider]
55
61
  : undefined;
@@ -0,0 +1,25 @@
1
+ import { ASK_USER_TOOL_NAME, type ToolDefinition } from "./types.ts";
2
+
3
+ /** The provider id the runtime records for the tools it runs itself. */
4
+ export const RUNTIME_PROVIDER_ID = "runtime";
5
+
6
+ /** The one tool the runtime itself provides: stop and ask the person. */
7
+ export const ASK_USER: Readonly<ToolDefinition> = Object.freeze({
8
+ name: ASK_USER_TOOL_NAME,
9
+ description:
10
+ "Ask the person you work for a question when you cannot proceed without their answer. Use it sparingly; prefer the workspace over guessing. The work waits until the answer is recorded with work_answer.",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ question: {
15
+ type: "string",
16
+ minLength: 1,
17
+ maxLength: 10_000,
18
+ description: "The question, in the person's language.",
19
+ },
20
+ },
21
+ required: ["question"],
22
+ additionalProperties: false,
23
+ },
24
+ effect: "observe",
25
+ });
package/src/tool/types.ts CHANGED
@@ -20,6 +20,8 @@ export const RESERVED_TOOL_NAMES: readonly string[] = [
20
20
  "work_list",
21
21
  "work_complete",
22
22
  "work_fail",
23
+ "work_answer",
24
+ "work_record",
23
25
  "work_run",
24
26
  "work_show",
25
27
  ];
@@ -41,6 +41,7 @@ export const TOOL_REJECTION_CODES = [
41
41
  "reserved_path",
42
42
  "outside_workspace",
43
43
  "invalid_path",
44
+ "limit_reached",
44
45
  ] as const;
45
46
 
46
47
  export type ToolRejectionCode = (typeof TOOL_REJECTION_CODES)[number];
@@ -73,6 +74,8 @@ export interface EventPayloads {
73
74
  "human.input_provided": { callId: string; answer: string };
74
75
  /** What the person said in a session. Becomes a user message in the projection. */
75
76
  "human.message": { text: string };
77
+ /** A prompt command expanded for the model: its name, where it came from, and the text handed over. */
78
+ "prompt.expanded": { name: string; source: string; text: string };
76
79
  "usage.recorded":
77
80
  | { kind: "model_inference"; provider: string; model: string; usage: ModelUsage }
78
81
  | { kind: "tool_execution"; provider: string; usage: { durationMs: number } };
@@ -179,6 +182,7 @@ export const payloadFileSchemas = {
179
182
  "human.input_requested": z.looseObject({ call_id: z.string(), question: z.string() }),
180
183
  "human.input_provided": z.looseObject({ call_id: z.string(), answer: z.string() }),
181
184
  "human.message": z.looseObject({ text: z.string() }),
185
+ "prompt.expanded": z.looseObject({ name: z.string(), source: z.string(), text: z.string() }),
182
186
  "usage.recorded": z.discriminatedUnion("kind", [
183
187
  z.looseObject({
184
188
  kind: z.literal("model_inference"),
@@ -309,6 +313,22 @@ export function eventFromFile(input: unknown): AnyEvent {
309
313
  } as Event;
310
314
  }
311
315
 
316
+ /**
317
+ * Validates a payload given in the file form (snake_case, as in spec/schemas/events.v1.json) and
318
+ * returns it in the in-memory form. For events a client hands the runtime to record.
319
+ */
320
+ export function parsePayloadFile<T extends EventType>(type: T, payload: unknown): EventPayloads[T] {
321
+ const parsed = payloadFileSchemas[type].safeParse(payload);
322
+ if (!parsed.success) {
323
+ throw new OpenshainError("invalid_event", `${type} payload: ${describeIssues(parsed.error)}`);
324
+ }
325
+ return payloadFromFile(type, parsed.data as FilePayload<T>);
326
+ }
327
+
328
+ export function isKnownEventType(type: string): type is EventType {
329
+ return isKnownType(type);
330
+ }
331
+
312
332
  function isKnownType(type: string): type is EventType {
313
333
  return Object.hasOwn(payloadFileSchemas, type);
314
334
  }
@@ -0,0 +1,96 @@
1
+ import type { AnyEvent, Event } from "./events.ts";
2
+
3
+ /** Why a client gives up on a work, as recorded in `work.failed`. */
4
+ export type FailureReason = "limit_reached" | "model_refusal" | "model_error";
5
+
6
+ /**
7
+ * Counts the tool calls of a work the way the limits do: every call the runtime started, plus
8
+ * every rejection that never became a call. A rejection of a started call is not a second call.
9
+ */
10
+ export function countToolCalls(events: readonly AnyEvent[]): number {
11
+ let count = 0;
12
+ let started = new Set<string>();
13
+ for (const event of events) {
14
+ if (event.type === "model.completed") started = new Set();
15
+ else if (event.type === "tool.called") {
16
+ started.add((event as Event<"tool.called">).payload.callId);
17
+ count += 1;
18
+ } else if (event.type === "tool.rejected") {
19
+ if (!started.has((event as Event<"tool.rejected">).payload.callId)) count += 1;
20
+ }
21
+ }
22
+ return count;
23
+ }
24
+
25
+ export interface PendingQuestion {
26
+ callId: string;
27
+ question: string;
28
+ }
29
+
30
+ /**
31
+ * The questions of the work that have no answer yet, oldest first. Call ids of questions are
32
+ * minted by the runtime, so the whole log is searched: a client recording its own model turns
33
+ * must not hide a question.
34
+ */
35
+ export function pendingQuestions(events: readonly AnyEvent[]): PendingQuestion[] {
36
+ const answered = new Set(
37
+ events
38
+ .filter((e): e is Event<"human.input_provided"> => e.type === "human.input_provided")
39
+ .map((e) => e.payload.callId),
40
+ );
41
+ return events
42
+ .filter((e): e is Event<"human.input_requested"> => e.type === "human.input_requested")
43
+ .filter((e) => !answered.has(e.payload.callId))
44
+ .map((e) => ({ callId: e.payload.callId, question: e.payload.question }));
45
+ }
46
+
47
+ export interface HistoryCall {
48
+ callId: string;
49
+ name: string;
50
+ /** The path the call named, when its input had one. */
51
+ path?: string;
52
+ /** Present once the call has a result; absent while it is still open. */
53
+ isError?: boolean;
54
+ rejected?: string;
55
+ }
56
+
57
+ export interface WorkHistory {
58
+ calls: HistoryCall[];
59
+ /** Calls that were started but have no result: the work stopped while they ran. */
60
+ unfinished: HistoryCall[];
61
+ pending: PendingQuestion[];
62
+ toolCalls: number;
63
+ /** Model calls recorded on the work, for a client that counts them against a limit. */
64
+ modelCalls: number;
65
+ }
66
+
67
+ /** What a client needs to pick a work up where it stopped. Built from the log alone. */
68
+ export function workHistory(events: readonly AnyEvent[]): WorkHistory {
69
+ const calls: HistoryCall[] = [];
70
+ const byId = new Map<string, HistoryCall>();
71
+ for (const event of events) {
72
+ if (event.type === "tool.called") {
73
+ const { callId, name, input } = (event as Event<"tool.called">).payload;
74
+ const path = (input as { path?: unknown } | null)?.path;
75
+ const call: HistoryCall = { callId, name, ...(typeof path === "string" && { path }) };
76
+ calls.push(call);
77
+ byId.set(callId, call);
78
+ } else if (event.type === "tool.completed") {
79
+ const { callId, isError } = (event as Event<"tool.completed">).payload;
80
+ const call = byId.get(callId);
81
+ if (call) call.isError = isError;
82
+ } else if (event.type === "tool.rejected") {
83
+ const { callId, name, code } = (event as Event<"tool.rejected">).payload;
84
+ const call = byId.get(callId);
85
+ if (call) call.rejected = code;
86
+ else calls.push({ callId, name, rejected: code });
87
+ }
88
+ }
89
+ return {
90
+ calls,
91
+ unfinished: calls.filter((c) => c.isError === undefined && c.rejected === undefined),
92
+ pending: pendingQuestions(events),
93
+ toolCalls: countToolCalls(events),
94
+ modelCalls: events.filter((e) => e.type === "model.requested").length,
95
+ };
96
+ }
@@ -61,6 +61,9 @@ export function buildProjection(input: ProjectionInput): Projection {
61
61
  case "human.message":
62
62
  pushUserPart({ type: "text", text: (event as Event<"human.message">).payload.text });
63
63
  break;
64
+ case "prompt.expanded":
65
+ pushUserPart({ type: "text", text: (event as Event<"prompt.expanded">).payload.text });
66
+ break;
64
67
  case "model.completed": {
65
68
  const content = (event as Event<"model.completed">).payload.content
66
69
  .filter((part) => part.type !== "opaque" || part.provider === input.providerId)