naisys
Version:
NAISYS - Autonomous AI agent runner with built-in context management and cost tracking
460 lines • 19.5 kB
JavaScript
import { agentNames } from "@naisys/common";
import { HubEvents } from "@naisys/hub-protocol";
import stringArgv from "string-argv";
import stripAnsi from "strip-ansi";
import table from "text-table";
import { subagentCmd } from "../../command/commandDefs.js";
export function createSubagentService(mailService, chatService, output, agentManager, inputMode, userService, localUserId, promptNotification, hubClient, globalConfig, agentConfig, runService, costTracker) {
const mySubagentsMap = new Map();
async function handleCommand(args) {
const argv = stringArgv(args);
if (!argv[0]) {
argv[0] = "help";
}
let errorText = "";
switch (argv[0]) {
case "help": {
const subs = subagentCmd.subcommands;
let helpOutput = `${subagentCmd.name} <command>
${subs.list.usage}: ${subs.list.description}
${subs.create.usage}: ${subs.create.description}
${subs.start.usage}: ${subs.start.description}
${subs.stop.usage}: ${subs.stop.description}
${subs.peek.usage}: ${subs.peek.description}`;
if (inputMode.isDebug()) {
helpOutput += `\n\nDebug commands:`;
helpOutput += `\n ${subs.local.usage}: ${subs.local.description}`;
helpOutput += `\n ${subs.switch.usage}: ${subs.switch.description}`;
}
helpOutput += `\n\n* Use ns-chat to communicate with subagents once started`;
return helpOutput;
}
case "list": {
return listMySubagents();
}
case "local": {
if (!inputMode.isDebug()) {
errorText =
"The 'subagent local' command is only available in debug mode.\n";
break;
}
return listLocalAgents();
}
// "spawn" command removed - launched agents as separate child node processes. See git history.
case "create": {
const title = argv[1];
const taskDescription = argv[2];
if (!title || !taskDescription) {
errorText =
'Missing required parameters. Expected: create "<title>" "<task>"\n';
break;
}
return await createEphemeralSubagent(title, taskDescription);
}
case "start": {
const subagentName = argv[1];
const taskDescription = argv[2];
if (!subagentName || !taskDescription) {
errorText =
'Missing required parameters. Expected: start <username> "<description>"\n';
break;
}
return await startAgent(subagentName, taskDescription);
}
case "switch": {
if (!inputMode.isDebug()) {
errorText =
"The 'subagent switch' command is only available in debug mode.\n";
break;
}
const switchName = argv[1];
return switchAgent(switchName);
}
case "peek": {
const peekName = argv[1];
const skip = argv[2] ? parseInt(argv[2], 10) : undefined;
const take = argv[3] ? parseInt(argv[3], 10) : undefined;
return await peekAgent(peekName, skip, take);
}
case "stop": {
const recursive = argv[1] === "-r";
const username = recursive ? argv[2] : argv[1];
const recursiveText = recursive ? " (recursive)" : "";
return await stopAgent(username, "subagent stop" + recursiveText, recursive);
}
default: {
errorText = "Error, unknown command. See valid commands below:\n";
}
}
return errorText + (await handleCommand("help"));
}
function refreshMySubagents() {
const allUsers = userService.getUsers();
for (const user of allUsers) {
if (user.userId === localUserId)
continue; // Exclude self
// Only include agents led by this user
if (user.leadUserId != localUserId)
continue;
const existing = mySubagentsMap.get(user.userId);
if (!existing) {
mySubagentsMap.set(user.userId, {
userId: user.userId,
agentName: user.username,
title: user.config.title,
});
}
}
}
/** Returns IDs of running agents. In hub mode, includes remote agents via agent status. */
function getRunningAgentsIds() {
const runningAgentIds = new Set(agentManager.runningAgents.map((a) => a.agentUserId));
// In hub mode, also include agents reported active by the hub heartbeat
if (hubClient) {
for (const subagent of mySubagentsMap.values()) {
if (userService.isUserActive(subagent.userId)) {
runningAgentIds.add(subagent.userId);
}
}
}
return runningAgentIds;
}
function listMySubagents() {
refreshMySubagents();
if (mySubagentsMap.size === 0) {
return "No subagents.";
}
const runningAgentIds = getRunningAgentsIds();
const rows = [...mySubagentsMap.values()].map((subagent) => {
const running = runningAgentIds.has(subagent.userId);
return [
subagent.agentName,
subagent.taskDescription?.substring(0, 70) || subagent.title,
running ? "running" : "stopped",
];
});
return table([["Name", "Task", "Status"], ...rows], {
hsep: " | ",
});
}
function listLocalAgents() {
if (agentManager.runningAgents.length === 0) {
return "No locally running agents.";
}
const rows = agentManager.runningAgents.map((a) => {
return [a.agentUsername, "Running"];
});
return table([["Name", "Status"], ...rows], {
hsep: " | ",
});
}
function raiseSwitchEvent() {
promptNotification.notify({
userId: localUserId,
wake: "always",
});
}
/** Check if targetUserId is a subordinate of localUserId at any depth */
function isSubordinate(targetUserId) {
let current = userService.getUserById(targetUserId);
const visited = new Set();
while (current?.leadUserId != null) {
if (current.leadUserId === localUserId)
return true;
if (visited.has(current.leadUserId))
return false; // cycle guard
visited.add(current.leadUserId);
current = userService.getUserById(current.leadUserId);
}
return false;
}
function validateUser(agentName) {
const user = userService.getUserByName(agentName);
if (!user) {
throw `Agent '${agentName}' not found`;
}
return user;
}
function validateSubagentStart(agentName, taskDescription) {
if (!agentName) {
throw "Subagent name is required to start a subagent";
}
if (!taskDescription) {
throw "Task description is required to start a subagent";
}
const user = validateUser(agentName);
if (!user.enabled) {
throw `Agent '${agentName}' is disabled`;
}
if (!inputMode.isDebug() && !isSubordinate(user.userId)) {
throw "You're not authorized to start this agent — not your subordinate";
}
const runningAgentIds = getRunningAgentsIds();
if (runningAgentIds.has(user.userId)) {
throw `Subagent '${agentName}' is already running`;
}
return user;
}
async function createEphemeralSubagent(title, taskDescription) {
if (userService.getUserById(localUserId)?.isEphemeral) {
throw "Subagents cannot create their own subagents";
}
const existingNames = new Set(userService.getUsers().map((u) => u.username));
const shuffled = [...agentNames].sort(() => Math.random() - 0.5);
const agentName = shuffled.find((n) => !existingNames.has(n));
if (!agentName) {
throw "No available usernames for subagents";
}
const subagentUserId = userService.nextSyntheticUserId();
const parentConfig = agentConfig.agentConfig();
const leadAgent = parentConfig.username;
const subagentConfig = {
username: agentName,
title,
agentPrompt: `You are \${agent.username}, a \${agent.title} working for ${leadAgent}.\n` +
`Task: ${taskDescription}\n` +
`Complete the task and/or wait for follow-up messages from ${leadAgent}. ` +
`When finished, use ns-session to end your session.`,
commandProtection: parentConfig.commandProtection == "auto" ? "auto" : "none",
wakeOnMessage: true,
completeSessionEnabled: true,
// spendLimitDollars omitted: subagent defers checkSpendLimit to parent.
// spendLimitHours kept: drives ns-cost period display.
spendLimitHours: parentConfig.spendLimitHours,
shellModel: parentConfig.shellModel,
imageModel: parentConfig.imageModel,
tokenMax: parentConfig.tokenMax,
webEnabled: parentConfig.webEnabled,
mailEnabled: parentConfig.mailEnabled,
chatEnabled: parentConfig.chatEnabled,
browserEnabled: parentConfig.browserEnabled,
multipleCommandsEnabled: parentConfig.multipleCommandsEnabled,
};
userService.addLocalUser({
userId: subagentUserId,
username: agentName,
enabled: true,
leadUserId: localUserId,
config: subagentConfig,
isEphemeral: true,
});
const subagent = {
userId: subagentUserId,
agentName,
title,
taskDescription,
isEphemeral: true,
};
mySubagentsMap.set(subagentUserId, subagent);
try {
await agentManager.startAgent(subagentUserId, undefined, (stopReason) => handleAgentTermination(subagent, stopReason), {
parentUserId: localUserId,
parentRunId: runService.getRunId(),
subagentId: subagentUserId,
parentCostTracker: costTracker,
});
}
catch (error) {
userService.removeLocalUser(subagentUserId);
mySubagentsMap.delete(subagentUserId);
throw error;
}
await sendStartupMessage(subagent, taskDescription);
return `Subagent '${agentName}' (${title}) created and started`;
}
async function startAgent(agentName, taskDescription) {
refreshMySubagents();
const user = validateSubagentStart(agentName, taskDescription);
let resultMessage = "";
let subagent = mySubagentsMap.get(user.userId);
if (!subagent) {
subagent = {
userId: user.userId,
agentName: user.username,
title: user.config.title,
taskDescription,
};
mySubagentsMap.set(subagent.userId, subagent);
}
if (hubClient) {
// Hub mode: send start request through hub, which routes to the target host
const response = await hubClient.sendRequest(HubEvents.AGENT_START, {
startUserId: subagent.userId,
requesterUserId: localUserId,
taskDescription,
});
if (!response.success) {
throw `Failed to start agent via hub: ${response.error}`;
}
resultMessage = `Subagent '${agentName}' started on host '${response.hostname}'`;
}
// Non-hub mode: start agent locally
else {
await agentManager.startAgent(subagent.userId, undefined, (stopReason) => handleAgentTermination(subagent, stopReason));
resultMessage = `Subagent '${agentName}' started`;
}
subagent.taskDescription = taskDescription;
// In non-hub mode, send mail locally; in hub mode, the hub sends it from the AGENT_START handler
if (!hubClient) {
await sendStartupMessage(subagent, taskDescription);
}
return resultMessage;
}
async function sendStartupMessage(subagent, taskDescription) {
const useMail = globalConfig.globalConfig().mailServiceEnabled &&
agentConfig.agentConfig().mailEnabled;
if (useMail) {
const recipients = userService.resolveUsernames(subagent.agentName);
return await mailService
.sendMessage(recipients, "Your Task", taskDescription)
.catch(() => {
output.commentAndLog(`Failed to send initial task email to subagent ${subagent.agentName}`);
});
}
return await chatService
.sendToUser(subagent.userId, `Your Task: ${taskDescription}`)
.catch(() => {
output.commentAndLog(`Failed to send initial task chat to subagent ${subagent.agentName}`);
});
}
function validateAgentRunning(agentName) {
const user = validateUser(agentName);
const userId = user.userId;
if (!getRunningAgentsIds().has(userId)) {
if (userService.isUserActive(userId)) {
throw `Agent '${agentName}' is active, but not on this host`;
}
else {
throw `Agent '${agentName}' is not running`;
}
}
return userId;
}
/** Find all running subordinates of a user (recursively) */
function findRunningSubordinates(parentUserId) {
const allUsers = userService.getUsers();
const runningAgentIds = getRunningAgentsIds();
const result = [];
function collect(parentId) {
for (const user of allUsers) {
if (user.leadUserId === parentId && runningAgentIds.has(user.userId)) {
result.push(user.userId);
collect(user.userId);
}
}
}
collect(parentUserId);
return result;
}
async function stopAgent(agentName, reason, recursive) {
// Also check if running in agentManager (debug user can stop agents other than local subagents)
const userId = validateAgentRunning(agentName);
if (!inputMode.isDebug() && !isSubordinate(userId)) {
throw `You're not authorized to stop agent '${agentName}' — not your subordinate`;
}
// Collect subordinate IDs before stopping the parent
const subordinateIds = recursive ? findRunningSubordinates(userId) : [];
// Ephemerals are local-only — the hub doesn't know their synthetic ids,
// so route each target per its own ephemerality (a non-ephemeral parent
// can have ephemeral children).
const isHubRouted = (targetId) => !!hubClient && !userService.getUserById(targetId)?.isEphemeral;
if (isHubRouted(userId)) {
// Confirm the hub accepted the parent stop so failures surface to the caller
const response = await hubClient.sendRequest(HubEvents.AGENT_STOP, {
userId,
reason,
});
if (!response.success) {
throw `Failed to stop agent via hub: ${response.error}`;
}
}
else {
// Local stop awaits the agent's actual shutdown (up to 10s) — fire-and-forget
void agentManager.stopAgent(userId, reason);
}
// Subordinates fire-and-forget; route each per its own ephemerality
void Promise.all(subordinateIds.map((subId) => isHubRouted(subId)
? hubClient
.sendRequest(HubEvents.AGENT_STOP, { userId: subId, reason })
.catch(() => { })
: agentManager.stopAgent(subId, reason).catch(() => { })));
const stoppedCount = subordinateIds.length + 1;
return recursive && stoppedCount > 1
? `Stop requested for '${agentName}' and ${stoppedCount - 1} subordinate(s)`
: `Agent '${agentName}' stop requested`;
}
/** Only for in-process agents */
function switchAgent(agentName) {
const userId = validateAgentRunning(agentName);
agentManager.setActiveConsoleAgent(userId);
return "";
}
async function peekAgent(agentName, skip, take) {
const userId = validateAgentRunning(agentName);
if (!inputMode.isDebug() && !isSubordinate(userId)) {
throw `You're not authorized to peek at agent '${agentName}' — not your subordinate`;
}
const isEphemeral = userService.getUserById(userId)?.isEphemeral;
let lines;
let totalLines;
if (hubClient && !isEphemeral) {
const response = await hubClient.sendRequest(HubEvents.AGENT_PEEK, {
userId,
skip,
take,
});
if (!response.success) {
throw `Failed to peek agent via hub: ${response.error}`;
}
lines = response.lines ?? [];
totalLines = response.totalLines ?? 0;
}
else {
// Local mode: get buffer lines directly
const allLines = agentManager
.getBufferLines(userId)
.map((line) => stripAnsi(line));
totalLines = allLines.length;
const s = skip ?? 0;
const t = take ?? totalLines;
lines = allLines.slice(s, s + t);
}
if (lines.length === 0) {
return `No buffered output for '${agentName}'.`;
}
return (`[${lines.length} of ${totalLines} lines]\n` +
lines.map((l) => " " + l).join("\n"));
}
function handleAgentTermination(subagent, reason) {
subagent.taskDescription = undefined;
promptNotification.notify({
userId: localUserId,
wake: "yes",
contextOutput: [
`Subagent '${subagent.agentName}' has terminated. Reason: ${reason}`,
],
});
if (subagent.isEphemeral) {
userService.removeLocalUser(subagent.userId);
mySubagentsMap.delete(subagent.userId);
}
}
const registrableCommand = {
command: subagentCmd,
handleCommand,
};
async function cleanup() {
const stopPromises = [...mySubagentsMap.values()]
.filter((sub) => sub.isEphemeral)
.map((sub) => agentManager.stopAgent(sub.userId, "parent terminated").catch((err) => {
output.errorAndLog(`Failed to stop ephemeral subagent ${sub.agentName}: ${err}`, err);
}));
await Promise.all(stopPromises);
}
return {
...registrableCommand,
raiseSwitchEvent,
cleanup,
};
}
//# sourceMappingURL=subagent.js.map