Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { onTurnBoundary } from "./reactor-events.js";
import { isOperatorOriginated } from "./message-provenance.js";
import { errorMessage } from "./error-message.js";
import { type } from "arktype";
import { lazyCallable, memoSchema } from "../schema/lazy.js";
import {
composeDirectedPrompt,
inferWithEphemeralNudge,
Expand Down Expand Up @@ -398,9 +399,13 @@ export const COMPACTION_FOLD_NONCONVERGED_EVENT =
"custom.compaction.fold_nonconverged";
export const CREDENTIAL_RECOVERY_INTERCHANGE_TYPE =
"system.credential.refresh" as const;
export const ChatTasksChangedDataSchema = type({
tasks: TaskSchema.array(),
});
export const ChatTasksChangedDataSchema = lazyCallable(
memoSchema(() =>
type({
tasks: TaskSchema.array(),
}),
),
);
export const ChatToolsActivateDataSchema = type({
names: "string[]",
});
Expand Down
83 changes: 45 additions & 38 deletions src/agent/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { join } from "node:path";

import { type } from "arktype";

import { lazyCallable, memoSchema } from "../schema/lazy.js";

import { defaultAgentsPlugin as defaultPlugin } from "./default-agents.js";
import { isDirectorId } from "./directors/registry.js";
import { REASONING_EFFORTS } from "./profile-types.js";
Expand All @@ -16,45 +18,50 @@ export type {
} from "./profile-types.js";
import type { AgentProfile } from "./profile-types.js";

function buildAgentProfileSchema() {
const CapabilityFilterSchema = type({
mode: "'exclude' | 'allow'",
tools: "string[]",
});

// Reasoning-effort schema derived from the canonical array. arktype's `type()`
// is statically typed for literal strings; a computed string requires a cast
// through `unknown`. The schema is exercised by src/plugins/data-only-agent
// and the runtime ReasoningEffort re-export, so drift is caught.
const reasoningEffortLiteral = REASONING_EFFORTS.map((e) => `'${e}'`).join(
" | ",
);
const ReasoningEffortSchema = type(
reasoningEffortLiteral as unknown as "'none'",
);

const InferenceLegSchema = type({
provider: "string>0",
model: "string>0",
"reasoningEffort?": ReasoningEffortSchema,
});

const InferenceSpecSchema = type({
"mode?": "'pin' | 'prefer'",
order: InferenceLegSchema.array(),
});

return type({
id: "string",
"description?": "string",
"inference?": InferenceSpecSchema,
"capabilities?": CapabilityFilterSchema,
"systemPromptRole?": "string",
"systemPromptPath?": "string",
"orchestrator?": "boolean",
});
}

const agentProfileSchema = memoSchema(buildAgentProfileSchema);

// Exported so agent-kind plugins can validate contributed profiles.
export { AgentProfileSchema };

const CapabilityFilterSchema = type({
mode: "'exclude' | 'allow'",
tools: "string[]",
});

// Reasoning-effort schema derived from the canonical array. arktype's `type()`
// is statically typed for literal strings; a computed string requires a cast
// through `unknown`. The schema is exercised by src/plugins/data-only-agent
// and the runtime ReasoningEffort re-export, so drift is caught.
const reasoningEffortLiteral = REASONING_EFFORTS.map((e) => `'${e}'`).join(
" | ",
);
const ReasoningEffortSchema = type(
reasoningEffortLiteral as unknown as "'none'",
);

const InferenceLegSchema = type({
provider: "string>0",
model: "string>0",
"reasoningEffort?": ReasoningEffortSchema,
});

const InferenceSpecSchema = type({
"mode?": "'pin' | 'prefer'",
order: InferenceLegSchema.array(),
});

const AgentProfileSchema = type({
id: "string",
"description?": "string",
"inference?": InferenceSpecSchema,
"capabilities?": CapabilityFilterSchema,
"systemPromptRole?": "string",
"systemPromptPath?": "string",
"orchestrator?": "boolean",
});
export const AgentProfileSchema: ReturnType<typeof buildAgentProfileSchema> =
lazyCallable(agentProfileSchema);

function isENOENT(err: unknown): boolean {
return (
Expand Down
72 changes: 43 additions & 29 deletions src/agent/tasks.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import { type } from "arktype";
import type { ToolDefinition } from "@intx/types/runtime";

import { lazyCallable, memoSchema } from "../schema/lazy.js";

// A task is a unit of work the agent registered for itself. The agent owns the
// list — it adds, renames, cancels, and status-updates items mid-run.
export const TaskStatusSchema = type("'todo' | 'doing' | 'done' | 'cancelled'");
export type TaskStatus = typeof TaskStatusSchema.infer;
function buildTaskStatusSchema() {
return type("'todo' | 'doing' | 'done' | 'cancelled'");
}
const taskStatusSchema = memoSchema(buildTaskStatusSchema);
export type TaskStatus = ReturnType<typeof buildTaskStatusSchema>["infer"];

// GLM (and other Claude-shaped) payloads send in_progress; map it at the
// boundary so stored tasks stay on the todo/doing/done/cancelled enum.
const TaskStatusInput = type(
"'todo' | 'doing' | 'done' | 'cancelled' | 'in_progress'",
).pipe((s): TaskStatus => (s === "in_progress" ? "doing" : s));

export const TaskSchema = type({
id: "string>0",
title: "string>0",
status: TaskStatusSchema,
});
export type Task = typeof TaskSchema.infer;
const taskStatusInput = memoSchema(() =>
type("'todo' | 'doing' | 'done' | 'cancelled' | 'in_progress'").pipe(
(s): TaskStatus => (s === "in_progress" ? "doing" : s),
),
);

function buildTaskSchema() {
return type({
id: "string>0",
title: "string>0",
status: taskStatusSchema(),
});
}
const taskSchema = memoSchema(buildTaskSchema);
export const TaskSchema: ReturnType<typeof buildTaskSchema> =
lazyCallable(taskSchema);
export type Task = ReturnType<typeof buildTaskSchema>["infer"];

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

// `create` overwrites the list; `update` patches by id and may append new
// items (unknown id + title). One multi-purpose tool keeps the schema small.
const ManageTasksArgsSchema = type({
action: "'create' | 'update'",
"tasks?": type({
id: "string>0",
title: "string>0",
"status?": TaskStatusInput,
}).array(),
"updates?": type({
id: "string>0",
"title?": "string>0",
"status?": TaskStatusInput,
}).array(),
});

export type ManageTasksArgs = typeof ManageTasksArgsSchema.infer;
const manageTasksArgsSchema = memoSchema(() =>
type({
action: "'create' | 'update'",
"tasks?": type({
id: "string>0",
title: "string>0",
"status?": taskStatusInput(),
}).array(),
"updates?": type({
id: "string>0",
"title?": "string>0",
"status?": taskStatusInput(),
}).array(),
}),
);

export type ManageTasksArgs = ReturnType<typeof manageTasksArgsSchema>["infer"];

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

Expand Down Expand Up @@ -179,7 +193,7 @@ export type ManageTasksRunner = (rawArgs: Record<string, unknown>) => Promise<{
export function createManageTasksRunner(): ManageTasksRunner {
let tasks: Task[] = [];
return async (rawArgs) => {
const parsed = ManageTasksArgsSchema(cloneManageTasksInput(rawArgs));
const parsed = manageTasksArgsSchema()(cloneManageTasksInput(rawArgs));
if (parsed instanceof type.errors) {
return {
content: `Error: manage_tasks arguments invalid: ${parsed.summary}`,
Expand Down
55 changes: 30 additions & 25 deletions src/config/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,37 @@ import { join } from "node:path";
import { type } from "arktype";
import { SETTINGS_DIR_NAME } from "../branding.js";
import { GUIDELINE_SUB_BLOCK_IDS } from "../agent/prompts.js";
import { memoSchema } from "../schema/lazy.js";

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

const profileSchema = memoSchema(buildProfileSchema);

export type ProfileConfig = typeof ProfileSchema.infer;
export type ProfileConfig = ReturnType<typeof buildProfileSchema>["infer"];

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

function validateProfileConfig(value: unknown, path: string): ProfileConfig {
const result = ProfileSchema(value);
const result = profileSchema()(value);
if (result instanceof type.errors) {
throw new Error(`Invalid profile at ${path}: ${result.summary}`);
}
Expand Down
59 changes: 45 additions & 14 deletions src/config/provider-inference-options.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,51 @@
import { type } from "arktype";

import { lazyCallable, memoSchema } from "../schema/lazy.js";

const contextWindow = memoSchema(() => type("number.integer > 0"));
const maxTokens = memoSchema(() => type("number.integer > 0"));
const temperature = memoSchema(() => type("0 <= number <= 2"));
const topP = memoSchema(() => type("0 <= number <= 1"));

export const ProviderInferenceOptionSchemas = {
contextWindow: type("number.integer > 0"),
maxTokens: type("number.integer > 0"),
temperature: type("0 <= number <= 2"),
topP: type("0 <= number <= 1"),
get contextWindow() {
return contextWindow();
},
get maxTokens() {
return maxTokens();
},
get temperature() {
return temperature();
},
get topP() {
return topP();
},
};

export const ProviderInferenceOptionsSchema = type({
"contextWindow?": ProviderInferenceOptionSchemas.contextWindow,
"maxTokens?": ProviderInferenceOptionSchemas.maxTokens,
"temperature?": ProviderInferenceOptionSchemas.temperature,
"topP?": ProviderInferenceOptionSchemas.topP,
}).narrow(
(options, ctx) =>
options.temperature === undefined ||
options.topP === undefined ||
ctx.mustBe("sampling options with only one of temperature and top p"),
function buildProviderInferenceOptionsSchema() {
return type({
"contextWindow?": contextWindow(),
"maxTokens?": maxTokens(),
"temperature?": temperature(),
"topP?": topP(),
}).narrow(
(options, ctx) =>
options.temperature === undefined ||
options.topP === undefined ||
ctx.mustBe("sampling options with only one of temperature and top p"),
);
}

const providerInferenceOptionsSchema = memoSchema(
buildProviderInferenceOptionsSchema,
);

export function readProviderInferenceOptionsSchema(): ReturnType<
typeof buildProviderInferenceOptionsSchema
> {
return providerInferenceOptionsSchema();
}

export const ProviderInferenceOptionsSchema: ReturnType<
typeof buildProviderInferenceOptionsSchema
> = lazyCallable(providerInferenceOptionsSchema);
22 changes: 22 additions & 0 deletions src/config/settings-lazy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "bun:test";

import { schemaMaterializations } from "../schema/lazy.js";

test("importing settings does not build schemas; a malformed file fails when read", async () => {
const before = schemaMaterializations();
const settings = await import("./settings.js");
expect(schemaMaterializations()).toBe(before);

const dir = await mkdtemp(join(tmpdir(), "lazy-settings-"));
try {
const path = join(dir, "settings.json");
await writeFile(path, JSON.stringify({ providers: "nope" }));
await expect(settings.loadSettings(path)).rejects.toThrow();
expect(schemaMaterializations()).toBeGreaterThan(before);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
Loading
Loading