@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
323 lines (322 loc) • 13.8 kB
JavaScript
/**
* Task checklist primitive (N1) — session-scoped, compaction-proof.
*
* A long-running agent that plans in prose loses the plan the moment the
* conversation is summarized. This keeps the plan OUT of the message list:
* checklist state lives in a module-level Map keyed by sessionId, so
* compaction — which only ever rewrites messages — cannot touch it. Every
* `tasks_*` tool returns the WHOLE list, so the first call after a compaction
* re-anchors the model for free; there is no re-injection machinery to get
* wrong (N1.3 is satisfied structurally, not by a mechanism).
*
* The host reads the same state synchronously via `getChecklistState()`, which
* is what makes a completeness gate ("no pending items may survive this
* stage") one line of host code with no LLM in the loop.
*
* Registration is opt-in (`NeuroLink.registerTaskTools()`), and goes through
* `host.registerTool()` so the tools land in the "user-defined" category —
* the only one that reaches the LLM's tool schema.
*/
import { z } from "zod";
import { logger } from "../utils/logger.js";
/** Module-level on purpose: compaction rewrites messages, never a module map. */
const checklists = new Map();
const STATUSES = [
"pending",
"in_progress",
"done",
"closed",
];
/** Ids the engine hands out: t1, t2, … — never accepted from the model. */
const ID_PREFIX = "t";
/**
* A session id the engine invented for a single tool call: NeuroLink's
* tool-context merge defaults to `fallback-${Date.now()}` when no session was
* declared anywhere. Honouring it would file every model tool call under a
* different checklist — a silently empty list — so it is ignored in favour of
* a stable per-host key.
*
* The registry's other placeholder (a bare `randomUUID()` minted in
* `createExecutionContext` when a DIRECT `executeTool()` call carries no
* session) is indistinguishable from a host that genuinely uses UUIDs as
* session ids, so it is honoured as given: programmatic callers pass
* `authContext: { sessionId }`.
*/
const SYNTHETIC_SESSION_ID = /^fallback-\d+$/;
const hostDefaultSessions = new WeakMap();
let hostDefaultCounter = 0;
/** Set by the delegation primitive; absent means "no background workers". */
let delegateCountsSource;
/** Set by the background-command primitive; absent means "no commands". */
let commandCountsSource;
/**
* Let the async-delegation primitive feed `delegatesPending` / `delegatesReady`
* into every checklist result, so the model learns a worker finished from any
* `tasks_list` — without this module importing delegation (which would make a
* cycle) and without a polling loop.
*/
export function setChecklistDelegateCountsSource(source) {
delegateCountsSource = source;
}
function delegateCountsFor(sessionId) {
if (!delegateCountsSource) {
return { pending: 0, ready: 0 };
}
try {
return delegateCountsSource(sessionId);
}
catch (error) {
logger.warn("[TaskChecklist] Delegate counts source failed — reporting 0", {
error: error instanceof Error ? error.message : String(error),
});
return { pending: 0, ready: 0 };
}
}
/**
* Let the background-command primitive feed `commandsRunning` /
* `commandsFinished` into every checklist result — the same notification
* channel the delegate counters use, so the model learns "the build finished"
* from any `tasks_list` without this module importing the command runtime
* (which would make a cycle) and without a polling loop.
*/
export function setChecklistCommandCountsSource(source) {
commandCountsSource = source;
}
function commandCountsFor(sessionId) {
if (!commandCountsSource) {
return { running: 0, finished: 0 };
}
try {
return commandCountsSource(sessionId);
}
catch (error) {
logger.warn("[TaskChecklist] Command counts source failed — reporting 0", {
error: error instanceof Error ? error.message : String(error),
});
return { running: 0, finished: 0 };
}
}
function defaultSessionIdFor(host) {
const existing = hostDefaultSessions.get(host);
if (existing) {
return existing;
}
hostDefaultCounter += 1;
const created = `checklist-default-${hostDefaultCounter}`;
hostDefaultSessions.set(host, created);
return created;
}
function readSessionId(source) {
const value = source?.sessionId;
return typeof value === "string" ? value.trim() : "";
}
/**
* Which checklist a tool call belongs to.
*
* Execution context wins: a worker created with the shared tool registry runs
* the host's registered tool closure but arrives with its OWN sessionId, and
* that worker's checklist must not merge into its parent's. The host's
* `setToolContext()` session is the next authority, and a per-host key is the
* last resort so a host that never declared a session still gets ONE list
* instead of a new one per call.
*/
export function resolveChecklistSessionId(host, context) {
const contextRecord = context && typeof context === "object"
? context
: undefined;
const fromContext = readSessionId(contextRecord);
if (fromContext && !SYNTHETIC_SESSION_ID.test(fromContext)) {
return fromContext;
}
const fromHost = readSessionId(host.getToolContext());
if (fromHost && !SYNTHETIC_SESSION_ID.test(fromHost)) {
return fromHost;
}
return defaultSessionIdFor(host);
}
function cloneState(state) {
return {
sessionId: state.sessionId,
items: state.items.map((item) => ({ ...item })),
updatedAt: state.updatedAt,
};
}
function emptyState(sessionId) {
return { sessionId, items: [], updatedAt: 0 };
}
/** Never throws: an unknown session simply has an empty checklist. */
export function getChecklistState(sessionId) {
const state = checklists.get(sessionId);
return state ? cloneState(state) : emptyState(sessionId);
}
/** Drop one session's checklist. Returns whether there was one to drop. */
export function clearChecklistState(sessionId) {
return checklists.delete(sessionId);
}
/** The live state, created on first write. Reads must not create entries. */
function stateForWrite(sessionId) {
let state = checklists.get(sessionId);
if (!state) {
state = { sessionId, items: [], updatedAt: Date.now() };
checklists.set(sessionId, state);
}
return state;
}
/** Continue the id run rather than reusing an id a closed item still holds. */
function nextItemId(items) {
let highest = 0;
for (const item of items) {
const parsed = Number.parseInt(item.id.slice(ID_PREFIX.length), 10);
if (Number.isFinite(parsed) && parsed > highest) {
highest = parsed;
}
}
return `${ID_PREFIX}${highest + 1}`;
}
function toResult(state) {
const counts = {
pending: 0,
in_progress: 0,
done: 0,
closed: 0,
};
for (const item of state.items) {
counts[item.status] += 1;
}
const delegates = delegateCountsFor(state.sessionId);
const runCommands = commandCountsFor(state.sessionId);
return {
items: state.items.map((item) => ({ ...item })),
counts,
delegatesPending: delegates.pending,
delegatesReady: delegates.ready,
commandsRunning: runCommands.running,
commandsFinished: runCommands.finished,
};
}
/** Matches `agentToolRegistrar`'s convention: the recovery step is IN the text. */
function refusal(message) {
return { isError: true, error: message };
}
const CREATE_SCHEMA = z.object({
titles: z
.array(z.string())
.describe("One short imperative title per task, in the order you intend to work them, " +
'e.g. ["Check auth changes against the security rules", "Review the migration files"].'),
});
const UPDATE_SCHEMA = z.object({
id: z.string().describe('Task id from the checklist, e.g. "t2".'),
status: z
.enum(["pending", "in_progress", "done", "closed"])
.describe("in_progress when you start it, done when it is finished, closed when it " +
"will NOT be done (a reason is then required)."),
note: z
.string()
.optional()
.describe("What you found, or — for closed — why the task will not be done. Required for closed."),
});
const LIST_SCHEMA = z.object({});
/**
* The three model-facing checklist tools, bound to `host` for session
* resolution. Register them with `host.registerTool()` (see
* `NeuroLink.registerTaskTools()`), never on the tool registry directly:
* only the "user-defined" category reaches the LLM's tool schema.
*/
export function createChecklistTools(host) {
return {
tasks_create: {
name: "tasks_create",
description: "Write the checklist of concrete tasks this run must finish. Call it once " +
"up front, and again only to ADD tasks you discover later — titles are " +
"appended, never replaced, and the engine assigns the ids. Returns the full " +
"checklist. Pending tasks mean the work is not finished.",
inputSchema: CREATE_SCHEMA,
execute: async (params, context) => {
const parsed = CREATE_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
return refusal("tasks_create expects { titles: string[] }. Call it again with a non-empty " +
"array of short task titles.");
}
const titles = parsed.data.titles
.map((title) => title.trim())
.filter((title) => title.length > 0);
if (titles.length === 0) {
return refusal("No task titles were given. Call tasks_create again with at least one " +
"non-empty title describing work this run must finish.");
}
const sessionId = resolveChecklistSessionId(host, context);
const state = stateForWrite(sessionId);
const now = Date.now();
for (const title of titles) {
state.items.push({
id: nextItemId(state.items),
title,
status: "pending",
createdAt: now,
updatedAt: now,
});
}
state.updatedAt = now;
logger.debug("[TaskChecklist] Items created", {
sessionId,
added: titles.length,
total: state.items.length,
});
return toResult(state);
},
},
tasks_update: {
name: "tasks_update",
description: "Move one checklist task to a new status: in_progress when you start it, " +
"done when it is genuinely finished, closed when it will not be done (say " +
"why in note — a closed task without a reason is refused). Returns the full " +
"checklist so you always see what is left.",
inputSchema: UPDATE_SCHEMA,
execute: async (params, context) => {
const parsed = UPDATE_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
return refusal(`tasks_update expects { id, status, note? } with status one of ${STATUSES.join(" | ")}. Call tasks_list to see the current ids, then retry.`);
}
const { id, status, note } = parsed.data;
const sessionId = resolveChecklistSessionId(host, context);
const state = checklists.get(sessionId);
const item = state?.items.find((candidate) => candidate.id === id);
if (!state || !item) {
const valid = state?.items.map((candidate) => candidate.id) ?? [];
return refusal(valid.length > 0
? `No checklist task "${id}". Valid ids are ${valid.join(", ")} — retry with one of them.`
: `No checklist task "${id}": the checklist is empty. Call tasks_create first.`);
}
const reason = note?.trim();
if (status === "closed" && !reason) {
return refusal(`Closing "${id}" needs a reason. Call tasks_update again with note set to why ` +
"this task will not be completed, or finish it and mark it done.");
}
item.status = status;
if (reason) {
item.note = reason;
}
item.updatedAt = Date.now();
state.updatedAt = item.updatedAt;
logger.debug("[TaskChecklist] Item updated", {
sessionId,
id,
status,
});
return toResult(state);
},
},
tasks_list: {
name: "tasks_list",
description: "Read the current checklist — every task with its status, plus how many " +
"background workers are still running or waiting to be collected and how many " +
"background commands are still running or have finished unread. Use it to " +
"re-orient after a long stretch of work; it is cheap and always current.",
inputSchema: LIST_SCHEMA,
execute: async (_params, context) => {
const sessionId = resolveChecklistSessionId(host, context);
return toResult(checklists.get(sessionId) ?? emptyState(sessionId));
},
},
};
}