import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Api, Model, ServiceTier } from "@earendil-works/pi-ai";
import type { AgentSession } from "./agent-session.js";
import type { ToolDefinition } from "./extensions/index.js";
import type { HostRequestHandler } from "./kernel/index.js";
import type { RlmChildContextContract, RlmResultRead, RlmResultSubmission, RlmResultTurn } from "./rlm-contract.js";
import { THINKING_LEVELS } from "./thinking-levels.js";
/** Request emitted by `rlm.run`; cellSourceCode preserves the spawning cell for display. */
export interface RlmRunRequest {
prompt: string;
kwargs: Record<string, unknown>;
cellSourceCode?: string;
}
export interface RlmSpawnHandle {
rlm_child_id: string;
name: string;
session_dir: string;
model: string;
result_path?: string;
}
export type RlmSubagentRegistryStatus = "running" | "completed" | "error";
export interface RlmSubagentRegistryEntry {
rlm_child_id: string;
active_session_id: string | null;
session_id: string | null;
session_name: string;
session_dir: string;
status: RlmSubagentRegistryStatus;
}
export interface RlmListSubagentsResult {
subagents: RlmSubagentRegistryEntry[];
}
export interface RlmDeleteSubagentResult {
subagent: RlmSubagentRegistryEntry;
outcome?: "deleted" | "skipped_running";
}
export interface RlmModelMatch {
provider: string;
id: string;
name: string;
selector: string;
}
export interface RlmFindModelsResult {
models: RlmModelMatch[];
}
export type RlmRunHandler = (request: RlmRunRequest) => Promise<Record<string, unknown>>;
export type RlmListSubagentsHandler = () => RlmListSubagentsResult | Promise<RlmListSubagentsResult>;
export type RlmDeleteSubagentHandler = (target: string) => Promise<RlmDeleteSubagentResult>;
export type RlmGetResultHandler = (target: string, fields?: string[], generation?: number) => Promise<RlmResultRead>;
export type RlmSubmitResultHandler = (value: unknown) => Promise<RlmResultSubmission>;
export interface RlmResultController {
contract: { context?: RlmChildContextContract; result?: { required: boolean } };
currentResultTurn?(): RlmResultTurn | undefined;
beginResultTurn?(turnKey: string): RlmResultTurn;
completeResultTurn?(turnKey: string, repliedToParent: boolean): RlmResultTurn;
settleResultTurn?(turnKey: string): { turn: RlmResultTurn; error?: string };
failResultTurn?(turnKey: string, error?: string): RlmResultTurn;
markResultTurnNotified?(turnKey: string): RlmResultTurn;
resultTurnInstructions?(turn: RlmResultTurn): string;
submitResult(value: unknown): Promise<RlmResultSubmission>;
}
export type RlmFindModelsHandler = (query: string, limit: number) => RlmFindModelsResult | Promise<RlmFindModelsResult>;
const RLM_SUBAGENT_SESSION_NAME_MAX_LENGTH = 64;
export const DEFAULT_RLM_MODEL_SEARCH_LIMIT = 8;
export const MAX_RLM_MODEL_SEARCH_LIMIT = 20;
export function normalizeRequestedRlmSubagentSessionName(value: unknown): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error("rlm.run name must be a string");
}
const name = value.trim();
if (!name) {
throw new Error("rlm.run name must not be empty");
}
if (name.length > RLM_SUBAGENT_SESSION_NAME_MAX_LENGTH) {
throw new Error(`rlm.run name must be at most ${RLM_SUBAGENT_SESSION_NAME_MAX_LENGTH} characters`);
}
return name;
}
export function normalizeRequestedRlmSubagentThinkingLevel(value: unknown): ThinkingLevel | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error("rlm.run thinking must be a string");
}
const level = value.trim().toLowerCase();
if (!THINKING_LEVELS.includes(level as ThinkingLevel)) {
throw new Error(`rlm.run thinking must be one of: ${THINKING_LEVELS.join(", ")}`);
}
return level as ThinkingLevel;
}
export function normalizeRequestedRlmSubagentModel(value: unknown): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error("rlm.run model must be a string");
}
const model = value.trim();
if (!model) {
throw new Error("rlm.run model must not be empty");
}
return model;
}
export function requireRequestedRlmSubagentModel(value: unknown): string {
const model = normalizeRequestedRlmSubagentModel(value);
if (!model) {
throw new Error('rlm.run requires model="provider/model"; the parent model is never inherited implicitly');
}
return model;
}
/** Create a readable, collision-resistant default name usable as an agent-message selector. */
export function createDefaultRlmSubagentSessionName(prompt: string, childId: string): string {
const promptSlug = prompt
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const idSuffix =
childId
.replace(/^sub-/, "")
.replace(/[^A-Za-z0-9]+/g, "")
.slice(-8) || "child";
const fixedLength = "subagent--".length + idSuffix.length;
const promptPart = (promptSlug || "worker")
.slice(0, Math.max(1, RLM_SUBAGENT_SESSION_NAME_MAX_LENGTH - fixedLength))
.replace(/-+$/g, "");
return `subagent-${promptPart || "worker"}-${idSuffix}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeModelSearchText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
export function findRlmModelMatches(query: string, models: Model<Api>[], limit: number): RlmModelMatch[] {
const normalizedQuery = normalizeModelSearchText(query.trim());
return models
.map((model) => {
const selector = `${model.provider}/${model.id}`;
const fields = [selector, model.id, model.name || model.id];
const normalizedFields = fields.map(normalizeModelSearchText);
let score = normalizedQuery ? Number.POSITIVE_INFINITY : 0;
if (normalizedQuery) {
const exactIndex = normalizedFields.indexOf(normalizedQuery);
const prefixIndex = normalizedFields.findIndex((field) => field.startsWith(normalizedQuery));
const partialIndex = normalizedFields.findIndex((field) => field.includes(normalizedQuery));
if (exactIndex >= 0) score = exactIndex;
else if (prefixIndex >= 0) score = 3 + prefixIndex;
else if (partialIndex >= 0) score = 6 + partialIndex;
}
return { model, selector, score };
})
.filter((candidate) => Number.isFinite(candidate.score))
.sort((a, b) => a.score - b.score || a.selector.localeCompare(b.selector))
.slice(0, limit)
.map(({ model, selector }) => ({
provider: model.provider,
id: model.id,
name: model.name || model.id,
selector,
}));
}
/** Adapt an RlmRunHandler into the typed `rlm.run` kernel host handler. */
export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHandler {
return async (payload) => {
if (typeof payload.prompt !== "string") {
throw new Error("rlm.run prompt must be a string");
}
const kwargs = isRecord(payload.kwargs) ? payload.kwargs : {};
requireRequestedRlmSubagentModel(kwargs.model);
if (kwargs.unguarded !== undefined && typeof kwargs.unguarded !== "boolean") {
throw new Error("rlm.run unguarded must be a boolean");
}
// The agent-facing spawn API is fail-closed: a child's communication with
// its parent must be schema-bound unless the caller explicitly opts out.
if (kwargs.result === undefined && kwargs.unguarded !== true) {
throw new Error(
"rlm.run requires a result contract so the child's communication with the parent is schema-bound and context-guarded; pass result={'schema': schema, 'required': True} (the file sink is automatic) or the explicit opt-out unguarded=True",
);
}
const cellSourceCode = typeof payload.cellSourceCode === "string" ? payload.cellSourceCode : undefined;
const result = await handler({
prompt: payload.prompt,
kwargs,
cellSourceCode,
});
return result as unknown as Record<string, unknown>;
};
}
/** Search a bounded authenticated model catalog without adding it to the system prompt. */
export function createRlmFindModelsHostHandler(handler: RlmFindModelsHandler): HostRequestHandler {
return async (payload) => {
if (typeof payload.query !== "string") {
throw new Error("rlm.find_models query must be a string");
}
const limit = payload.limit === undefined ? DEFAULT_RLM_MODEL_SEARCH_LIMIT : payload.limit;
if (!Number.isInteger(limit) || (limit as number) < 1 || (limit as number) > MAX_RLM_MODEL_SEARCH_LIMIT) {
throw new Error(`rlm.find_models limit must be an integer from 1 to ${MAX_RLM_MODEL_SEARCH_LIMIT}`);
}
return { models: (await handler(payload.query, limit as number)).models };
};
}
/** Submit the current child's host-bound structured result. */
export function createRlmSubmitResultHostHandler(handler: RlmSubmitResultHandler): HostRequestHandler {
return async (payload) => {
if (!("value" in payload)) throw new Error("rlm.submit_result requires a value");
return (await handler(payload.value)) as unknown as Record<string, unknown>;
};
}
/** Read a schema-validated projection of one direct child's declared result. */
export function createRlmGetResultHostHandler(handler: RlmGetResultHandler): HostRequestHandler {
return async (payload) => {
if (typeof payload.target !== "string" || !payload.target.trim()) {
throw new Error("rlm.get_result target must be a non-empty string");
}
let fields: string[] | undefined;
if (payload.fields !== undefined) {
if (
!Array.isArray(payload.fields) ||
!payload.fields.every((field) => typeof field === "string" && field.length > 0)
) {
throw new Error("rlm.get_result fields must be an array of non-empty strings");
}
fields = payload.fields;
}
const generation = payload.generation;
if (generation !== undefined && (!Number.isSafeInteger(generation) || (generation as number) < 0)) {
throw new Error("rlm.get_result generation must be a non-negative integer");
}
return (await handler(payload.target.trim(), fields, generation as number | undefined)) as unknown as Record<
string,
unknown
>;
};
}
/** Expose the current parent session's direct RLM child registry to its kernel. */
export function createRlmListSubagentsHostHandler(handler: RlmListSubagentsHandler): HostRequestHandler {
return async () => {
const { subagents } = await handler();
return { subagents };
};
}
/** Delete one direct child selected from the current parent session's registry. */
export function createRlmDeleteSubagentHostHandler(handler: RlmDeleteSubagentHandler): HostRequestHandler {
return async (payload) => {
if (typeof payload.target !== "string" || !payload.target.trim()) {
throw new Error("rlm.delete_subagent target must be a non-empty string");
}
const { subagent, outcome } = await handler(payload.target.trim());
return outcome === undefined ? { subagent } : { subagent, outcome };
};
}
export interface RlmSubagentRuntime {
session: AgentSession;
}
export interface CreateRlmSubagentRuntimeOptions {
parentSession: AgentSession;
id: string;
prompt: string;
sessionName: string;
sessionDir: string;
model: Model<any>;
thinkingLevel: ThinkingLevel;
serviceTier: ServiceTier;
scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
activeToolNames: string[];
allowedToolNames?: string[];
customTools: ToolDefinition[];
includeGoals: boolean;
includeCompactSkill: boolean;
rlmResultController?: RlmResultController;
rlmDepth: number;
rlmMaxDepth: number;
rlmParentNodeId: string;
/** Source of the Python cell that spawned this subagent, for display. */
spawnCode?: string;
/** Publish the session to the parent before a host makes the runtime addressable. */
onSessionPublished?: (session: AgentSession) => void;
}
export interface SubagentRuntimeHost {
createRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): Promise<RlmSubagentRuntime>;
/** Persist host-owned completion before the child becomes passivation-eligible. */
completeRlmSubagentRuntime?(childId: string, session: AgentSession): boolean;
/** Release a host-owned child after its detached initial task settles. */
releaseRlmSubagentRuntime?: (
runtime: RlmSubagentRuntime,
options: CreateRlmSubagentRuntimeOptions,
status: "done" | "error" | "cancelled",
) => Promise<void>;
/** Close or remove the host-owned child; session is absent when a persisted child is still passive. */
deleteRlmSubagentRuntime(childId: string, session?: AgentSession): Promise<void>;
disposeRlmSubagentRuntimes?(): Promise<void>;
}