mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:01:36 +00:00
feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
handleAbortTrigger,
|
||||
handleActivationCommand,
|
||||
handleRestartCommand,
|
||||
handleSessionCommand,
|
||||
handleSendPolicyCommand,
|
||||
handleStopCommand,
|
||||
handleUsageCommand,
|
||||
@@ -47,6 +48,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
handleUsageCommand,
|
||||
handleSessionCommand,
|
||||
handleRestartCommand,
|
||||
handleTtsCommands,
|
||||
handleHelpCommand,
|
||||
|
||||
147
src/auto-reply/reply/commands-session-ttl.test.ts
Normal file
147
src/auto-reply/reply/commands-session-ttl.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const getThreadBindingManagerMock = vi.fn();
|
||||
const setThreadBindingTtlBySessionKeyMock = vi.fn();
|
||||
return {
|
||||
getThreadBindingManagerMock,
|
||||
setThreadBindingTtlBySessionKeyMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
|
||||
setThreadBindingTtlBySessionKey: hoisted.setThreadBindingTtlBySessionKeyMock,
|
||||
};
|
||||
});
|
||||
|
||||
const { handleSessionCommand } = await import("./commands-session.js");
|
||||
const { buildCommandTestParams } = await import("./commands.test-harness.js");
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
type FakeBinding = {
|
||||
threadId: string;
|
||||
targetSessionKey: string;
|
||||
expiresAt?: number;
|
||||
boundBy?: string;
|
||||
};
|
||||
|
||||
function createDiscordCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
|
||||
return buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:thread-1",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "thread-1",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createFakeThreadBindingManager(binding: FakeBinding | null) {
|
||||
return {
|
||||
getByThreadId: vi.fn((_threadId: string) => binding),
|
||||
};
|
||||
}
|
||||
|
||||
describe("/session ttl", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.getThreadBindingManagerMock.mockReset();
|
||||
hoisted.setThreadBindingTtlBySessionKeyMock.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sets ttl for the focused session", async () => {
|
||||
const binding: FakeBinding = {
|
||||
threadId: "thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
};
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
|
||||
hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([
|
||||
{
|
||||
...binding,
|
||||
boundAt: Date.now(),
|
||||
expiresAt: new Date("2026-02-21T02:00:00.000Z").getTime(),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(createDiscordCommandParams("/session ttl 2h"), true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
ttlMs: 2 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(text).toContain("Session TTL set to 2h");
|
||||
expect(text).toContain("2026-02-21T02:00:00.000Z");
|
||||
});
|
||||
|
||||
it("shows active ttl when no value is provided", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
|
||||
|
||||
const binding: FakeBinding = {
|
||||
threadId: "thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(),
|
||||
};
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
|
||||
|
||||
const result = await handleSessionCommand(createDiscordCommandParams("/session ttl"), true);
|
||||
expect(result?.reply?.text).toContain("Session TTL active (2h");
|
||||
});
|
||||
|
||||
it("disables ttl when set to off", async () => {
|
||||
const binding: FakeBinding = {
|
||||
threadId: "thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(),
|
||||
};
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
|
||||
hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([
|
||||
{ ...binding, boundAt: Date.now(), expiresAt: undefined },
|
||||
]);
|
||||
|
||||
const result = await handleSessionCommand(createDiscordCommandParams("/session ttl off"), true);
|
||||
|
||||
expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
accountId: "default",
|
||||
ttlMs: 0,
|
||||
});
|
||||
expect(result?.reply?.text).toContain("Session TTL disabled");
|
||||
});
|
||||
|
||||
it("is unavailable outside discord", async () => {
|
||||
const params = buildCommandTestParams("/session ttl 2h", baseCfg);
|
||||
const result = await handleSessionCommand(params, true);
|
||||
expect(result?.reply?.text).toContain("currently available for Discord thread-bound sessions");
|
||||
});
|
||||
|
||||
it("requires binding owner for ttl updates", async () => {
|
||||
const binding: FakeBinding = {
|
||||
threadId: "thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
boundBy: "owner-1",
|
||||
};
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
|
||||
|
||||
const result = await handleSessionCommand(
|
||||
createDiscordCommandParams("/session ttl 2h", {
|
||||
SenderId: "other-user",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(hoisted.setThreadBindingTtlBySessionKeyMock).not.toHaveBeenCalled();
|
||||
expect(result?.reply?.text).toContain("Only owner-1 can update session TTL");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
|
||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||
import { isRestartEnabled } from "../../config/commands.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { updateSessionStore } from "../../config/sessions.js";
|
||||
import {
|
||||
formatThreadBindingTtlLabel,
|
||||
getThreadBindingManager,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
} from "../../discord/monitor/thread-bindings.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
|
||||
@@ -41,6 +47,53 @@ function resolveAbortTarget(params: {
|
||||
return { entry: undefined, key: targetSessionKey, sessionId: undefined };
|
||||
}
|
||||
|
||||
const SESSION_COMMAND_PREFIX = "/session";
|
||||
const SESSION_TTL_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
|
||||
|
||||
function isDiscordSurface(params: Parameters<CommandHandler>[0]): boolean {
|
||||
const channel =
|
||||
params.ctx.OriginatingChannel ??
|
||||
params.command.channel ??
|
||||
params.ctx.Surface ??
|
||||
params.ctx.Provider;
|
||||
return (
|
||||
String(channel ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "discord"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDiscordAccountId(params: Parameters<CommandHandler>[0]): string {
|
||||
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
|
||||
return accountId || "default";
|
||||
}
|
||||
|
||||
function resolveSessionCommandUsage() {
|
||||
return "Usage: /session ttl <duration|off> (example: /session ttl 24h)";
|
||||
}
|
||||
|
||||
function parseSessionTtlMs(raw: string): number {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
throw new Error("missing ttl");
|
||||
}
|
||||
if (SESSION_TTL_OFF_VALUES.has(normalized)) {
|
||||
return 0;
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(normalized)) {
|
||||
const hours = Number(normalized);
|
||||
if (!Number.isFinite(hours) || hours < 0) {
|
||||
throw new Error("invalid ttl");
|
||||
}
|
||||
return Math.round(hours * 60 * 60 * 1000);
|
||||
}
|
||||
return parseDurationMs(normalized, { defaultUnit: "h" });
|
||||
}
|
||||
|
||||
function formatSessionExpiry(expiresAt: number) {
|
||||
return new Date(expiresAt).toISOString();
|
||||
}
|
||||
|
||||
async function applyAbortTarget(params: {
|
||||
abortTarget: ReturnType<typeof resolveAbortTarget>;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
@@ -244,6 +297,133 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
};
|
||||
};
|
||||
|
||||
export const handleSessionCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (!/^\/session(?:\s|$)/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /session from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const rest = normalized.slice(SESSION_COMMAND_PREFIX.length).trim();
|
||||
const tokens = rest.split(/\s+/).filter(Boolean);
|
||||
const action = tokens[0]?.toLowerCase();
|
||||
if (action !== "ttl") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: resolveSessionCommandUsage() },
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDiscordSurface(params)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /session ttl is currently available for Discord thread-bound sessions." },
|
||||
};
|
||||
}
|
||||
|
||||
const threadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
if (!threadId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /session ttl must be run inside a focused Discord thread." },
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = resolveDiscordAccountId(params);
|
||||
const threadBindings = getThreadBindingManager(accountId);
|
||||
if (!threadBindings) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ Discord thread bindings are unavailable for this account." },
|
||||
};
|
||||
}
|
||||
|
||||
const binding = threadBindings.getByThreadId(threadId);
|
||||
if (!binding) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ This thread is not currently focused." },
|
||||
};
|
||||
}
|
||||
|
||||
const ttlArgRaw = tokens.slice(1).join("");
|
||||
if (!ttlArgRaw) {
|
||||
const expiresAt = binding.expiresAt;
|
||||
if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > Date.now()) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `ℹ️ Session TTL active (${formatThreadBindingTtlLabel(expiresAt - Date.now())}, auto-unfocus at ${formatSessionExpiry(expiresAt)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ Session TTL is currently disabled for this focused session." },
|
||||
};
|
||||
}
|
||||
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ Only ${binding.boundBy} can update session TTL for this thread.` },
|
||||
};
|
||||
}
|
||||
|
||||
let ttlMs: number;
|
||||
try {
|
||||
ttlMs = parseSessionTtlMs(ttlArgRaw);
|
||||
} catch {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: resolveSessionCommandUsage() },
|
||||
};
|
||||
}
|
||||
|
||||
const updatedBindings = setThreadBindingTtlBySessionKey({
|
||||
targetSessionKey: binding.targetSessionKey,
|
||||
accountId,
|
||||
ttlMs,
|
||||
});
|
||||
if (updatedBindings.length === 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ Failed to update session TTL for the current binding." },
|
||||
};
|
||||
}
|
||||
|
||||
if (ttlMs <= 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `✅ Session TTL disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const expiresAt = updatedBindings[0]?.expiresAt;
|
||||
const expiryLabel =
|
||||
typeof expiresAt === "number" && Number.isFinite(expiresAt)
|
||||
? formatSessionExpiry(expiresAt)
|
||||
: "n/a";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `✅ Session TTL set to ${formatThreadBindingTtlLabel(ttlMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (auto-unfocus at ${expiryLabel}).`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
|
||||
331
src/auto-reply/reply/commands-subagents-focus.test.ts
Normal file
331
src/auto-reply/reply/commands-subagents-focus.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSubagentRunForTests,
|
||||
resetSubagentRegistryForTests,
|
||||
} from "../../agents/subagent-registry.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const callGatewayMock = vi.fn();
|
||||
const getThreadBindingManagerMock = vi.fn();
|
||||
const resolveThreadBindingThreadNameMock = vi.fn(() => "🤖 codex");
|
||||
return {
|
||||
callGatewayMock,
|
||||
getThreadBindingManagerMock,
|
||||
resolveThreadBindingThreadNameMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: hoisted.callGatewayMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
|
||||
resolveThreadBindingThreadName: hoisted.resolveThreadBindingThreadNameMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
|
||||
vi.mock("../../discord/monitor/gateway-plugin.js", () => ({
|
||||
createDiscordGatewayPlugin: () => ({}),
|
||||
}));
|
||||
|
||||
const { handleSubagentsCommand } = await import("./commands-subagents.js");
|
||||
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
|
||||
|
||||
type FakeBinding = {
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
threadId: string;
|
||||
targetKind: "subagent" | "acp";
|
||||
targetSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
boundBy: string;
|
||||
boundAt: number;
|
||||
};
|
||||
|
||||
function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) {
|
||||
const byThread = new Map<string, FakeBinding>(
|
||||
initialBindings.map((binding) => [binding.threadId, binding]),
|
||||
);
|
||||
|
||||
const manager = {
|
||||
getSessionTtlMs: vi.fn(() => 24 * 60 * 60 * 1000),
|
||||
getByThreadId: vi.fn((threadId: string) => byThread.get(threadId)),
|
||||
listBySessionKey: vi.fn((targetSessionKey: string) =>
|
||||
[...byThread.values()].filter((binding) => binding.targetSessionKey === targetSessionKey),
|
||||
),
|
||||
listBindings: vi.fn(() => [...byThread.values()]),
|
||||
bindTarget: vi.fn(async (params: Record<string, unknown>) => {
|
||||
const threadId =
|
||||
typeof params.threadId === "string" && params.threadId.trim()
|
||||
? params.threadId.trim()
|
||||
: "thread-created";
|
||||
const targetSessionKey =
|
||||
typeof params.targetSessionKey === "string" ? params.targetSessionKey.trim() : "";
|
||||
const agentId =
|
||||
typeof params.agentId === "string" && params.agentId.trim()
|
||||
? params.agentId.trim()
|
||||
: "main";
|
||||
const binding: FakeBinding = {
|
||||
accountId: "default",
|
||||
channelId:
|
||||
typeof params.channelId === "string" && params.channelId.trim()
|
||||
? params.channelId.trim()
|
||||
: "parent-1",
|
||||
threadId,
|
||||
targetKind:
|
||||
params.targetKind === "subagent" || params.targetKind === "acp"
|
||||
? params.targetKind
|
||||
: "acp",
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label: typeof params.label === "string" ? params.label : undefined,
|
||||
boundBy: typeof params.boundBy === "string" ? params.boundBy : "system",
|
||||
boundAt: Date.now(),
|
||||
};
|
||||
byThread.set(threadId, binding);
|
||||
return binding;
|
||||
}),
|
||||
unbindThread: vi.fn((params: { threadId: string }) => {
|
||||
const binding = byThread.get(params.threadId) ?? null;
|
||||
if (binding) {
|
||||
byThread.delete(params.threadId);
|
||||
}
|
||||
return binding;
|
||||
}),
|
||||
};
|
||||
|
||||
return { manager, byThread };
|
||||
}
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
function createDiscordCommandParams(commandBody: string) {
|
||||
const params = buildCommandTestParams(commandBody, baseCfg, {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:parent-1",
|
||||
AccountId: "default",
|
||||
MessageThreadId: "thread-1",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
describe("/focus, /unfocus, /agents", () => {
|
||||
beforeEach(() => {
|
||||
resetSubagentRegistryForTests();
|
||||
hoisted.callGatewayMock.mockReset();
|
||||
hoisted.getThreadBindingManagerMock.mockReset();
|
||||
hoisted.resolveThreadBindingThreadNameMock.mockReset().mockReturnValue("🤖 codex");
|
||||
});
|
||||
|
||||
it("/focus resolves ACP sessions and binds the current Discord thread", async () => {
|
||||
const fake = createFakeThreadBindingManager();
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
hoisted.callGatewayMock.mockImplementation(async (request: unknown) => {
|
||||
const method = (request as { method?: string }).method;
|
||||
if (method === "sessions.resolve") {
|
||||
return { key: "agent:codex-acp:session-1" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const params = createDiscordCommandParams("/focus codex-acp");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("bound this thread");
|
||||
expect(result?.reply?.text).toContain("(acp)");
|
||||
expect(fake.manager.bindTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread-1",
|
||||
createThread: false,
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex-acp:session-1",
|
||||
introText:
|
||||
"🤖 codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("/unfocus removes an active thread binding for the binding owner", async () => {
|
||||
const fake = createFakeThreadBindingManager([
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "child",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const params = createDiscordCommandParams("/unfocus");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Thread unfocused");
|
||||
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
threadId: "thread-1",
|
||||
reason: "manual",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("/focus rejects rebinding when the thread is focused by another user", async () => {
|
||||
const fake = createFakeThreadBindingManager([
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "child",
|
||||
boundBy: "user-2",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
hoisted.callGatewayMock.mockImplementation(async (request: unknown) => {
|
||||
const method = (request as { method?: string }).method;
|
||||
if (method === "sessions.resolve") {
|
||||
return { key: "agent:codex-acp:session-1" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const params = createDiscordCommandParams("/focus codex-acp");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result?.reply?.text).toContain("Only user-2 can refocus this thread.");
|
||||
expect(fake.manager.bindTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("/agents includes bound persistent sessions and requester-scoped ACP bindings", async () => {
|
||||
addSubagentRunForTests({
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:child-1",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "test task",
|
||||
cleanup: "keep",
|
||||
label: "child-1",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const fake = createFakeThreadBindingManager([
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
label: "child-1",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-2",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:main:main",
|
||||
agentId: "codex-acp",
|
||||
label: "main-session",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-3",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex-acp:session-2",
|
||||
agentId: "codex-acp",
|
||||
label: "codex-acp",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const params = createDiscordCommandParams("/agents");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(text).toContain("agents:");
|
||||
expect(text).toContain("thread:thread-1");
|
||||
expect(text).toContain("acp/session bindings:");
|
||||
expect(text).toContain("session:agent:main:main");
|
||||
expect(text).not.toContain("session:agent:codex-acp:session-2");
|
||||
});
|
||||
|
||||
it("/agents keeps finished session-mode runs visible while their thread binding remains", async () => {
|
||||
addSubagentRunForTests({
|
||||
runId: "run-session-1",
|
||||
childSessionKey: "agent:main:subagent:persistent-1",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "persistent task",
|
||||
cleanup: "keep",
|
||||
label: "persistent-1",
|
||||
spawnMode: "session",
|
||||
createdAt: Date.now(),
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
|
||||
const fake = createFakeThreadBindingManager([
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-persistent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:persistent-1",
|
||||
agentId: "main",
|
||||
label: "persistent-1",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
const params = createDiscordCommandParams("/agents");
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(text).toContain("agents:");
|
||||
expect(text).toContain("persistent-1");
|
||||
expect(text).toContain("thread:thread-persistent-1");
|
||||
});
|
||||
|
||||
it("/focus is discord-only", async () => {
|
||||
const params = buildCommandTestParams("/focus codex-acp", baseCfg);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result?.reply?.text).toContain("only available on Discord");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const hoisted = vi.hoisted(() => {
|
||||
|
||||
vi.mock("../../agents/subagent-spawn.js", () => ({
|
||||
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
|
||||
SUBAGENT_SPAWN_MODES: ["run", "session"],
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
@@ -93,6 +94,7 @@ describe("/subagents spawn command", () => {
|
||||
const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
|
||||
expect(spawnParams.task).toBe("do the thing");
|
||||
expect(spawnParams.agentId).toBe("beta");
|
||||
expect(spawnParams.mode).toBe("run");
|
||||
expect(spawnParams.cleanup).toBe("keep");
|
||||
expect(spawnParams.expectsCompletionMessage).toBe(true);
|
||||
expect(spawnCtx.agentSessionKey).toBeDefined();
|
||||
|
||||
@@ -1,255 +1,38 @@
|
||||
import crypto from "node:crypto";
|
||||
import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js";
|
||||
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
|
||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||
import {
|
||||
clearSubagentRunSteerRestart,
|
||||
listSubagentRunsForRequester,
|
||||
markSubagentRunTerminated,
|
||||
markSubagentRunForSteerRestart,
|
||||
replaceSubagentRunAfterSteer,
|
||||
} from "../../agents/subagent-registry.js";
|
||||
import { spawnSubagentDirect } from "../../agents/subagent-spawn.js";
|
||||
import {
|
||||
extractAssistantText,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
sanitizeTextContent,
|
||||
stripToolMessages,
|
||||
} from "../../agents/tools/sessions-helpers.js";
|
||||
import {
|
||||
type SessionEntry,
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { extractTextFromChatContent } from "../../shared/chat-content.js";
|
||||
import { handleSubagentsAgentsAction } from "./commands-subagents/action-agents.js";
|
||||
import { handleSubagentsFocusAction } from "./commands-subagents/action-focus.js";
|
||||
import { handleSubagentsHelpAction } from "./commands-subagents/action-help.js";
|
||||
import { handleSubagentsInfoAction } from "./commands-subagents/action-info.js";
|
||||
import { handleSubagentsKillAction } from "./commands-subagents/action-kill.js";
|
||||
import { handleSubagentsListAction } from "./commands-subagents/action-list.js";
|
||||
import { handleSubagentsLogAction } from "./commands-subagents/action-log.js";
|
||||
import { handleSubagentsSendAction } from "./commands-subagents/action-send.js";
|
||||
import { handleSubagentsSpawnAction } from "./commands-subagents/action-spawn.js";
|
||||
import { handleSubagentsUnfocusAction } from "./commands-subagents/action-unfocus.js";
|
||||
import {
|
||||
formatDurationCompact,
|
||||
formatTokenUsageDisplay,
|
||||
truncateLine,
|
||||
} from "../../shared/subagents-format.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import { stopSubagentsForRequester } from "./abort.js";
|
||||
type SubagentsCommandContext,
|
||||
extractMessageText,
|
||||
resolveHandledPrefix,
|
||||
resolveRequesterSessionKey,
|
||||
resolveSubagentsAction,
|
||||
stopWithText,
|
||||
} from "./commands-subagents/shared.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { clearSessionQueues } from "./queue.js";
|
||||
import {
|
||||
formatRunLabel,
|
||||
formatRunStatus,
|
||||
resolveSubagentTargetFromRuns,
|
||||
type SubagentTargetResolution,
|
||||
sortSubagentRuns,
|
||||
} from "./subagents-utils.js";
|
||||
|
||||
const COMMAND = "/subagents";
|
||||
const COMMAND_KILL = "/kill";
|
||||
const COMMAND_STEER = "/steer";
|
||||
const COMMAND_TELL = "/tell";
|
||||
const ACTIONS = new Set(["list", "kill", "log", "send", "steer", "info", "spawn", "help"]);
|
||||
const RECENT_WINDOW_MINUTES = 30;
|
||||
const SUBAGENT_TASK_PREVIEW_MAX = 110;
|
||||
const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
||||
|
||||
function compactLine(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function formatTaskPreview(value: string) {
|
||||
return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX);
|
||||
}
|
||||
|
||||
function resolveModelDisplay(
|
||||
entry?: {
|
||||
model?: unknown;
|
||||
modelProvider?: unknown;
|
||||
modelOverride?: unknown;
|
||||
providerOverride?: unknown;
|
||||
},
|
||||
fallbackModel?: string,
|
||||
) {
|
||||
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
|
||||
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
|
||||
let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model;
|
||||
if (!combined) {
|
||||
// Fall back to override fields which are populated at spawn time,
|
||||
// before the first run completes and writes model/modelProvider.
|
||||
const overrideModel =
|
||||
typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
|
||||
const overrideProvider =
|
||||
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
|
||||
combined = overrideModel.includes("/")
|
||||
? overrideModel
|
||||
: overrideModel && overrideProvider
|
||||
? `${overrideProvider}/${overrideModel}`
|
||||
: overrideModel;
|
||||
}
|
||||
if (!combined) {
|
||||
combined = fallbackModel?.trim() || "";
|
||||
}
|
||||
if (!combined) {
|
||||
return "model n/a";
|
||||
}
|
||||
const slash = combined.lastIndexOf("/");
|
||||
if (slash >= 0 && slash < combined.length - 1) {
|
||||
return combined.slice(slash + 1);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
function resolveDisplayStatus(entry: SubagentRunRecord) {
|
||||
const status = formatRunStatus(entry);
|
||||
return status === "error" ? "failed" : status;
|
||||
}
|
||||
|
||||
function formatSubagentListLine(params: {
|
||||
entry: SubagentRunRecord;
|
||||
index: number;
|
||||
runtimeMs: number;
|
||||
sessionEntry?: SessionEntry;
|
||||
}) {
|
||||
const usageText = formatTokenUsageDisplay(params.sessionEntry);
|
||||
const label = truncateLine(formatRunLabel(params.entry, { maxLength: 48 }), 48);
|
||||
const task = formatTaskPreview(params.entry.task);
|
||||
const runtime = formatDurationCompact(params.runtimeMs);
|
||||
const status = resolveDisplayStatus(params.entry);
|
||||
return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return new Date(valueMs).toISOString();
|
||||
}
|
||||
|
||||
function formatTimestampWithAge(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
|
||||
}
|
||||
|
||||
function resolveRequesterSessionKey(
|
||||
params: Parameters<CommandHandler>[0],
|
||||
opts?: { preferCommandTarget?: boolean },
|
||||
): string | undefined {
|
||||
const commandTarget = params.ctx.CommandTargetSessionKey?.trim();
|
||||
const commandSession = params.sessionKey?.trim();
|
||||
const raw = opts?.preferCommandTarget
|
||||
? commandTarget || commandSession
|
||||
: commandSession || commandTarget;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
return resolveInternalSessionKey({ key: raw, alias, mainKey });
|
||||
}
|
||||
|
||||
function resolveSubagentTarget(
|
||||
runs: SubagentRunRecord[],
|
||||
token: string | undefined,
|
||||
): SubagentTargetResolution {
|
||||
return resolveSubagentTargetFromRuns({
|
||||
runs,
|
||||
token,
|
||||
recentWindowMinutes: RECENT_WINDOW_MINUTES,
|
||||
label: (entry) => formatRunLabel(entry),
|
||||
errors: {
|
||||
missingTarget: "Missing subagent id.",
|
||||
invalidIndex: (value) => `Invalid subagent index: ${value}`,
|
||||
unknownSession: (value) => `Unknown subagent session: ${value}`,
|
||||
ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
|
||||
ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
|
||||
ambiguousRunIdPrefix: (value) => `Ambiguous run id prefix: ${value}`,
|
||||
unknownTarget: (value) => `Unknown subagent id: ${value}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildSubagentsHelp() {
|
||||
return [
|
||||
"Subagents",
|
||||
"Usage:",
|
||||
"- /subagents list",
|
||||
"- /subagents kill <id|#|all>",
|
||||
"- /subagents log <id|#> [limit] [tools]",
|
||||
"- /subagents info <id|#>",
|
||||
"- /subagents send <id|#> <message>",
|
||||
"- /subagents steer <id|#> <message>",
|
||||
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
"- /kill <id|#|all>",
|
||||
"- /steer <id|#> <message>",
|
||||
"- /tell <id|#> <message>",
|
||||
"",
|
||||
"Ids: use the list index (#), runId/session prefix, label, or full session key.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
type ChatMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
const shouldSanitize = role === "assistant";
|
||||
const text = extractTextFromChatContent(message.content, {
|
||||
sanitizeText: shouldSanitize ? sanitizeTextContent : undefined,
|
||||
});
|
||||
return text ? { role, text } : null;
|
||||
}
|
||||
|
||||
function formatLogLines(messages: ChatMessage[]) {
|
||||
const lines: string[] = [];
|
||||
for (const msg of messages) {
|
||||
const extracted = extractMessageText(msg);
|
||||
if (!extracted) {
|
||||
continue;
|
||||
}
|
||||
const label = extracted.role === "assistant" ? "Assistant" : "User";
|
||||
lines.push(`${label}: ${extracted.text}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
type SessionStoreCache = Map<string, Record<string, SessionEntry>>;
|
||||
|
||||
function loadSubagentSessionEntry(
|
||||
params: Parameters<CommandHandler>[0],
|
||||
childKey: string,
|
||||
storeCache?: SessionStoreCache,
|
||||
) {
|
||||
const parsed = parseAgentSessionKey(childKey);
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
|
||||
let store = storeCache?.get(storePath);
|
||||
if (!store) {
|
||||
store = loadSessionStore(storePath);
|
||||
storeCache?.set(storePath, store);
|
||||
}
|
||||
return { storePath, store, entry: store[childKey] };
|
||||
}
|
||||
export { extractMessageText };
|
||||
|
||||
export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
const handledPrefix = normalized.startsWith(COMMAND)
|
||||
? COMMAND
|
||||
: normalized.startsWith(COMMAND_KILL)
|
||||
? COMMAND_KILL
|
||||
: normalized.startsWith(COMMAND_STEER)
|
||||
? COMMAND_STEER
|
||||
: normalized.startsWith(COMMAND_TELL)
|
||||
? COMMAND_TELL
|
||||
: null;
|
||||
const handledPrefix = resolveHandledPrefix(normalized);
|
||||
if (!handledPrefix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring ${handledPrefix} from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
@@ -259,438 +42,50 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
|
||||
|
||||
const rest = normalized.slice(handledPrefix.length).trim();
|
||||
const restTokens = rest.split(/\s+/).filter(Boolean);
|
||||
let action = "list";
|
||||
if (handledPrefix === COMMAND) {
|
||||
const [actionRaw] = restTokens;
|
||||
action = actionRaw?.toLowerCase() || "list";
|
||||
if (!ACTIONS.has(action)) {
|
||||
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
|
||||
}
|
||||
restTokens.splice(0, 1);
|
||||
} else if (handledPrefix === COMMAND_KILL) {
|
||||
action = "kill";
|
||||
} else {
|
||||
action = "steer";
|
||||
const action = resolveSubagentsAction({ handledPrefix, restTokens });
|
||||
if (!action) {
|
||||
return handleSubagentsHelpAction();
|
||||
}
|
||||
|
||||
const requesterKey = resolveRequesterSessionKey(params, {
|
||||
preferCommandTarget: action === "spawn",
|
||||
});
|
||||
if (!requesterKey) {
|
||||
return { shouldContinue: false, reply: { text: "⚠️ Missing session key." } };
|
||||
}
|
||||
const runs = listSubagentRunsForRequester(requesterKey);
|
||||
|
||||
if (action === "help") {
|
||||
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
|
||||
return stopWithText("⚠️ Missing session key.");
|
||||
}
|
||||
|
||||
if (action === "list") {
|
||||
const sorted = sortSubagentRuns(runs);
|
||||
const now = Date.now();
|
||||
const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
|
||||
const storeCache: SessionStoreCache = new Map();
|
||||
let index = 1;
|
||||
const mapRuns = (
|
||||
entries: SubagentRunRecord[],
|
||||
runtimeMs: (entry: SubagentRunRecord) => number,
|
||||
) =>
|
||||
entries.map((entry) => {
|
||||
const { entry: sessionEntry } = loadSubagentSessionEntry(
|
||||
params,
|
||||
entry.childSessionKey,
|
||||
storeCache,
|
||||
);
|
||||
const line = formatSubagentListLine({
|
||||
entry,
|
||||
index,
|
||||
runtimeMs: runtimeMs(entry),
|
||||
sessionEntry,
|
||||
});
|
||||
index += 1;
|
||||
return line;
|
||||
});
|
||||
const activeEntries = sorted.filter((entry) => !entry.endedAt);
|
||||
const activeLines = mapRuns(
|
||||
activeEntries,
|
||||
(entry) => now - (entry.startedAt ?? entry.createdAt),
|
||||
);
|
||||
const recentEntries = sorted.filter(
|
||||
(entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
|
||||
);
|
||||
const recentLines = mapRuns(
|
||||
recentEntries,
|
||||
(entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
|
||||
);
|
||||
const ctx: SubagentsCommandContext = {
|
||||
params,
|
||||
handledPrefix,
|
||||
requesterKey,
|
||||
runs: listSubagentRunsForRequester(requesterKey),
|
||||
restTokens,
|
||||
};
|
||||
|
||||
const lines = ["active subagents:", "-----"];
|
||||
if (activeLines.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
lines.push(activeLines.join("\n"));
|
||||
}
|
||||
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
|
||||
if (recentLines.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
lines.push(recentLines.join("\n"));
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||
switch (action) {
|
||||
case "help":
|
||||
return handleSubagentsHelpAction();
|
||||
case "agents":
|
||||
return handleSubagentsAgentsAction(ctx);
|
||||
case "focus":
|
||||
return await handleSubagentsFocusAction(ctx);
|
||||
case "unfocus":
|
||||
return handleSubagentsUnfocusAction(ctx);
|
||||
case "list":
|
||||
return handleSubagentsListAction(ctx);
|
||||
case "kill":
|
||||
return await handleSubagentsKillAction(ctx);
|
||||
case "info":
|
||||
return handleSubagentsInfoAction(ctx);
|
||||
case "log":
|
||||
return await handleSubagentsLogAction(ctx);
|
||||
case "send":
|
||||
return await handleSubagentsSendAction(ctx, false);
|
||||
case "steer":
|
||||
return await handleSubagentsSendAction(ctx, true);
|
||||
case "spawn":
|
||||
return await handleSubagentsSpawnAction(ctx);
|
||||
default:
|
||||
return handleSubagentsHelpAction();
|
||||
}
|
||||
|
||||
if (action === "kill") {
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
handledPrefix === COMMAND
|
||||
? "Usage: /subagents kill <id|#|all>"
|
||||
: "Usage: /kill <id|#|all>",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (target === "all" || target === "*") {
|
||||
stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: requesterKey,
|
||||
});
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
const resolved = resolveSubagentTarget(runs, target);
|
||||
if (!resolved.entry) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
|
||||
};
|
||||
}
|
||||
if (resolved.entry.endedAt) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` },
|
||||
};
|
||||
}
|
||||
|
||||
const childKey = resolved.entry.childSessionKey;
|
||||
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
if (sessionId) {
|
||||
abortEmbeddedPiRun(sessionId);
|
||||
}
|
||||
const cleared = clearSessionQueues([childKey, sessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
if (entry) {
|
||||
entry.abortedLastRun = true;
|
||||
entry.updatedAt = Date.now();
|
||||
store[childKey] = entry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[childKey] = entry;
|
||||
});
|
||||
}
|
||||
markSubagentRunTerminated({
|
||||
runId: resolved.entry.runId,
|
||||
childSessionKey: childKey,
|
||||
reason: "killed",
|
||||
});
|
||||
// Cascade: also stop any sub-sub-agents spawned by this child.
|
||||
stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: childKey,
|
||||
});
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
if (action === "info") {
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return { shouldContinue: false, reply: { text: "ℹ️ Usage: /subagents info <id|#>" } };
|
||||
}
|
||||
const resolved = resolveSubagentTarget(runs, target);
|
||||
if (!resolved.entry) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
|
||||
};
|
||||
}
|
||||
const run = resolved.entry;
|
||||
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
|
||||
const runtime =
|
||||
run.startedAt && Number.isFinite(run.startedAt)
|
||||
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
|
||||
: "n/a";
|
||||
const outcome = run.outcome
|
||||
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
|
||||
: "n/a";
|
||||
const lines = [
|
||||
"ℹ️ Subagent info",
|
||||
`Status: ${resolveDisplayStatus(run)}`,
|
||||
`Label: ${formatRunLabel(run)}`,
|
||||
`Task: ${run.task}`,
|
||||
`Run: ${run.runId}`,
|
||||
`Session: ${run.childSessionKey}`,
|
||||
`SessionId: ${sessionEntry?.sessionId ?? "n/a"}`,
|
||||
`Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`,
|
||||
`Runtime: ${runtime}`,
|
||||
`Created: ${formatTimestampWithAge(run.createdAt)}`,
|
||||
`Started: ${formatTimestampWithAge(run.startedAt)}`,
|
||||
`Ended: ${formatTimestampWithAge(run.endedAt)}`,
|
||||
`Cleanup: ${run.cleanup}`,
|
||||
run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined,
|
||||
run.cleanupHandled ? "Cleanup handled: yes" : undefined,
|
||||
`Outcome: ${outcome}`,
|
||||
].filter(Boolean);
|
||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||
}
|
||||
|
||||
if (action === "log") {
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return { shouldContinue: false, reply: { text: "📜 Usage: /subagents log <id|#> [limit]" } };
|
||||
}
|
||||
const includeTools = restTokens.some((token) => token.toLowerCase() === "tools");
|
||||
const limitToken = restTokens.find((token) => /^\d+$/.test(token));
|
||||
const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20;
|
||||
const resolved = resolveSubagentTarget(runs, target);
|
||||
if (!resolved.entry) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
|
||||
};
|
||||
}
|
||||
const history = await callGateway<{ messages: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolved.entry.childSessionKey, limit },
|
||||
});
|
||||
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
|
||||
const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages);
|
||||
const lines = formatLogLines(filtered as ChatMessage[]);
|
||||
const header = `📜 Subagent log: ${formatRunLabel(resolved.entry)}`;
|
||||
if (lines.length === 0) {
|
||||
return { shouldContinue: false, reply: { text: `${header}\n(no messages)` } };
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: [header, ...lines].join("\n") } };
|
||||
}
|
||||
|
||||
if (action === "send" || action === "steer") {
|
||||
const steerRequested = action === "steer";
|
||||
const target = restTokens[0];
|
||||
const message = restTokens.slice(1).join(" ").trim();
|
||||
if (!target || !message) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: steerRequested
|
||||
? handledPrefix === COMMAND
|
||||
? "Usage: /subagents steer <id|#> <message>"
|
||||
: `Usage: ${handledPrefix} <id|#> <message>`
|
||||
: "Usage: /subagents send <id|#> <message>",
|
||||
},
|
||||
};
|
||||
}
|
||||
const resolved = resolveSubagentTarget(runs, target);
|
||||
if (!resolved.entry) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
|
||||
};
|
||||
}
|
||||
if (steerRequested && resolved.entry.endedAt) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` },
|
||||
};
|
||||
}
|
||||
const { entry: targetSessionEntry } = loadSubagentSessionEntry(
|
||||
params,
|
||||
resolved.entry.childSessionKey,
|
||||
);
|
||||
const targetSessionId =
|
||||
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
|
||||
? targetSessionEntry.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
if (steerRequested) {
|
||||
// Suppress stale announce before interrupting the in-flight run.
|
||||
markSubagentRunForSteerRestart(resolved.entry.runId);
|
||||
|
||||
// Force an immediate interruption and make steer the next run.
|
||||
if (targetSessionId) {
|
||||
abortEmbeddedPiRun(targetSessionId);
|
||||
}
|
||||
const cleared = clearSessionQueues([resolved.entry.childSessionKey, targetSessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Best effort: wait for the interrupted run to settle so the steer
|
||||
// message is appended on the existing conversation state.
|
||||
try {
|
||||
await callGateway({
|
||||
method: "agent.wait",
|
||||
params: {
|
||||
runId: resolved.entry.runId,
|
||||
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
|
||||
},
|
||||
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
|
||||
});
|
||||
} catch {
|
||||
// Continue even if wait fails; steer should still be attempted.
|
||||
}
|
||||
}
|
||||
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
let runId: string = idempotencyKey;
|
||||
try {
|
||||
const response = await callGateway<{ runId: string }>({
|
||||
method: "agent",
|
||||
params: {
|
||||
message,
|
||||
sessionKey: resolved.entry.childSessionKey,
|
||||
sessionId: targetSessionId,
|
||||
idempotencyKey,
|
||||
deliver: false,
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
lane: AGENT_LANE_SUBAGENT,
|
||||
timeout: 0,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
|
||||
if (responseRunId) {
|
||||
runId = responseRunId;
|
||||
}
|
||||
} catch (err) {
|
||||
if (steerRequested) {
|
||||
// Replacement launch failed; restore announce behavior for the
|
||||
// original run so completion is not silently suppressed.
|
||||
clearSubagentRunSteerRestart(resolved.entry.runId);
|
||||
}
|
||||
const messageText =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
||||
return { shouldContinue: false, reply: { text: `send failed: ${messageText}` } };
|
||||
}
|
||||
|
||||
if (steerRequested) {
|
||||
replaceSubagentRunAfterSteer({
|
||||
previousRunId: resolved.entry.runId,
|
||||
nextRunId: runId,
|
||||
fallback: resolved.entry,
|
||||
runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `steered ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const waitMs = 30_000;
|
||||
const wait = await callGateway<{ status?: string; error?: string }>({
|
||||
method: "agent.wait",
|
||||
params: { runId, timeoutMs: waitMs },
|
||||
timeoutMs: waitMs + 2000,
|
||||
});
|
||||
if (wait?.status === "timeout") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⏳ Subagent still running (run ${runId.slice(0, 8)}).` },
|
||||
};
|
||||
}
|
||||
if (wait?.status === "error") {
|
||||
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const history = await callGateway<{ messages: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: resolved.entry.childSessionKey, limit: 50 },
|
||||
});
|
||||
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
|
||||
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
|
||||
const replyText = last ? extractAssistantText(last) : undefined;
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
replyText ?? `✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "spawn") {
|
||||
const agentId = restTokens[0];
|
||||
// Parse remaining tokens: task text with optional --model and --thinking flags.
|
||||
const taskParts: string[] = [];
|
||||
let model: string | undefined;
|
||||
let thinking: string | undefined;
|
||||
for (let i = 1; i < restTokens.length; i++) {
|
||||
if (restTokens[i] === "--model" && i + 1 < restTokens.length) {
|
||||
i += 1;
|
||||
model = restTokens[i];
|
||||
} else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) {
|
||||
i += 1;
|
||||
thinking = restTokens[i];
|
||||
} else {
|
||||
taskParts.push(restTokens[i]);
|
||||
}
|
||||
}
|
||||
const task = taskParts.join(" ").trim();
|
||||
if (!agentId || !task) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: "Usage: /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const commandTo = typeof params.command.to === "string" ? params.command.to.trim() : "";
|
||||
const originatingTo =
|
||||
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "";
|
||||
const fallbackTo = typeof params.ctx.To === "string" ? params.ctx.To.trim() : "";
|
||||
// OriginatingTo reflects the active conversation target and is safer than
|
||||
// command.to for cross-surface command dispatch.
|
||||
const normalizedTo = originatingTo || commandTo || fallbackTo || undefined;
|
||||
|
||||
const result = await spawnSubagentDirect(
|
||||
{ task, agentId, model, thinking, cleanup: "keep", expectsCompletionMessage: true },
|
||||
{
|
||||
agentSessionKey: requesterKey,
|
||||
agentChannel: params.ctx.OriginatingChannel ?? params.command.channel,
|
||||
agentAccountId: params.ctx.AccountId,
|
||||
agentTo: normalizedTo,
|
||||
agentThreadId: params.ctx.MessageThreadId,
|
||||
agentGroupId: params.sessionEntry?.groupId ?? null,
|
||||
agentGroupChannel: params.sessionEntry?.groupChannel ?? null,
|
||||
agentGroupSpace: params.sessionEntry?.space ?? null,
|
||||
},
|
||||
);
|
||||
if (result.status === "accepted") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `Spawn failed: ${result.error ?? result.status}` },
|
||||
};
|
||||
}
|
||||
|
||||
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
|
||||
};
|
||||
|
||||
55
src/auto-reply/reply/commands-subagents/action-agents.ts
Normal file
55
src/auto-reply/reply/commands-subagents/action-agents.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { formatRunLabel, sortSubagentRuns } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
resolveDiscordAccountId,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params, requesterKey, runs } = ctx;
|
||||
const isDiscord = isDiscordSurface(params);
|
||||
const accountId = isDiscord ? resolveDiscordAccountId(params) : undefined;
|
||||
const threadBindings = accountId ? getThreadBindingManager(accountId) : null;
|
||||
const visibleRuns = sortSubagentRuns(runs).filter((entry) => {
|
||||
if (!entry.endedAt) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(threadBindings?.listBySessionKey(entry.childSessionKey)[0]);
|
||||
});
|
||||
|
||||
const lines = ["agents:", "-----"];
|
||||
if (visibleRuns.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
let index = 1;
|
||||
for (const entry of visibleRuns) {
|
||||
const threadBinding = threadBindings?.listBySessionKey(entry.childSessionKey)[0];
|
||||
const bindingText = threadBinding
|
||||
? `thread:${threadBinding.threadId}`
|
||||
: isDiscord
|
||||
? "unbound"
|
||||
: "bindings available on discord";
|
||||
lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (threadBindings) {
|
||||
const acpBindings = threadBindings
|
||||
.listBindings()
|
||||
.filter((entry) => entry.targetKind === "acp" && entry.targetSessionKey === requesterKey);
|
||||
if (acpBindings.length > 0) {
|
||||
lines.push("", "acp/session bindings:", "-----");
|
||||
for (const binding of acpBindings) {
|
||||
lines.push(
|
||||
`- ${binding.label ?? binding.targetSessionKey} (thread:${binding.threadId}, session:${binding.targetSessionKey})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stopWithText(lines.join("\n"));
|
||||
}
|
||||
90
src/auto-reply/reply/commands-subagents/action-focus.ts
Normal file
90
src/auto-reply/reply/commands-subagents/action-focus.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
getThreadBindingManager,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "../../../discord/monitor/thread-bindings.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
resolveDiscordAccountId,
|
||||
resolveDiscordChannelIdForFocus,
|
||||
resolveFocusTargetSession,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsFocusAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, runs, restTokens } = ctx;
|
||||
if (!isDiscordSurface(params)) {
|
||||
return stopWithText("⚠️ /focus is only available on Discord.");
|
||||
}
|
||||
|
||||
const token = restTokens.join(" ").trim();
|
||||
if (!token) {
|
||||
return stopWithText("Usage: /focus <subagent-label|session-key|session-id|session-label>");
|
||||
}
|
||||
|
||||
const accountId = resolveDiscordAccountId(params);
|
||||
const threadBindings = getThreadBindingManager(accountId);
|
||||
if (!threadBindings) {
|
||||
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
|
||||
}
|
||||
|
||||
const focusTarget = await resolveFocusTargetSession({ runs, token });
|
||||
if (!focusTarget) {
|
||||
return stopWithText(`⚠️ Unable to resolve focus target: ${token}`);
|
||||
}
|
||||
|
||||
const currentThreadId =
|
||||
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
|
||||
const parentChannelId = currentThreadId ? undefined : resolveDiscordChannelIdForFocus(params);
|
||||
if (!currentThreadId && !parentChannelId) {
|
||||
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
|
||||
}
|
||||
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
if (currentThreadId) {
|
||||
const existingBinding = threadBindings.getByThreadId(currentThreadId);
|
||||
if (
|
||||
existingBinding &&
|
||||
existingBinding.boundBy &&
|
||||
existingBinding.boundBy !== "system" &&
|
||||
senderId &&
|
||||
senderId !== existingBinding.boundBy
|
||||
) {
|
||||
return stopWithText(`⚠️ Only ${existingBinding.boundBy} can refocus this thread.`);
|
||||
}
|
||||
}
|
||||
|
||||
const label = focusTarget.label || token;
|
||||
const binding = await threadBindings.bindTarget({
|
||||
threadId: currentThreadId || undefined,
|
||||
channelId: parentChannelId,
|
||||
createThread: !currentThreadId,
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: focusTarget.agentId,
|
||||
label,
|
||||
}),
|
||||
targetKind: focusTarget.targetKind,
|
||||
targetSessionKey: focusTarget.targetSessionKey,
|
||||
agentId: focusTarget.agentId,
|
||||
label,
|
||||
boundBy: senderId || "unknown",
|
||||
introText: resolveThreadBindingIntroText({
|
||||
agentId: focusTarget.agentId,
|
||||
label,
|
||||
sessionTtlMs: threadBindings.getSessionTtlMs(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!binding) {
|
||||
return stopWithText("⚠️ Failed to bind a Discord thread to the target session.");
|
||||
}
|
||||
|
||||
const actionText = currentThreadId
|
||||
? `bound this thread to ${binding.targetSessionKey}`
|
||||
: `created thread ${binding.threadId} and bound it to ${binding.targetSessionKey}`;
|
||||
return stopWithText(`✅ ${actionText} (${binding.targetKind}).`);
|
||||
}
|
||||
6
src/auto-reply/reply/commands-subagents/action-help.ts
Normal file
6
src/auto-reply/reply/commands-subagents/action-help.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { buildSubagentsHelp, stopWithText } from "./shared.js";
|
||||
|
||||
export function handleSubagentsHelpAction(): CommandHandlerResult {
|
||||
return stopWithText(buildSubagentsHelp());
|
||||
}
|
||||
59
src/auto-reply/reply/commands-subagents/action-info.ts
Normal file
59
src/auto-reply/reply/commands-subagents/action-info.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
|
||||
import { formatDurationCompact } from "../../../shared/subagents-format.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
formatTimestampWithAge,
|
||||
loadSubagentSessionEntry,
|
||||
resolveDisplayStatus,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params, runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText("ℹ️ Usage: /subagents info <id|#>");
|
||||
}
|
||||
|
||||
const targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
|
||||
const run = targetResolution.entry;
|
||||
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey, {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
});
|
||||
const runtime =
|
||||
run.startedAt && Number.isFinite(run.startedAt)
|
||||
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
|
||||
: "n/a";
|
||||
const outcome = run.outcome
|
||||
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
|
||||
: "n/a";
|
||||
|
||||
const lines = [
|
||||
"ℹ️ Subagent info",
|
||||
`Status: ${resolveDisplayStatus(run)}`,
|
||||
`Label: ${formatRunLabel(run)}`,
|
||||
`Task: ${run.task}`,
|
||||
`Run: ${run.runId}`,
|
||||
`Session: ${run.childSessionKey}`,
|
||||
`SessionId: ${sessionEntry?.sessionId ?? "n/a"}`,
|
||||
`Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`,
|
||||
`Runtime: ${runtime}`,
|
||||
`Created: ${formatTimestampWithAge(run.createdAt)}`,
|
||||
`Started: ${formatTimestampWithAge(run.startedAt)}`,
|
||||
`Ended: ${formatTimestampWithAge(run.endedAt)}`,
|
||||
`Cleanup: ${run.cleanup}`,
|
||||
run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined,
|
||||
run.cleanupHandled ? "Cleanup handled: yes" : undefined,
|
||||
`Outcome: ${outcome}`,
|
||||
].filter(Boolean);
|
||||
|
||||
return stopWithText(lines.join("\n"));
|
||||
}
|
||||
86
src/auto-reply/reply/commands-subagents/action-kill.ts
Normal file
86
src/auto-reply/reply/commands-subagents/action-kill.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
|
||||
import { markSubagentRunTerminated } from "../../../agents/subagent-registry.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
updateSessionStore,
|
||||
} from "../../../config/sessions.js";
|
||||
import { logVerbose } from "../../../globals.js";
|
||||
import { stopSubagentsForRequester } from "../abort.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { clearSessionQueues } from "../queue.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
COMMAND,
|
||||
loadSubagentSessionEntry,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsKillAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, handledPrefix, requesterKey, runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText(
|
||||
handledPrefix === COMMAND ? "Usage: /subagents kill <id|#|all>" : "Usage: /kill <id|#|all>",
|
||||
);
|
||||
}
|
||||
|
||||
if (target === "all" || target === "*") {
|
||||
stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: requesterKey,
|
||||
});
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
if (targetResolution.entry.endedAt) {
|
||||
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
|
||||
}
|
||||
|
||||
const childKey = targetResolution.entry.childSessionKey;
|
||||
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey, {
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
});
|
||||
const sessionId = entry?.sessionId;
|
||||
if (sessionId) {
|
||||
abortEmbeddedPiRun(sessionId);
|
||||
}
|
||||
|
||||
const cleared = clearSessionQueues([childKey, sessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry) {
|
||||
entry.abortedLastRun = true;
|
||||
entry.updatedAt = Date.now();
|
||||
store[childKey] = entry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[childKey] = entry;
|
||||
});
|
||||
}
|
||||
|
||||
markSubagentRunTerminated({
|
||||
runId: targetResolution.entry.runId,
|
||||
childSessionKey: childKey,
|
||||
reason: "killed",
|
||||
});
|
||||
|
||||
stopSubagentsForRequester({
|
||||
cfg: params.cfg,
|
||||
requesterSessionKey: childKey,
|
||||
});
|
||||
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
66
src/auto-reply/reply/commands-subagents/action-list.ts
Normal file
66
src/auto-reply/reply/commands-subagents/action-list.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { sortSubagentRuns } from "../subagents-utils.js";
|
||||
import {
|
||||
type SessionStoreCache,
|
||||
type SubagentsCommandContext,
|
||||
RECENT_WINDOW_MINUTES,
|
||||
formatSubagentListLine,
|
||||
loadSubagentSessionEntry,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsListAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params, runs } = ctx;
|
||||
const sorted = sortSubagentRuns(runs);
|
||||
const now = Date.now();
|
||||
const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
|
||||
const storeCache: SessionStoreCache = new Map();
|
||||
let index = 1;
|
||||
|
||||
const mapRuns = (entries: typeof runs, runtimeMs: (entry: (typeof runs)[number]) => number) =>
|
||||
entries.map((entry) => {
|
||||
const { entry: sessionEntry } = loadSubagentSessionEntry(
|
||||
params,
|
||||
entry.childSessionKey,
|
||||
{
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
},
|
||||
storeCache,
|
||||
);
|
||||
const line = formatSubagentListLine({
|
||||
entry,
|
||||
index,
|
||||
runtimeMs: runtimeMs(entry),
|
||||
sessionEntry,
|
||||
});
|
||||
index += 1;
|
||||
return line;
|
||||
});
|
||||
|
||||
const activeEntries = sorted.filter((entry) => !entry.endedAt);
|
||||
const activeLines = mapRuns(activeEntries, (entry) => now - (entry.startedAt ?? entry.createdAt));
|
||||
const recentEntries = sorted.filter(
|
||||
(entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
|
||||
);
|
||||
const recentLines = mapRuns(
|
||||
recentEntries,
|
||||
(entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
|
||||
);
|
||||
|
||||
const lines = ["active subagents:", "-----"];
|
||||
if (activeLines.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
lines.push(activeLines.join("\n"));
|
||||
}
|
||||
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
|
||||
if (recentLines.length === 0) {
|
||||
lines.push("(none)");
|
||||
} else {
|
||||
lines.push(recentLines.join("\n"));
|
||||
}
|
||||
|
||||
return stopWithText(lines.join("\n"));
|
||||
}
|
||||
43
src/auto-reply/reply/commands-subagents/action-log.ts
Normal file
43
src/auto-reply/reply/commands-subagents/action-log.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type ChatMessage,
|
||||
type SubagentsCommandContext,
|
||||
formatLogLines,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
stripToolMessages,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsLogAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
if (!target) {
|
||||
return stopWithText("📜 Usage: /subagents log <id|#> [limit]");
|
||||
}
|
||||
|
||||
const includeTools = restTokens.some((token) => token.toLowerCase() === "tools");
|
||||
const limitToken = restTokens.find((token) => /^\d+$/.test(token));
|
||||
const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20;
|
||||
|
||||
const targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
|
||||
const history = await callGateway<{ messages: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: targetResolution.entry.childSessionKey, limit },
|
||||
});
|
||||
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
|
||||
const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages);
|
||||
const lines = formatLogLines(filtered as ChatMessage[]);
|
||||
const header = `📜 Subagent log: ${formatRunLabel(targetResolution.entry)}`;
|
||||
if (lines.length === 0) {
|
||||
return stopWithText(`${header}\n(no messages)`);
|
||||
}
|
||||
return stopWithText([header, ...lines].join("\n"));
|
||||
}
|
||||
159
src/auto-reply/reply/commands-subagents/action-send.ts
Normal file
159
src/auto-reply/reply/commands-subagents/action-send.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import crypto from "node:crypto";
|
||||
import { AGENT_LANE_SUBAGENT } from "../../../agents/lanes.js";
|
||||
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
|
||||
import {
|
||||
clearSubagentRunSteerRestart,
|
||||
replaceSubagentRunAfterSteer,
|
||||
markSubagentRunForSteerRestart,
|
||||
} from "../../../agents/subagent-registry.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import { logVerbose } from "../../../globals.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { clearSessionQueues } from "../queue.js";
|
||||
import { formatRunLabel } from "../subagents-utils.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
COMMAND,
|
||||
STEER_ABORT_SETTLE_TIMEOUT_MS,
|
||||
extractAssistantText,
|
||||
loadSubagentSessionEntry,
|
||||
resolveSubagentEntryForToken,
|
||||
stopWithText,
|
||||
stripToolMessages,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function handleSubagentsSendAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
steerRequested: boolean,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, handledPrefix, runs, restTokens } = ctx;
|
||||
const target = restTokens[0];
|
||||
const message = restTokens.slice(1).join(" ").trim();
|
||||
if (!target || !message) {
|
||||
return stopWithText(
|
||||
steerRequested
|
||||
? handledPrefix === COMMAND
|
||||
? "Usage: /subagents steer <id|#> <message>"
|
||||
: `Usage: ${handledPrefix} <id|#> <message>`
|
||||
: "Usage: /subagents send <id|#> <message>",
|
||||
);
|
||||
}
|
||||
|
||||
const targetResolution = resolveSubagentEntryForToken(runs, target);
|
||||
if ("reply" in targetResolution) {
|
||||
return targetResolution.reply;
|
||||
}
|
||||
if (steerRequested && targetResolution.entry.endedAt) {
|
||||
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
|
||||
}
|
||||
|
||||
const { entry: targetSessionEntry } = loadSubagentSessionEntry(
|
||||
params,
|
||||
targetResolution.entry.childSessionKey,
|
||||
{
|
||||
loadSessionStore,
|
||||
resolveStorePath,
|
||||
},
|
||||
);
|
||||
const targetSessionId =
|
||||
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
|
||||
? targetSessionEntry.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
if (steerRequested) {
|
||||
markSubagentRunForSteerRestart(targetResolution.entry.runId);
|
||||
|
||||
if (targetSessionId) {
|
||||
abortEmbeddedPiRun(targetSessionId);
|
||||
}
|
||||
|
||||
const cleared = clearSessionQueues([targetResolution.entry.childSessionKey, targetSessionId]);
|
||||
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
|
||||
logVerbose(
|
||||
`subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await callGateway({
|
||||
method: "agent.wait",
|
||||
params: {
|
||||
runId: targetResolution.entry.runId,
|
||||
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
|
||||
},
|
||||
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
|
||||
});
|
||||
} catch {
|
||||
// Continue even if wait fails; steer should still be attempted.
|
||||
}
|
||||
}
|
||||
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
let runId: string = idempotencyKey;
|
||||
try {
|
||||
const response = await callGateway<{ runId: string }>({
|
||||
method: "agent",
|
||||
params: {
|
||||
message,
|
||||
sessionKey: targetResolution.entry.childSessionKey,
|
||||
sessionId: targetSessionId,
|
||||
idempotencyKey,
|
||||
deliver: false,
|
||||
channel: INTERNAL_MESSAGE_CHANNEL,
|
||||
lane: AGENT_LANE_SUBAGENT,
|
||||
timeout: 0,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
|
||||
if (responseRunId) {
|
||||
runId = responseRunId;
|
||||
}
|
||||
} catch (err) {
|
||||
if (steerRequested) {
|
||||
clearSubagentRunSteerRestart(targetResolution.entry.runId);
|
||||
}
|
||||
const messageText =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
||||
return stopWithText(`send failed: ${messageText}`);
|
||||
}
|
||||
|
||||
if (steerRequested) {
|
||||
replaceSubagentRunAfterSteer({
|
||||
previousRunId: targetResolution.entry.runId,
|
||||
nextRunId: runId,
|
||||
fallback: targetResolution.entry,
|
||||
runTimeoutSeconds: targetResolution.entry.runTimeoutSeconds ?? 0,
|
||||
});
|
||||
return stopWithText(
|
||||
`steered ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const waitMs = 30_000;
|
||||
const wait = await callGateway<{ status?: string; error?: string }>({
|
||||
method: "agent.wait",
|
||||
params: { runId, timeoutMs: waitMs },
|
||||
timeoutMs: waitMs + 2000,
|
||||
});
|
||||
if (wait?.status === "timeout") {
|
||||
return stopWithText(`⏳ Subagent still running (run ${runId.slice(0, 8)}).`);
|
||||
}
|
||||
if (wait?.status === "error") {
|
||||
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
|
||||
return stopWithText(`⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`);
|
||||
}
|
||||
|
||||
const history = await callGateway<{ messages: Array<unknown> }>({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: targetResolution.entry.childSessionKey, limit: 50 },
|
||||
});
|
||||
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
|
||||
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
|
||||
const replyText = last ? extractAssistantText(last) : undefined;
|
||||
return stopWithText(
|
||||
replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
|
||||
);
|
||||
}
|
||||
65
src/auto-reply/reply/commands-subagents/action-spawn.ts
Normal file
65
src/auto-reply/reply/commands-subagents/action-spawn.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { spawnSubagentDirect } from "../../../agents/subagent-spawn.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import { type SubagentsCommandContext, stopWithText } from "./shared.js";
|
||||
|
||||
export async function handleSubagentsSpawnAction(
|
||||
ctx: SubagentsCommandContext,
|
||||
): Promise<CommandHandlerResult> {
|
||||
const { params, requesterKey, restTokens } = ctx;
|
||||
const agentId = restTokens[0];
|
||||
|
||||
const taskParts: string[] = [];
|
||||
let model: string | undefined;
|
||||
let thinking: string | undefined;
|
||||
for (let i = 1; i < restTokens.length; i++) {
|
||||
if (restTokens[i] === "--model" && i + 1 < restTokens.length) {
|
||||
i += 1;
|
||||
model = restTokens[i];
|
||||
} else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) {
|
||||
i += 1;
|
||||
thinking = restTokens[i];
|
||||
} else {
|
||||
taskParts.push(restTokens[i]);
|
||||
}
|
||||
}
|
||||
const task = taskParts.join(" ").trim();
|
||||
if (!agentId || !task) {
|
||||
return stopWithText(
|
||||
"Usage: /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
);
|
||||
}
|
||||
|
||||
const commandTo = typeof params.command.to === "string" ? params.command.to.trim() : "";
|
||||
const originatingTo =
|
||||
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "";
|
||||
const fallbackTo = typeof params.ctx.To === "string" ? params.ctx.To.trim() : "";
|
||||
const normalizedTo = originatingTo || commandTo || fallbackTo || undefined;
|
||||
|
||||
const result = await spawnSubagentDirect(
|
||||
{
|
||||
task,
|
||||
agentId,
|
||||
model,
|
||||
thinking,
|
||||
mode: "run",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
{
|
||||
agentSessionKey: requesterKey,
|
||||
agentChannel: params.ctx.OriginatingChannel ?? params.command.channel,
|
||||
agentAccountId: params.ctx.AccountId,
|
||||
agentTo: normalizedTo,
|
||||
agentThreadId: params.ctx.MessageThreadId,
|
||||
agentGroupId: params.sessionEntry?.groupId ?? null,
|
||||
agentGroupChannel: params.sessionEntry?.groupChannel ?? null,
|
||||
agentGroupSpace: params.sessionEntry?.space ?? null,
|
||||
},
|
||||
);
|
||||
if (result.status === "accepted") {
|
||||
return stopWithText(
|
||||
`Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`,
|
||||
);
|
||||
}
|
||||
return stopWithText(`Spawn failed: ${result.error ?? result.status}`);
|
||||
}
|
||||
42
src/auto-reply/reply/commands-subagents/action-unfocus.ts
Normal file
42
src/auto-reply/reply/commands-subagents/action-unfocus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
|
||||
import type { CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
type SubagentsCommandContext,
|
||||
isDiscordSurface,
|
||||
resolveDiscordAccountId,
|
||||
stopWithText,
|
||||
} from "./shared.js";
|
||||
|
||||
export function handleSubagentsUnfocusAction(ctx: SubagentsCommandContext): CommandHandlerResult {
|
||||
const { params } = ctx;
|
||||
if (!isDiscordSurface(params)) {
|
||||
return stopWithText("⚠️ /unfocus is only available on Discord.");
|
||||
}
|
||||
|
||||
const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : "";
|
||||
if (!threadId.trim()) {
|
||||
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
|
||||
}
|
||||
|
||||
const threadBindings = getThreadBindingManager(resolveDiscordAccountId(params));
|
||||
if (!threadBindings) {
|
||||
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
|
||||
}
|
||||
|
||||
const binding = threadBindings.getByThreadId(threadId);
|
||||
if (!binding) {
|
||||
return stopWithText("ℹ️ This thread is not currently focused.");
|
||||
}
|
||||
|
||||
const senderId = params.command.senderId?.trim() || "";
|
||||
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
|
||||
return stopWithText(`⚠️ Only ${binding.boundBy} can unfocus this thread.`);
|
||||
}
|
||||
|
||||
threadBindings.unbindThread({
|
||||
threadId,
|
||||
reason: "manual",
|
||||
sendFarewell: true,
|
||||
});
|
||||
return stopWithText("✅ Thread unfocused.");
|
||||
}
|
||||
432
src/auto-reply/reply/commands-subagents/shared.ts
Normal file
432
src/auto-reply/reply/commands-subagents/shared.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import type { SubagentRunRecord } from "../../../agents/subagent-registry.js";
|
||||
import {
|
||||
extractAssistantText,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
sanitizeTextContent,
|
||||
stripToolMessages,
|
||||
} from "../../../agents/tools/sessions-helpers.js";
|
||||
import type {
|
||||
SessionEntry,
|
||||
loadSessionStore as loadSessionStoreFn,
|
||||
resolveStorePath as resolveStorePathFn,
|
||||
} from "../../../config/sessions.js";
|
||||
import { parseDiscordTarget } from "../../../discord/targets.js";
|
||||
import { callGateway } from "../../../gateway/call.js";
|
||||
import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts";
|
||||
import { parseAgentSessionKey } from "../../../routing/session-key.js";
|
||||
import { extractTextFromChatContent } from "../../../shared/chat-content.js";
|
||||
import {
|
||||
formatDurationCompact,
|
||||
formatTokenUsageDisplay,
|
||||
truncateLine,
|
||||
} from "../../../shared/subagents-format.js";
|
||||
import type { CommandHandler, CommandHandlerResult } from "../commands-types.js";
|
||||
import {
|
||||
formatRunLabel,
|
||||
formatRunStatus,
|
||||
resolveSubagentTargetFromRuns,
|
||||
type SubagentTargetResolution,
|
||||
} from "../subagents-utils.js";
|
||||
|
||||
export { extractAssistantText, stripToolMessages };
|
||||
|
||||
export const COMMAND = "/subagents";
|
||||
export const COMMAND_KILL = "/kill";
|
||||
export const COMMAND_STEER = "/steer";
|
||||
export const COMMAND_TELL = "/tell";
|
||||
export const COMMAND_FOCUS = "/focus";
|
||||
export const COMMAND_UNFOCUS = "/unfocus";
|
||||
export const COMMAND_AGENTS = "/agents";
|
||||
export const ACTIONS = new Set([
|
||||
"list",
|
||||
"kill",
|
||||
"log",
|
||||
"send",
|
||||
"steer",
|
||||
"info",
|
||||
"spawn",
|
||||
"focus",
|
||||
"unfocus",
|
||||
"agents",
|
||||
"help",
|
||||
]);
|
||||
|
||||
export const RECENT_WINDOW_MINUTES = 30;
|
||||
const SUBAGENT_TASK_PREVIEW_MAX = 110;
|
||||
export const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
||||
|
||||
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function compactLine(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function formatTaskPreview(value: string) {
|
||||
return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX);
|
||||
}
|
||||
|
||||
function resolveModelDisplay(
|
||||
entry?: {
|
||||
model?: unknown;
|
||||
modelProvider?: unknown;
|
||||
modelOverride?: unknown;
|
||||
providerOverride?: unknown;
|
||||
},
|
||||
fallbackModel?: string,
|
||||
) {
|
||||
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
|
||||
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
|
||||
let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model;
|
||||
if (!combined) {
|
||||
const overrideModel =
|
||||
typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
|
||||
const overrideProvider =
|
||||
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
|
||||
combined = overrideModel.includes("/")
|
||||
? overrideModel
|
||||
: overrideModel && overrideProvider
|
||||
? `${overrideProvider}/${overrideModel}`
|
||||
: overrideModel;
|
||||
}
|
||||
if (!combined) {
|
||||
combined = fallbackModel?.trim() || "";
|
||||
}
|
||||
if (!combined) {
|
||||
return "model n/a";
|
||||
}
|
||||
const slash = combined.lastIndexOf("/");
|
||||
if (slash >= 0 && slash < combined.length - 1) {
|
||||
return combined.slice(slash + 1);
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
export function resolveDisplayStatus(entry: SubagentRunRecord) {
|
||||
const status = formatRunStatus(entry);
|
||||
return status === "error" ? "failed" : status;
|
||||
}
|
||||
|
||||
export function formatSubagentListLine(params: {
|
||||
entry: SubagentRunRecord;
|
||||
index: number;
|
||||
runtimeMs: number;
|
||||
sessionEntry?: SessionEntry;
|
||||
}) {
|
||||
const usageText = formatTokenUsageDisplay(params.sessionEntry);
|
||||
const label = truncateLine(formatRunLabel(params.entry, { maxLength: 48 }), 48);
|
||||
const task = formatTaskPreview(params.entry.task);
|
||||
const runtime = formatDurationCompact(params.runtimeMs);
|
||||
const status = resolveDisplayStatus(params.entry);
|
||||
return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return new Date(valueMs).toISOString();
|
||||
}
|
||||
|
||||
export function formatTimestampWithAge(valueMs?: number) {
|
||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||
return "n/a";
|
||||
}
|
||||
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
|
||||
}
|
||||
|
||||
export type SubagentsAction =
|
||||
| "list"
|
||||
| "kill"
|
||||
| "log"
|
||||
| "send"
|
||||
| "steer"
|
||||
| "info"
|
||||
| "spawn"
|
||||
| "focus"
|
||||
| "unfocus"
|
||||
| "agents"
|
||||
| "help";
|
||||
|
||||
export type SubagentsCommandParams = Parameters<CommandHandler>[0];
|
||||
|
||||
export type SubagentsCommandContext = {
|
||||
params: SubagentsCommandParams;
|
||||
handledPrefix: string;
|
||||
requesterKey: string;
|
||||
runs: SubagentRunRecord[];
|
||||
restTokens: string[];
|
||||
};
|
||||
|
||||
export function stopWithText(text: string): CommandHandlerResult {
|
||||
return { shouldContinue: false, reply: { text } };
|
||||
}
|
||||
|
||||
export function stopWithUnknownTargetError(error?: string): CommandHandlerResult {
|
||||
return stopWithText(`⚠️ ${error ?? "Unknown subagent."}`);
|
||||
}
|
||||
|
||||
export function resolveSubagentTarget(
|
||||
runs: SubagentRunRecord[],
|
||||
token: string | undefined,
|
||||
): SubagentTargetResolution {
|
||||
return resolveSubagentTargetFromRuns({
|
||||
runs,
|
||||
token,
|
||||
recentWindowMinutes: RECENT_WINDOW_MINUTES,
|
||||
label: (entry) => formatRunLabel(entry),
|
||||
errors: {
|
||||
missingTarget: "Missing subagent id.",
|
||||
invalidIndex: (value) => `Invalid subagent index: ${value}`,
|
||||
unknownSession: (value) => `Unknown subagent session: ${value}`,
|
||||
ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
|
||||
ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
|
||||
ambiguousRunIdPrefix: (value) => `Ambiguous run id prefix: ${value}`,
|
||||
unknownTarget: (value) => `Unknown subagent id: ${value}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSubagentEntryForToken(
|
||||
runs: SubagentRunRecord[],
|
||||
token: string | undefined,
|
||||
): { entry: SubagentRunRecord } | { reply: CommandHandlerResult } {
|
||||
const resolved = resolveSubagentTarget(runs, token);
|
||||
if (!resolved.entry) {
|
||||
return { reply: stopWithUnknownTargetError(resolved.error) };
|
||||
}
|
||||
return { entry: resolved.entry };
|
||||
}
|
||||
|
||||
export function resolveRequesterSessionKey(
|
||||
params: SubagentsCommandParams,
|
||||
opts?: { preferCommandTarget?: boolean },
|
||||
): string | undefined {
|
||||
const commandTarget = params.ctx.CommandTargetSessionKey?.trim();
|
||||
const commandSession = params.sessionKey?.trim();
|
||||
const raw = opts?.preferCommandTarget
|
||||
? commandTarget || commandSession
|
||||
: commandSession || commandTarget;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
return resolveInternalSessionKey({ key: raw, alias, mainKey });
|
||||
}
|
||||
|
||||
export function resolveHandledPrefix(normalized: string): string | null {
|
||||
return normalized.startsWith(COMMAND)
|
||||
? COMMAND
|
||||
: normalized.startsWith(COMMAND_KILL)
|
||||
? COMMAND_KILL
|
||||
: normalized.startsWith(COMMAND_STEER)
|
||||
? COMMAND_STEER
|
||||
: normalized.startsWith(COMMAND_TELL)
|
||||
? COMMAND_TELL
|
||||
: normalized.startsWith(COMMAND_FOCUS)
|
||||
? COMMAND_FOCUS
|
||||
: normalized.startsWith(COMMAND_UNFOCUS)
|
||||
? COMMAND_UNFOCUS
|
||||
: normalized.startsWith(COMMAND_AGENTS)
|
||||
? COMMAND_AGENTS
|
||||
: null;
|
||||
}
|
||||
|
||||
export function resolveSubagentsAction(params: {
|
||||
handledPrefix: string;
|
||||
restTokens: string[];
|
||||
}): SubagentsAction | null {
|
||||
if (params.handledPrefix === COMMAND) {
|
||||
const [actionRaw] = params.restTokens;
|
||||
const action = (actionRaw?.toLowerCase() || "list") as SubagentsAction;
|
||||
if (!ACTIONS.has(action)) {
|
||||
return null;
|
||||
}
|
||||
params.restTokens.splice(0, 1);
|
||||
return action;
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_KILL) {
|
||||
return "kill";
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_FOCUS) {
|
||||
return "focus";
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_UNFOCUS) {
|
||||
return "unfocus";
|
||||
}
|
||||
if (params.handledPrefix === COMMAND_AGENTS) {
|
||||
return "agents";
|
||||
}
|
||||
return "steer";
|
||||
}
|
||||
|
||||
export type FocusTargetResolution = {
|
||||
targetKind: "subagent" | "acp";
|
||||
targetSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function isDiscordSurface(params: SubagentsCommandParams): boolean {
|
||||
const channel =
|
||||
params.ctx.OriginatingChannel ??
|
||||
params.command.channel ??
|
||||
params.ctx.Surface ??
|
||||
params.ctx.Provider;
|
||||
return (
|
||||
String(channel ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "discord"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordAccountId(params: SubagentsCommandParams): string {
|
||||
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
|
||||
return accountId || "default";
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelIdForFocus(
|
||||
params: SubagentsCommandParams,
|
||||
): string | undefined {
|
||||
const toCandidates = [
|
||||
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "",
|
||||
typeof params.command.to === "string" ? params.command.to.trim() : "",
|
||||
typeof params.ctx.To === "string" ? params.ctx.To.trim() : "",
|
||||
].filter(Boolean);
|
||||
for (const candidate of toCandidates) {
|
||||
try {
|
||||
const target = parseDiscordTarget(candidate, { defaultKind: "channel" });
|
||||
if (target?.kind === "channel" && target.id) {
|
||||
return target.id;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse failures and try the next candidate.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveFocusTargetSession(params: {
|
||||
runs: SubagentRunRecord[];
|
||||
token: string;
|
||||
}): Promise<FocusTargetResolution | null> {
|
||||
const subagentMatch = resolveSubagentTarget(params.runs, params.token);
|
||||
if (subagentMatch.entry) {
|
||||
const key = subagentMatch.entry.childSessionKey;
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
return {
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: key,
|
||||
agentId: parsed?.agentId ?? "main",
|
||||
label: formatRunLabel(subagentMatch.entry),
|
||||
};
|
||||
}
|
||||
|
||||
const token = params.token.trim();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attempts: Array<Record<string, string>> = [];
|
||||
attempts.push({ key: token });
|
||||
if (SESSION_ID_RE.test(token)) {
|
||||
attempts.push({ sessionId: token });
|
||||
}
|
||||
attempts.push({ label: token });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const resolved = await callGateway<{ key?: string }>({
|
||||
method: "sessions.resolve",
|
||||
params: attempt,
|
||||
});
|
||||
const key = typeof resolved?.key === "string" ? resolved.key.trim() : "";
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
return {
|
||||
targetKind: key.includes(":subagent:") ? "subagent" : "acp",
|
||||
targetSessionKey: key,
|
||||
agentId: parsed?.agentId ?? "main",
|
||||
label: token,
|
||||
};
|
||||
} catch {
|
||||
// Try the next resolution strategy.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildSubagentsHelp() {
|
||||
return [
|
||||
"Subagents",
|
||||
"Usage:",
|
||||
"- /subagents list",
|
||||
"- /subagents kill <id|#|all>",
|
||||
"- /subagents log <id|#> [limit] [tools]",
|
||||
"- /subagents info <id|#>",
|
||||
"- /subagents send <id|#> <message>",
|
||||
"- /subagents steer <id|#> <message>",
|
||||
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
|
||||
"- /focus <subagent-label|session-key|session-id|session-label>",
|
||||
"- /unfocus",
|
||||
"- /agents",
|
||||
"- /session ttl <duration|off>",
|
||||
"- /kill <id|#|all>",
|
||||
"- /steer <id|#> <message>",
|
||||
"- /tell <id|#> <message>",
|
||||
"",
|
||||
"Ids: use the list index (#), runId/session prefix, label, or full session key.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export type ChatMessage = {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||
const role = typeof message.role === "string" ? message.role : "";
|
||||
const shouldSanitize = role === "assistant";
|
||||
const text = extractTextFromChatContent(message.content, {
|
||||
sanitizeText: shouldSanitize ? sanitizeTextContent : undefined,
|
||||
});
|
||||
return text ? { role, text } : null;
|
||||
}
|
||||
|
||||
export function formatLogLines(messages: ChatMessage[]) {
|
||||
const lines: string[] = [];
|
||||
for (const msg of messages) {
|
||||
const extracted = extractMessageText(msg);
|
||||
if (!extracted) {
|
||||
continue;
|
||||
}
|
||||
const label = extracted.role === "assistant" ? "Assistant" : "User";
|
||||
lines.push(`${label}: ${extracted.text}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export type SessionStoreCache = Map<string, Record<string, SessionEntry>>;
|
||||
|
||||
export function loadSubagentSessionEntry(
|
||||
params: SubagentsCommandParams,
|
||||
childKey: string,
|
||||
loaders: {
|
||||
loadSessionStore: typeof loadSessionStoreFn;
|
||||
resolveStorePath: typeof resolveStorePathFn;
|
||||
},
|
||||
storeCache?: SessionStoreCache,
|
||||
) {
|
||||
const parsed = parseAgentSessionKey(childKey);
|
||||
const storePath = loaders.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: parsed?.agentId,
|
||||
});
|
||||
let store = storeCache?.get(storePath);
|
||||
if (!store) {
|
||||
store = loaders.loadSessionStore(storePath);
|
||||
storeCache?.set(storePath, store);
|
||||
}
|
||||
return { storePath, store, entry: store[childKey] };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
||||
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
||||
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
@@ -120,11 +121,6 @@ export function filterMessagingToolMediaDuplicates(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAccountId(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
export function shouldSuppressMessagingToolReplies(params: {
|
||||
messageProvider?: string;
|
||||
messagingToolSentTargets?: MessagingToolSend[];
|
||||
@@ -139,7 +135,7 @@ export function shouldSuppressMessagingToolReplies(params: {
|
||||
if (!originTarget) {
|
||||
return false;
|
||||
}
|
||||
const originAccount = normalizeAccountId(params.accountId);
|
||||
const originAccount = normalizeOptionalAccountId(params.accountId);
|
||||
const sentTargets = params.messagingToolSentTargets ?? [];
|
||||
if (sentTargets.length === 0) {
|
||||
return false;
|
||||
@@ -155,7 +151,7 @@ export function shouldSuppressMessagingToolReplies(params: {
|
||||
if (!targetKey) {
|
||||
return false;
|
||||
}
|
||||
const targetAccount = normalizeAccountId(target.accountId);
|
||||
const targetAccount = normalizeOptionalAccountId(target.accountId);
|
||||
if (originAccount && targetAccount && originAccount !== targetAccount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
evaluateSessionFreshness,
|
||||
type GroupKeyResolution,
|
||||
loadSessionStore,
|
||||
resolveAndPersistSessionFile,
|
||||
resolveChannelResetConfig,
|
||||
resolveThreadFlag,
|
||||
resolveSessionResetPolicy,
|
||||
@@ -354,13 +355,21 @@ export async function initSessionState(params: {
|
||||
console.warn(`[session-init] forked session created: file=${forked.sessionFile}`);
|
||||
}
|
||||
}
|
||||
if (!sessionEntry.sessionFile) {
|
||||
sessionEntry.sessionFile = resolveSessionTranscriptPath(
|
||||
sessionEntry.sessionId,
|
||||
agentId,
|
||||
ctx.MessageThreadId,
|
||||
);
|
||||
}
|
||||
const fallbackSessionFile = !sessionEntry.sessionFile
|
||||
? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId)
|
||||
: undefined;
|
||||
const resolvedSessionFile = await resolveAndPersistSessionFile({
|
||||
sessionId: sessionEntry.sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId,
|
||||
sessionsDir: path.dirname(storePath),
|
||||
fallbackSessionFile,
|
||||
activeSessionKey: sessionKey,
|
||||
});
|
||||
sessionEntry = resolvedSessionFile.sessionEntry;
|
||||
if (isNewSession) {
|
||||
sessionEntry.compactionCount = 0;
|
||||
sessionEntry.memoryFlushCompactionCount = undefined;
|
||||
|
||||
Reference in New Issue
Block a user