Skip to content

Commit 2b42830

Browse files
Merge pull request #1537 from corbitsdev/cl-10809-defer-module-scope-arktype-schema-and-isogit-store
perf(boot): defer schema and isogit construction until first use
2 parents 10c30e7 + 3707821 commit 2b42830

17 files changed

Lines changed: 661 additions & 267 deletions

‎src/agent/director.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { onTurnBoundary } from "./reactor-events.js";
2828
import { isOperatorOriginated } from "./message-provenance.js";
2929
import { errorMessage } from "./error-message.js";
3030
import { type } from "arktype";
31+
import { lazyCallable, memoSchema } from "../schema/lazy.js";
3132
import {
3233
composeDirectedPrompt,
3334
inferWithEphemeralNudge,
@@ -398,9 +399,13 @@ export const COMPACTION_FOLD_NONCONVERGED_EVENT =
398399
"custom.compaction.fold_nonconverged";
399400
export const CREDENTIAL_RECOVERY_INTERCHANGE_TYPE =
400401
"system.credential.refresh" as const;
401-
export const ChatTasksChangedDataSchema = type({
402-
tasks: TaskSchema.array(),
403-
});
402+
export const ChatTasksChangedDataSchema = lazyCallable(
403+
memoSchema(() =>
404+
type({
405+
tasks: TaskSchema.array(),
406+
}),
407+
),
408+
);
404409
export const ChatToolsActivateDataSchema = type({
405410
names: "string[]",
406411
});

‎src/agent/profiles.ts‎

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { join } from "node:path";
33

44
import { type } from "arktype";
55

6+
import { lazyCallable, memoSchema } from "../schema/lazy.js";
7+
68
import { defaultAgentsPlugin as defaultPlugin } from "./default-agents.js";
79
import { isDirectorId } from "./directors/registry.js";
810
import { REASONING_EFFORTS } from "./profile-types.js";
@@ -16,45 +18,50 @@ export type {
1618
} from "./profile-types.js";
1719
import type { AgentProfile } from "./profile-types.js";
1820

21+
function buildAgentProfileSchema() {
22+
const CapabilityFilterSchema = type({
23+
mode: "'exclude' | 'allow'",
24+
tools: "string[]",
25+
});
26+
27+
// Reasoning-effort schema derived from the canonical array. arktype's `type()`
28+
// is statically typed for literal strings; a computed string requires a cast
29+
// through `unknown`. The schema is exercised by src/plugins/data-only-agent
30+
// and the runtime ReasoningEffort re-export, so drift is caught.
31+
const reasoningEffortLiteral = REASONING_EFFORTS.map((e) => `'${e}'`).join(
32+
" | ",
33+
);
34+
const ReasoningEffortSchema = type(
35+
reasoningEffortLiteral as unknown as "'none'",
36+
);
37+
38+
const InferenceLegSchema = type({
39+
provider: "string>0",
40+
model: "string>0",
41+
"reasoningEffort?": ReasoningEffortSchema,
42+
});
43+
44+
const InferenceSpecSchema = type({
45+
"mode?": "'pin' | 'prefer'",
46+
order: InferenceLegSchema.array(),
47+
});
48+
49+
return type({
50+
id: "string",
51+
"description?": "string",
52+
"inference?": InferenceSpecSchema,
53+
"capabilities?": CapabilityFilterSchema,
54+
"systemPromptRole?": "string",
55+
"systemPromptPath?": "string",
56+
"orchestrator?": "boolean",
57+
});
58+
}
59+
60+
const agentProfileSchema = memoSchema(buildAgentProfileSchema);
61+
1962
// Exported so agent-kind plugins can validate contributed profiles.
20-
export { AgentProfileSchema };
21-
22-
const CapabilityFilterSchema = type({
23-
mode: "'exclude' | 'allow'",
24-
tools: "string[]",
25-
});
26-
27-
// Reasoning-effort schema derived from the canonical array. arktype's `type()`
28-
// is statically typed for literal strings; a computed string requires a cast
29-
// through `unknown`. The schema is exercised by src/plugins/data-only-agent
30-
// and the runtime ReasoningEffort re-export, so drift is caught.
31-
const reasoningEffortLiteral = REASONING_EFFORTS.map((e) => `'${e}'`).join(
32-
" | ",
33-
);
34-
const ReasoningEffortSchema = type(
35-
reasoningEffortLiteral as unknown as "'none'",
36-
);
37-
38-
const InferenceLegSchema = type({
39-
provider: "string>0",
40-
model: "string>0",
41-
"reasoningEffort?": ReasoningEffortSchema,
42-
});
43-
44-
const InferenceSpecSchema = type({
45-
"mode?": "'pin' | 'prefer'",
46-
order: InferenceLegSchema.array(),
47-
});
48-
49-
const AgentProfileSchema = type({
50-
id: "string",
51-
"description?": "string",
52-
"inference?": InferenceSpecSchema,
53-
"capabilities?": CapabilityFilterSchema,
54-
"systemPromptRole?": "string",
55-
"systemPromptPath?": "string",
56-
"orchestrator?": "boolean",
57-
});
63+
export const AgentProfileSchema: ReturnType<typeof buildAgentProfileSchema> =
64+
lazyCallable(agentProfileSchema);
5865

5966
function isENOENT(err: unknown): boolean {
6067
return (

‎src/agent/tasks.ts‎

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
import { type } from "arktype";
22
import type { ToolDefinition } from "@intx/types/runtime";
33

4+
import { lazyCallable, memoSchema } from "../schema/lazy.js";
5+
46
// A task is a unit of work the agent registered for itself. The agent owns the
57
// list — it adds, renames, cancels, and status-updates items mid-run.
6-
export const TaskStatusSchema = type("'todo' | 'doing' | 'done' | 'cancelled'");
7-
export type TaskStatus = typeof TaskStatusSchema.infer;
8+
function buildTaskStatusSchema() {
9+
return type("'todo' | 'doing' | 'done' | 'cancelled'");
10+
}
11+
const taskStatusSchema = memoSchema(buildTaskStatusSchema);
12+
export type TaskStatus = ReturnType<typeof buildTaskStatusSchema>["infer"];
813

914
// GLM (and other Claude-shaped) payloads send in_progress; map it at the
1015
// boundary so stored tasks stay on the todo/doing/done/cancelled enum.
11-
const TaskStatusInput = type(
12-
"'todo' | 'doing' | 'done' | 'cancelled' | 'in_progress'",
13-
).pipe((s): TaskStatus => (s === "in_progress" ? "doing" : s));
14-
15-
export const TaskSchema = type({
16-
id: "string>0",
17-
title: "string>0",
18-
status: TaskStatusSchema,
19-
});
20-
export type Task = typeof TaskSchema.infer;
16+
const taskStatusInput = memoSchema(() =>
17+
type("'todo' | 'doing' | 'done' | 'cancelled' | 'in_progress'").pipe(
18+
(s): TaskStatus => (s === "in_progress" ? "doing" : s),
19+
),
20+
);
21+
22+
function buildTaskSchema() {
23+
return type({
24+
id: "string>0",
25+
title: "string>0",
26+
status: taskStatusSchema(),
27+
});
28+
}
29+
const taskSchema = memoSchema(buildTaskSchema);
30+
export const TaskSchema: ReturnType<typeof buildTaskSchema> =
31+
lazyCallable(taskSchema);
32+
export type Task = ReturnType<typeof buildTaskSchema>["infer"];
2133

2234
// A task list has active work while any task is still todo or doing. Terminal
2335
// tasks (done/cancelled) are resolved, so an all-terminal list is finished.
@@ -27,21 +39,23 @@ export function hasActiveTasks(tasks: Task[]): boolean {
2739

2840
// `create` overwrites the list; `update` patches by id and may append new
2941
// items (unknown id + title). One multi-purpose tool keeps the schema small.
30-
const ManageTasksArgsSchema = type({
31-
action: "'create' | 'update'",
32-
"tasks?": type({
33-
id: "string>0",
34-
title: "string>0",
35-
"status?": TaskStatusInput,
36-
}).array(),
37-
"updates?": type({
38-
id: "string>0",
39-
"title?": "string>0",
40-
"status?": TaskStatusInput,
41-
}).array(),
42-
});
43-
44-
export type ManageTasksArgs = typeof ManageTasksArgsSchema.infer;
42+
const manageTasksArgsSchema = memoSchema(() =>
43+
type({
44+
action: "'create' | 'update'",
45+
"tasks?": type({
46+
id: "string>0",
47+
title: "string>0",
48+
"status?": taskStatusInput(),
49+
}).array(),
50+
"updates?": type({
51+
id: "string>0",
52+
"title?": "string>0",
53+
"status?": taskStatusInput(),
54+
}).array(),
55+
}),
56+
);
57+
58+
export type ManageTasksArgs = ReturnType<typeof manageTasksArgsSchema>["infer"];
4559

4660
export const manageTasksDefinition: ToolDefinition = {
4761
name: "manage_tasks",
@@ -109,7 +123,7 @@ export const manageTasksDefinition: ToolDefinition = {
109123
// Clone first: arktype's in_progress→doing morph writes onto the input, and
110124
// reactor snapshots deep-freeze tool_call.arguments before decide() runs.
111125
export function parseManageTasksArgs(rawArgs: unknown): ManageTasksArgs | null {
112-
const result = ManageTasksArgsSchema(cloneManageTasksInput(rawArgs));
126+
const result = manageTasksArgsSchema()(cloneManageTasksInput(rawArgs));
113127
return result instanceof type.errors ? null : result;
114128
}
115129

@@ -179,7 +193,7 @@ export type ManageTasksRunner = (rawArgs: Record<string, unknown>) => Promise<{
179193
export function createManageTasksRunner(): ManageTasksRunner {
180194
let tasks: Task[] = [];
181195
return async (rawArgs) => {
182-
const parsed = ManageTasksArgsSchema(cloneManageTasksInput(rawArgs));
196+
const parsed = manageTasksArgsSchema()(cloneManageTasksInput(rawArgs));
183197
if (parsed instanceof type.errors) {
184198
return {
185199
content: `Error: manage_tasks arguments invalid: ${parsed.summary}`,

‎src/config/profiles.ts‎

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,37 @@ import { join } from "node:path";
44
import { type } from "arktype";
55
import { SETTINGS_DIR_NAME } from "../branding.js";
66
import { GUIDELINE_SUB_BLOCK_IDS } from "../agent/prompts.js";
7+
import { memoSchema } from "../schema/lazy.js";
78

8-
const ProfileSchema = type({
9-
"profile?": "string",
10-
"model?": "string",
11-
"systemPromptExtensions?": "string[]",
12-
// Guideline sub-block ids to drop from the chat system prompt (see
13-
// GUIDELINE_SUB_BLOCK_IDS in agent/prompts.ts). Omitted = full guidelines.
14-
// Unknown ids are rejected so a profile typo fails closed at load.
15-
"promptSectionOmit?": type.enumerated(...GUIDELINE_SUB_BLOCK_IDS).array(),
16-
// Per-call inactivity timeout in milliseconds. If the provider yields no
17-
// inference event for this long, the call aborts with "Request timed out".
18-
// Default in the inference harness is 120_000 (2 min); tune higher for
19-
// reasoning models with long silent-thinking stretches.
20-
"inactivityTimeoutMs?": "number >= 1",
21-
// Per-call total wall-clock cap in milliseconds. Starts at fetch. Default
22-
// in the inference harness is 600_000 (10 min). Backstop for streams that
23-
// never terminate.
24-
"totalTimeoutMs?": "number >= 1",
25-
// Per-call cap for the compaction summary call in milliseconds. Default
26-
// 90_000 — well under totalTimeoutMs because compaction runs inline on the
27-
// reactor and a stuck summary call freezes the session.
28-
"summarizerTimeoutMs?": "number >= 1",
29-
"+": "reject",
30-
});
9+
function buildProfileSchema() {
10+
return type({
11+
"profile?": "string",
12+
"model?": "string",
13+
"systemPromptExtensions?": "string[]",
14+
// Guideline sub-block ids to drop from the chat system prompt (see
15+
// GUIDELINE_SUB_BLOCK_IDS in agent/prompts.ts). Omitted = full guidelines.
16+
// Unknown ids are rejected so a profile typo fails closed at load.
17+
"promptSectionOmit?": type.enumerated(...GUIDELINE_SUB_BLOCK_IDS).array(),
18+
// Per-call inactivity timeout in milliseconds. If the provider yields no
19+
// inference event for this long, the call aborts with "Request timed out".
20+
// Default in the inference harness is 120_000 (2 min); tune higher for
21+
// reasoning models with long silent-thinking stretches.
22+
"inactivityTimeoutMs?": "number >= 1",
23+
// Per-call total wall-clock cap in milliseconds. Starts at fetch. Default
24+
// in the inference harness is 600_000 (10 min). Backstop for streams that
25+
// never terminate.
26+
"totalTimeoutMs?": "number >= 1",
27+
// Per-call cap for the compaction summary call in milliseconds. Default
28+
// 90_000 — well under totalTimeoutMs because compaction runs inline on the
29+
// reactor and a stuck summary call freezes the session.
30+
"summarizerTimeoutMs?": "number >= 1",
31+
"+": "reject",
32+
});
33+
}
34+
35+
const profileSchema = memoSchema(buildProfileSchema);
3136

32-
export type ProfileConfig = typeof ProfileSchema.infer;
37+
export type ProfileConfig = ReturnType<typeof buildProfileSchema>["infer"];
3338

3439
export function profilesDir(home: string = homedir()): string {
3540
return join(home, SETTINGS_DIR_NAME, "profiles");
@@ -49,7 +54,7 @@ function isENOENT(err: unknown): boolean {
4954
}
5055

5156
function validateProfileConfig(value: unknown, path: string): ProfileConfig {
52-
const result = ProfileSchema(value);
57+
const result = profileSchema()(value);
5358
if (result instanceof type.errors) {
5459
throw new Error(`Invalid profile at ${path}: ${result.summary}`);
5560
}
Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,51 @@
11
import { type } from "arktype";
22

3+
import { lazyCallable, memoSchema } from "../schema/lazy.js";
4+
5+
const contextWindow = memoSchema(() => type("number.integer > 0"));
6+
const maxTokens = memoSchema(() => type("number.integer > 0"));
7+
const temperature = memoSchema(() => type("0 <= number <= 2"));
8+
const topP = memoSchema(() => type("0 <= number <= 1"));
9+
310
export const ProviderInferenceOptionSchemas = {
4-
contextWindow: type("number.integer > 0"),
5-
maxTokens: type("number.integer > 0"),
6-
temperature: type("0 <= number <= 2"),
7-
topP: type("0 <= number <= 1"),
11+
get contextWindow() {
12+
return contextWindow();
13+
},
14+
get maxTokens() {
15+
return maxTokens();
16+
},
17+
get temperature() {
18+
return temperature();
19+
},
20+
get topP() {
21+
return topP();
22+
},
823
};
924

10-
export const ProviderInferenceOptionsSchema = type({
11-
"contextWindow?": ProviderInferenceOptionSchemas.contextWindow,
12-
"maxTokens?": ProviderInferenceOptionSchemas.maxTokens,
13-
"temperature?": ProviderInferenceOptionSchemas.temperature,
14-
"topP?": ProviderInferenceOptionSchemas.topP,
15-
}).narrow(
16-
(options, ctx) =>
17-
options.temperature === undefined ||
18-
options.topP === undefined ||
19-
ctx.mustBe("sampling options with only one of temperature and top p"),
25+
function buildProviderInferenceOptionsSchema() {
26+
return type({
27+
"contextWindow?": contextWindow(),
28+
"maxTokens?": maxTokens(),
29+
"temperature?": temperature(),
30+
"topP?": topP(),
31+
}).narrow(
32+
(options, ctx) =>
33+
options.temperature === undefined ||
34+
options.topP === undefined ||
35+
ctx.mustBe("sampling options with only one of temperature and top p"),
36+
);
37+
}
38+
39+
const providerInferenceOptionsSchema = memoSchema(
40+
buildProviderInferenceOptionsSchema,
2041
);
42+
43+
export function readProviderInferenceOptionsSchema(): ReturnType<
44+
typeof buildProviderInferenceOptionsSchema
45+
> {
46+
return providerInferenceOptionsSchema();
47+
}
48+
49+
export const ProviderInferenceOptionsSchema: ReturnType<
50+
typeof buildProviderInferenceOptionsSchema
51+
> = lazyCallable(providerInferenceOptionsSchema);

‎src/config/settings-lazy.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { expect, test } from "bun:test";
5+
6+
import { schemaMaterializations } from "../schema/lazy.js";
7+
8+
test("importing settings does not build schemas; a malformed file fails when read", async () => {
9+
const before = schemaMaterializations();
10+
const settings = await import("./settings.js");
11+
expect(schemaMaterializations()).toBe(before);
12+
13+
const dir = await mkdtemp(join(tmpdir(), "lazy-settings-"));
14+
try {
15+
const path = join(dir, "settings.json");
16+
await writeFile(path, JSON.stringify({ providers: "nope" }));
17+
await expect(settings.loadSettings(path)).rejects.toThrow();
18+
expect(schemaMaterializations()).toBeGreaterThan(before);
19+
} finally {
20+
await rm(dir, { recursive: true, force: true });
21+
}
22+
});

0 commit comments

Comments
 (0)