diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b9bc17a5f..a67163d253c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it. - Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow. - Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow. +- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow. - Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow. - Docs/Discord: document forum channel thread creation flows and component limits. Thanks @thewilloftheshadow. diff --git a/Dockerfile b/Dockerfile index b174f9c8d15..1b40c2da353 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ ENV PATH="/root/.bun/bin:${PATH}" RUN corepack enable WORKDIR /app -RUN chown node:node /app ARG OPENCLAW_DOCKER_APT_PACKAGES="" RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ @@ -17,19 +16,17 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY --chown=node:node ui/package.json ./ui/package.json -COPY --chown=node:node patches ./patches -COPY --chown=node:node scripts ./scripts +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY ui/package.json ./ui/package.json +COPY patches ./patches +COPY scripts ./scripts -USER node RUN pnpm install --frozen-lockfile # Optionally install Chromium and Xvfb for browser automation. # Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... # Adds ~300MB but eliminates the 60-90s Playwright install on every container start. # Must run after pnpm install so playwright-core is available in node_modules. -USER root ARG OPENCLAW_INSTALL_BROWSER="" RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ apt-get update && \ @@ -39,8 +36,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -USER node -COPY --chown=node:node . . +COPY . . RUN pnpm build # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 @@ -48,6 +44,9 @@ RUN pnpm ui:build ENV NODE_ENV=production +# Allow non-root user to write temp files during runtime/tests. +RUN chown -R node:node /app + # Security hardening: Run as non-root user # The node:22-bookworm image includes a 'node' user (uid 1000) # This reduces the attack surface by preventing container escape via root privileges diff --git a/docs/channels/discord.md b/docs/channels/discord.md index aadf357a813..28c4c8e14ee 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -614,7 +614,7 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior. - parent thread metadata can be used for parent-session linkage - thread config inherits parent channel config unless a thread-specific entry exists - Channel topics are injected as untrusted context and also included in trusted inbound metadata on new sessions. + Channel topics are injected as **untrusted** context (not as system prompt). diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index d2f4b7decaa..b44d892be54 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -166,12 +166,12 @@ Behavior: - Starts a new `agent::subagent:` session with `deliver: false`. - Sub-agents default to the full tool set **minus session tools** (configurable via `tools.subagents.tools`). -- Depth policy is enforced for nested spawns. With the default `maxSpawnDepth = 2`, depth-1 sub-agents can call `sessions_spawn`, depth-2 sub-agents cannot. +- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning). - Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately. -- After completion, OpenClaw builds a sub-agent announce system message from the child session's latest assistant reply and injects it to the requester session. -- Delivery stays internal (`deliver=false`) when the requester is a sub-agent, and is user-facing (`deliver=true`) when the requester is main. -- Recipient agents can return the internal silent token to suppress duplicate outward delivery in the same turn. -- Announce replies are normalized to runtime-derived status plus result context. +- After completion, OpenClaw runs a sub-agent **announce step** and posts the result to the requester chat channel. + - If the assistant final reply is empty, the latest `toolResult` from sub-agent history is included as `Result`. +- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent. +- Announce replies are normalized to `Status`/`Result`/`Notes`; `Status` comes from runtime outcome (not model text). - Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60). - Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0ad82b64062..cb46bf4ec16 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -38,6 +38,29 @@ Pairing codes expire after 1 hour. Pending DM pairing requests are capped at **3 Slack/Discord have a special fallback: if their provider section is missing entirely, runtime group policy can resolve to `open` (with a startup warning). +### Channel model overrides + +Use `channels.modelByChannel` to pin specific channel IDs to a model. Values accept `provider/model` or configured model aliases. The channel mapping applies when a session does not already have a model override (for example, set via `/model`). + +```json5 +{ + channels: { + modelByChannel: { + discord: { + "123456789012345678": "anthropic/claude-opus-4-6", + }, + slack: { + C1234567890: "openai/gpt-4.1", + }, + telegram: { + "-1001234567890": "openai/gpt-4.1-mini", + "-1001234567890:topic:99": "anthropic/claude-sonnet-4-6", + }, + }, + }, +} +``` + ### WhatsApp WhatsApp runs through the gateway's web channel (Baileys Web). It starts automatically when a linked session exists. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index e05bcf98eea..3022d551921 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -35,7 +35,7 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session* - If direct delivery fails, it falls back to queue routing. - If queue routing is still not available, the announce is retried with a short exponential backoff before final give-up. - The completion message is a system message and includes: - - `Result` (latest assistant reply text from the child session, after a short settle retry) + - `Result` (`assistant` reply text, or latest `toolResult` if the assistant reply is empty) - `Status` (`completed successfully` / `failed` / `timed out`) - compact runtime/token stats - `--model` and `--thinking` override defaults for that specific run. @@ -90,7 +90,7 @@ Auto-archive: ## Nested Sub-Agents -By default, sub-agents can spawn one additional level (`maxSpawnDepth: 2`), enabling the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents. Set `maxSpawnDepth: 1` to disable nested spawning. +By default, sub-agents cannot spawn their own sub-agents (`maxSpawnDepth: 1`). You can enable one level of nesting by setting `maxSpawnDepth: 2`, which allows the **orchestrator pattern**: main → orchestrator sub-agent → worker sub-sub-agents. ### How to enable @@ -99,7 +99,7 @@ By default, sub-agents can spawn one additional level (`maxSpawnDepth: 2`), enab agents: { defaults: { subagents: { - maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 2) + maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1) maxChildrenPerAgent: 5, // max active children per agent session (default: 5) maxConcurrent: 8, // global concurrency lane cap (default: 8) }, @@ -110,11 +110,11 @@ By default, sub-agents can spawn one additional level (`maxSpawnDepth: 2`), enab ### Depth levels -| Depth | Session key shape | Role | Can spawn? | -| ----- | -------------------------------------------- | ----------------------------------- | ------------------------------ | -| 0 | `agent::main` | Main agent | Always | -| 1 | `agent::subagent:` | Sub-agent (orchestrator by default) | Yes, when `maxSpawnDepth >= 2` | -| 2 | `agent::subagent::subagent:` | Sub-sub-agent (leaf worker) | No, when `maxSpawnDepth = 2` | +| Depth | Session key shape | Role | Can spawn? | +| ----- | -------------------------------------------- | --------------------------------------------- | ---------------------------- | +| 0 | `agent::main` | Main agent | Always | +| 1 | `agent::subagent:` | Sub-agent (orchestrator when depth 2 allowed) | Only if `maxSpawnDepth >= 2` | +| 2 | `agent::subagent::subagent:` | Sub-sub-agent (leaf worker) | Never | ### Announce chain @@ -128,9 +128,9 @@ Each level only sees announces from its direct children. ### Tool policy by depth -- **Depth 1 (orchestrator, default with `maxSpawnDepth = 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied. -- **Depth 1 (leaf, when `maxSpawnDepth = 1`)**: No session tools. -- **Depth 2 (leaf worker, default `maxSpawnDepth = 2`)**: No session tools, `sessions_spawn` is denied at depth 2, cannot spawn further children. +- **Depth 1 (orchestrator, when `maxSpawnDepth >= 2`)**: Gets `sessions_spawn`, `subagents`, `sessions_list`, `sessions_history` so it can manage its children. Other session/system tools remain denied. +- **Depth 1 (leaf, when `maxSpawnDepth == 1`)**: No session tools (current default behavior). +- **Depth 2 (leaf worker)**: No session tools — `sessions_spawn` is always denied at depth 2. Cannot spawn further children. ### Per-agent spawn limit @@ -156,16 +156,17 @@ Note: the merge is additive, so main profiles are always available as fallbacks. ## Announce -Sub-agents report back via an announce injection step: +Sub-agents report back via an announce step: -- OpenClaw reads the child session's latest assistant reply after completion, with a short settle retry. -- It builds a system message with `Status`, `Result`, compact stats, and reply guidance. -- The message is injected with a follow-up `agent` call: - - `deliver=false` when the requester is another sub-agent, this keeps orchestration internal. - - `deliver=true` when the requester is main, this produces the user-facing update. -- Delivery context prefers captured requester origin, but non-deliverable channels (for example `webchat`) are ignored in favor of persisted deliverable routes. -- Recipient agents can return the internal silent token to suppress duplicate outward delivery in the same turn. -- `Status` is derived from runtime outcome signals, not inferred from model output. +- The announce step runs inside the sub-agent session (not the requester session). +- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted. +- Otherwise the announce reply is posted to the requester chat channel via a follow-up `agent` call (`deliver=true`). +- Announce replies preserve thread/topic routing when available (Slack threads, Telegram topics, Matrix threads). +- Announce messages are normalized to a stable template: + - `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`). + - `Result:` the summary content from the announce step (or `(not available)` if missing). + - `Notes:` error details and other useful context. +- `Status` is not inferred from model output; it comes from runtime outcome signals. Announce payloads include a stats line at the end (even when wrapped): @@ -183,7 +184,7 @@ By default, sub-agents get **all tools except session tools** and system tools: - `sessions_send` - `sessions_spawn` -With the default `maxSpawnDepth = 2`, depth-1 orchestrator sub-agents receive `sessions_spawn`, `subagents`, `sessions_list`, and `sessions_history` so they can manage their children. If you set `maxSpawnDepth = 1`, those session tools stay denied. +When `maxSpawnDepth >= 2`, depth-1 orchestrator sub-agents additionally receive `sessions_spawn`, `subagents`, `sessions_list`, and `sessions_history` so they can manage their children. Override via config: diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 91f7aede491..977af0c9666 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk"; @@ -178,12 +178,8 @@ describe("msteams messenger", () => { }); it("preserves parsed mentions when appending OneDrive fallback file links", async () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; - const tmpStateDir = await mkdtemp(path.join(os.tmpdir(), "msteams-mention-state-")); - process.env.OPENCLAW_STATE_DIR = tmpStateDir; - const workspaceDir = path.join(tmpStateDir, "workspace"); - await mkdir(workspaceDir, { recursive: true }); - const localFile = path.join(workspaceDir, "note.txt"); + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "msteams-mention-")); + const localFile = path.join(tmpDir, "note.txt"); await writeFile(localFile, "hello"); try { @@ -236,12 +232,7 @@ describe("msteams messenger", () => { }, ]); } finally { - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } - await rm(tmpStateDir, { recursive: true, force: true }); + await rm(tmpDir, { recursive: true, force: true }); } }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts index 08d499d943e..0cb5b62c835 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts @@ -94,14 +94,13 @@ describe("sessions_spawn depth + child limits", () => { }); }); - it("allows depth-1 callers by default (maxSpawnDepth defaults to 2)", async () => { + it("rejects spawning when caller depth reaches maxSpawnDepth", async () => { const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); const result = await tool.execute("call-depth-reject", { task: "hello" }); expect(result.details).toMatchObject({ - status: "accepted", - childSessionKey: expect.stringMatching(/^agent:main:subagent:/), - runId: "run-depth", + status: "forbidden", + error: "sessions_spawn is not allowed at this depth (current depth: 1, max: 1)", }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index 8f77474d11b..b3fbdacf152 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -133,6 +133,35 @@ const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { ); }; +function expectSingleCompletionSend( + calls: GatewayRequest[], + expected: { sessionKey: string; channel: string; to: string; message: string }, +) { + const sendCalls = calls.filter((call) => call.method === "send"); + expect(sendCalls).toHaveLength(1); + const send = sendCalls[0]?.params as + | { sessionKey?: string; channel?: string; to?: string; message?: string } + | undefined; + expect(send?.sessionKey).toBe(expected.sessionKey); + expect(send?.channel).toBe(expected.channel); + expect(send?.to).toBe(expected.to); + expect(send?.message).toBe(expected.message); +} + +function createDeleteCleanupHooks(setDeletedKey: (key: string | undefined) => void) { + return { + onAgentSubagentSpawn: (params: unknown) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params: unknown) => { + const rec = params as { key?: string } | undefined; + setDeletedKey(rec?.key); + }, + }; +} + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -155,6 +184,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { const tool = await getSessionsSpawnTool({ agentSessionKey: "main", agentChannel: "whatsapp", + agentTo: "+123", }); const result = await tool.execute("call2", { @@ -183,7 +213,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); await waitFor(() => patchCalls.some((call) => call.label === "my-task")); - await waitFor(() => ctx.calls.filter((c) => c.method === "agent").length >= 2); + await waitFor(() => ctx.calls.filter((c) => c.method === "send").length >= 1); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); @@ -192,22 +222,21 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(labelPatch?.key).toBe(child.sessionKey); expect(labelPatch?.label).toBe("my-task"); - // Two agent calls: subagent spawn + main agent trigger + // Subagent spawn call plus direct outbound completion send. const agentCalls = ctx.calls.filter((c) => c.method === "agent"); - expect(agentCalls).toHaveLength(2); + expect(agentCalls).toHaveLength(1); // First call: subagent spawn const first = agentCalls[0]?.params as { lane?: string } | undefined; expect(first?.lane).toBe("subagent"); - // Second call: main agent trigger (not "Sub-agent announce step." anymore) - const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined; - expect(second?.sessionKey).toBe("main"); - expect(second?.message).toContain("subagent task"); - - // No direct send to external channel (main agent handles delivery) - const sendCalls = ctx.calls.filter((c) => c.method === "send"); - expect(sendCalls.length).toBe(0); + // Direct send should route completion to the requester channel/session. + expectSingleCompletionSend(ctx.calls, { + sessionKey: "agent:main:main", + channel: "whatsapp", + to: "+123", + message: "✅ Subagent main finished\n\ndone", + }); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); @@ -216,20 +245,15 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { callGatewayMock.mockReset(); let deletedKey: string | undefined; const ctx = setupSessionsSpawnGatewayMock({ - onAgentSubagentSpawn: (params) => { - const rec = params as { channel?: string; timeout?: number } | undefined; - expect(rec?.channel).toBe("discord"); - expect(rec?.timeout).toBe(1); - }, - onSessionsDelete: (params) => { - const rec = params as { key?: string } | undefined; - deletedKey = rec?.key; - }, + ...createDeleteCleanupHooks((key) => { + deletedKey = key; + }), }); const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", + agentTo: "discord:dm:u123", }); const result = await tool.execute("call1", { @@ -267,7 +291,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(childWait?.timeoutMs).toBe(1000); const agentCalls = ctx.calls.filter((call) => call.method === "agent"); - expect(agentCalls).toHaveLength(2); + expect(agentCalls).toHaveLength(1); const first = agentCalls[0]?.params as | { @@ -283,19 +307,12 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - const second = agentCalls[1]?.params as - | { - sessionKey?: string; - message?: string; - deliver?: boolean; - } - | undefined; - expect(second?.sessionKey).toBe("discord:group:req"); - expect(second?.deliver).toBe(true); - expect(second?.message).toContain("subagent task"); - - const sendCalls = ctx.calls.filter((c) => c.method === "send"); - expect(sendCalls.length).toBe(0); + expectSingleCompletionSend(ctx.calls, { + sessionKey: "agent:main:discord:group:req", + channel: "discord", + to: "discord:dm:u123", + message: "✅ Subagent main finished", + }); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); @@ -306,21 +323,16 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { let deletedKey: string | undefined; const ctx = setupSessionsSpawnGatewayMock({ includeChatHistory: true, - onAgentSubagentSpawn: (params) => { - const rec = params as { channel?: string; timeout?: number } | undefined; - expect(rec?.channel).toBe("discord"); - expect(rec?.timeout).toBe(1); - }, - onSessionsDelete: (params) => { - const rec = params as { key?: string } | undefined; - deletedKey = rec?.key; - }, + ...createDeleteCleanupHooks((key) => { + deletedKey = key; + }), agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 }, }); const tool = await getSessionsSpawnTool({ agentSessionKey: "discord:group:req", agentChannel: "discord", + agentTo: "discord:dm:u123", }); const result = await tool.execute("call1b", { @@ -338,29 +350,27 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { throw new Error("missing child runId"); } await waitFor(() => ctx.waitCalls.some((call) => call.runId === child.runId)); - await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => ctx.calls.filter((call) => call.method === "send").length >= 1); await waitFor(() => Boolean(deletedKey)); const childWait = ctx.waitCalls.find((call) => call.runId === child.runId); expect(childWait?.timeoutMs).toBe(1000); expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); - // Two agent calls: subagent spawn + main agent trigger + // One agent call for spawn, then direct completion send. const agentCalls = ctx.calls.filter((call) => call.method === "agent"); - expect(agentCalls).toHaveLength(2); + expect(agentCalls).toHaveLength(1); // First call: subagent spawn const first = agentCalls[0]?.params as { lane?: string } | undefined; expect(first?.lane).toBe("subagent"); - // Second call: main agent trigger - const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; - expect(second?.sessionKey).toBe("discord:group:req"); - expect(second?.deliver).toBe(true); - - // No direct send to external channel (main agent handles delivery) - const sendCalls = ctx.calls.filter((c) => c.method === "send"); - expect(sendCalls.length).toBe(0); + expectSingleCompletionSend(ctx.calls, { + sessionKey: "agent:main:discord:group:req", + channel: "discord", + to: "discord:dm:u123", + message: "✅ Subagent main finished\n\ndone", + }); // Session should be deleted expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 3c363ac4172..14b0e2d29bb 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -1,5 +1,4 @@ import { getChannelDock } from "../channels/dock.js"; -import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js"; @@ -84,8 +83,7 @@ function resolveSubagentDenyList(depth: number, maxSpawnDepth: number): string[] export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy { const configured = cfg?.tools?.subagents?.tools; - const maxSpawnDepth = - cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + const maxSpawnDepth = cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; const effectiveDepth = typeof depth === "number" && depth >= 0 ? depth : 1; const baseDeny = resolveSubagentDenyList(effectiveDepth, maxSpawnDepth); const deny = [...baseDeny, ...(Array.isArray(configured?.deny) ? configured.deny : [])]; diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 8af9bed03b7..b6e594a401b 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -7,13 +7,8 @@ type RequesterResolution = { requesterOrigin?: Record; } | null; -type DescendantRun = { - runId: string; - requesterSessionKey: string; - childSessionKey: string; -}; - const agentSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" })); +const sendSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" })); const sessionsDeleteSpy = vi.fn((_req: AgentCallRequest) => undefined); const readLatestAssistantReplyMock = vi.fn( async (_sessionKey?: string): Promise => "raw subagent reply", @@ -27,9 +22,11 @@ const embeddedRunMock = { const subagentRegistryMock = { isSubagentSessionRunActive: vi.fn(() => true), countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0), - listDescendantRunsForRequester: vi.fn((_sessionKey: string): DescendantRun[] => []), resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null), }; +const chatHistoryMock = vi.fn(async (_sessionKey?: string) => ({ + messages: [] as Array, +})); let sessionStore: Record> = {}; let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { session: { @@ -70,9 +67,15 @@ vi.mock("../gateway/call.js", () => ({ if (typed.method === "agent") { return await agentSpy(typed); } + if (typed.method === "send") { + return await sendSpy(typed); + } if (typed.method === "agent.wait") { return { status: "error", startedAt: 10, endedAt: 20, error: "boom" }; } + if (typed.method === "chat.history") { + return await chatHistoryMock(typed.params?.sessionKey); + } if (typed.method === "sessions.patch") { return {}; } @@ -112,6 +115,7 @@ vi.mock("../config/config.js", async (importOriginal) => { describe("subagent announce formatting", () => { beforeEach(() => { agentSpy.mockClear(); + sendSpy.mockClear(); sessionsDeleteSpy.mockClear(); embeddedRunMock.isEmbeddedPiRunActive.mockReset().mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false); @@ -119,9 +123,9 @@ describe("subagent announce formatting", () => { embeddedRunMock.waitForEmbeddedPiRunEnd.mockReset().mockResolvedValue(true); subagentRegistryMock.isSubagentSessionRunActive.mockReset().mockReturnValue(true); subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0); - subagentRegistryMock.listDescendantRunsForRequester.mockReset().mockReturnValue([]); subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null); readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply"); + chatHistoryMock.mockReset().mockResolvedValue({ messages: [] }); sessionStore = {}; configOverride = { session: { @@ -205,6 +209,72 @@ describe("subagent announce formatting", () => { ); }); + it.each([ + { role: "toolResult", toolOutput: "tool output line 1", childRunId: "run-tool-fallback-1" }, + { role: "tool", toolOutput: "tool output line 2", childRunId: "run-tool-fallback-2" }, + ] as const)( + "falls back to latest $role output when assistant reply is empty", + async (testCase) => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + chatHistoryMock.mockResolvedValueOnce({ + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + { + role: testCase.role, + content: [{ type: "text", text: testCase.toolOutput }], + }, + ], + }); + readLatestAssistantReplyMock.mockResolvedValue(""); + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: testCase.childRunId, + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + ...defaultOutcomeAnnounce, + waitForCompletion: false, + }); + + const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain(testCase.toolOutput); + }, + ); + + it("uses latest assistant text when it appears after a tool output", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + chatHistoryMock.mockResolvedValueOnce({ + messages: [ + { + role: "tool", + content: [{ type: "text", text: "tool output line" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "assistant final line" }], + }, + ], + }); + readLatestAssistantReplyMock.mockResolvedValue(""); + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-latest-assistant", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + ...defaultOutcomeAnnounce, + waitForCompletion: false, + }); + + const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain("assistant final line"); + }); + it("keeps full findings and includes compact stats", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); sessionStore = { @@ -242,6 +312,121 @@ describe("subagent announce formatting", () => { expect(msg).toContain("step-139"); }); + it("sends deterministic completion message directly for manual spawn completion", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-direct", + inputTokens: 12, + outputTokens: 34, + totalTokens: 46, + }, + "agent:main:main": { + sessionId: "requester-session", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: 2" }] }], + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-completion", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + const rawMessage = call?.params?.message; + const msg = typeof rawMessage === "string" ? rawMessage : ""; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:12345"); + expect(call?.params?.sessionKey).toBe("agent:main:main"); + expect(msg).toContain("✅ Subagent main finished"); + expect(msg).toContain("final answer: 2"); + expect(msg).not.toContain("Convert the result above into your normal assistant voice"); + }); + + it("ignores stale session thread hints for manual completion direct-send", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-direct-thread", + }, + "agent:main:main": { + sessionId: "requester-session-thread", + lastChannel: "discord", + lastTo: "channel:stale", + lastThreadId: 42, + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }], + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-stale-thread", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:12345"); + expect(call?.params?.threadId).toBeUndefined(); + }); + + it("passes requesterOrigin.threadId for manual completion direct-send", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-direct-thread-pass", + }, + "agent:main:main": { + sessionId: "requester-session-thread-pass", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }], + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-thread-pass", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "channel:12345", + accountId: "acct-1", + threadId: 99, + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("discord"); + expect(call?.params?.to).toBe("channel:12345"); + expect(call?.params?.threadId).toBe("99"); + }); + it("steers announcements into an active run when queue mode is steer", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -356,6 +541,139 @@ describe("subagent announce formatting", () => { expect(new Set(idempotencyKeys).size).toBe(2); }); + it("prefers direct delivery first for completion-mode and then queues on direct failure", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + sessionStore = { + "agent:main:main": { + sessionId: "session-collect", + lastChannel: "whatsapp", + lastTo: "+1555", + queueMode: "collect", + queueDebounceMs: 0, + }, + }; + sendSpy.mockRejectedValueOnce(new Error("direct delivery unavailable")); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-direct-fallback", + requesterSessionKey: "main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + await expect.poll(() => sendSpy.mock.calls.length).toBe(1); + await expect.poll(() => agentSpy.mock.calls.length).toBe(1); + expect(sendSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "send", + params: { sessionKey: "agent:main:main" }, + }); + expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "agent", + params: { sessionKey: "agent:main:main" }, + }); + expect(agentSpy.mock.calls[0]?.[0]).toMatchObject({ + method: "agent", + params: { channel: "whatsapp", to: "+1555", deliver: true }, + }); + }); + + it("returns failure for completion-mode when direct delivery fails and queue fallback is unavailable", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); + embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); + sessionStore = { + "agent:main:main": { + sessionId: "session-direct-only", + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }; + sendSpy.mockRejectedValueOnce(new Error("direct delivery unavailable")); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-direct-fail", + requesterSessionKey: "main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(false); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).toHaveBeenCalledTimes(0); + }); + + it("uses assistant output for completion-mode when latest assistant text exists", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + chatHistoryMock.mockResolvedValueOnce({ + messages: [ + { + role: "toolResult", + content: [{ type: "text", text: "old tool output" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "assistant completion text" }], + }, + ], + }); + readLatestAssistantReplyMock.mockResolvedValue("assistant ignored fallback"); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-assistant-output", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + await expect.poll(() => sendSpy.mock.calls.length).toBe(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain("assistant completion text"); + expect(msg).not.toContain("old tool output"); + }); + + it("falls back to latest tool output for completion-mode when assistant output is empty", async () => { + const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + chatHistoryMock.mockResolvedValueOnce({ + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "" }], + }, + { + role: "toolResult", + content: [{ type: "text", text: "tool output only" }], + }, + ], + }); + readLatestAssistantReplyMock.mockResolvedValue(""); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-completion-tool-output", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + expectsCompletionMessage: true, + ...defaultOutcomeAnnounce, + }); + + expect(didAnnounce).toBe(true); + await expect.poll(() => sendSpy.mock.calls.length).toBe(1); + const call = sendSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain("tool output only"); + }); + it("queues announce delivery back into requester subagent session", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -388,7 +706,24 @@ describe("subagent announce formatting", () => { expect(call?.params?.to).toBeUndefined(); }); - it("includes threadId when origin has an active topic/thread", async () => { + it.each([ + { + testName: "includes threadId when origin has an active topic/thread", + childRunId: "run-thread", + expectedThreadId: "42", + requesterOrigin: undefined, + }, + { + testName: "prefers requesterOrigin.threadId over session entry threadId", + childRunId: "run-thread-override", + expectedThreadId: "99", + requesterOrigin: { + channel: "telegram", + to: "telegram:123", + threadId: 99, + }, + }, + ] as const)("$testName", async (testCase) => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); @@ -405,9 +740,10 @@ describe("subagent announce formatting", () => { const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", - childRunId: "run-thread", + childRunId: testCase.childRunId, requesterSessionKey: "main", requesterDisplayKey: "main", + ...(testCase.requesterOrigin ? { requesterOrigin: testCase.requesterOrigin } : {}), ...defaultOutcomeAnnounce, }); @@ -415,42 +751,7 @@ describe("subagent announce formatting", () => { const params = await getSingleAgentCallParams(); expect(params.channel).toBe("telegram"); expect(params.to).toBe("telegram:123"); - expect(params.threadId).toBe("42"); - }); - - it("prefers requesterOrigin.threadId over session entry threadId", async () => { - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); - sessionStore = { - "agent:main:main": { - sessionId: "session-thread-override", - lastChannel: "telegram", - lastTo: "telegram:123", - lastThreadId: 42, - queueMode: "collect", - queueDebounceMs: 0, - }, - }; - - const didAnnounce = await runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:test", - childRunId: "run-thread-override", - requesterSessionKey: "main", - requesterDisplayKey: "main", - requesterOrigin: { - channel: "telegram", - to: "telegram:123", - threadId: 99, - }, - ...defaultOutcomeAnnounce, - }); - - expect(didAnnounce).toBe(true); - await expect.poll(() => agentSpy.mock.calls.length).toBe(1); - - const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; - expect(call?.params?.threadId).toBe("99"); + expect(params.threadId).toBe(testCase.expectedThreadId); }); it("splits collect-mode queues when accountId differs", async () => { @@ -494,16 +795,31 @@ describe("subagent announce formatting", () => { expect(accountIds).toEqual(expect.arrayContaining(["acct-a", "acct-b"])); }); - it("uses requester origin for direct announce when not queued", async () => { + it.each([ + { + testName: "uses requester origin for direct announce when not queued", + childRunId: "run-direct", + requesterOrigin: { channel: "whatsapp", accountId: "acct-123" }, + expectedChannel: "whatsapp", + expectedAccountId: "acct-123", + }, + { + testName: "normalizes requesterOrigin for direct announce delivery", + childRunId: "run-direct-origin", + requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " }, + expectedChannel: "whatsapp", + expectedAccountId: "acct-987", + }, + ] as const)("$testName", async (testCase) => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", - childRunId: "run-direct", + childRunId: testCase.childRunId, requesterSessionKey: "agent:main:main", - requesterOrigin: { channel: "whatsapp", accountId: "acct-123" }, + requesterOrigin: testCase.requesterOrigin, requesterDisplayKey: "main", ...defaultOutcomeAnnounce, }); @@ -513,8 +829,8 @@ describe("subagent announce formatting", () => { params?: Record; expectFinal?: boolean; }; - expect(call?.params?.channel).toBe("whatsapp"); - expect(call?.params?.accountId).toBe("acct-123"); + expect(call?.params?.channel).toBe(testCase.expectedChannel); + expect(call?.params?.accountId).toBe(testCase.expectedAccountId); expect(call?.expectFinal).toBe(true); }); @@ -617,93 +933,6 @@ describe("subagent announce formatting", () => { expect(agentSpy).not.toHaveBeenCalled(); }); - it("waits for follow-up reply when descendant runs exist and child reply is still waiting", async () => { - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - const waitingReply = "Spawned the nested subagent. Waiting for its auto-announced result."; - const finalReply = "Nested subagent finished and I synthesized the final result."; - - subagentRegistryMock.listDescendantRunsForRequester.mockImplementation((sessionKey: string) => - sessionKey === "agent:main:subagent:parent" - ? [ - { - runId: "run-leaf", - requesterSessionKey: sessionKey, - childSessionKey: "agent:main:subagent:parent:subagent:leaf", - }, - ] - : [], - ); - readLatestAssistantReplyMock - .mockResolvedValueOnce(waitingReply) - .mockResolvedValueOnce(waitingReply) - .mockResolvedValueOnce(finalReply); - - vi.useFakeTimers(); - try { - const announcePromise = runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:parent", - childRunId: "run-parent", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - ...defaultOutcomeAnnounce, - }); - - await vi.advanceTimersByTimeAsync(500); - const didAnnounce = await announcePromise; - expect(didAnnounce).toBe(true); - } finally { - vi.useRealTimers(); - } - - const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; - const msg = call?.params?.message as string; - expect(msg).toContain(finalReply); - expect(msg).not.toContain("Waiting for its auto-announced result."); - }); - - it("defers announce when descendant follow-up reply has not arrived yet", async () => { - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - const waitingReply = "Spawned the nested subagent. Waiting for its auto-announced result."; - - subagentRegistryMock.listDescendantRunsForRequester.mockImplementation((sessionKey: string) => - sessionKey === "agent:main:subagent:parent" - ? [ - { - runId: "run-leaf", - requesterSessionKey: sessionKey, - childSessionKey: "agent:main:subagent:parent:subagent:leaf", - }, - ] - : [], - ); - readLatestAssistantReplyMock.mockResolvedValue(waitingReply); - - vi.useFakeTimers(); - try { - const announcePromise = runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:parent", - childRunId: "run-parent-still-waiting", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "main", - task: "nested test", - timeoutMs: 700, - cleanup: "keep", - waitForCompletion: false, - startedAt: 10, - endedAt: 20, - outcome: { status: "ok" }, - }); - - await vi.advanceTimersByTimeAsync(1200); - const didAnnounce = await announcePromise; - expect(didAnnounce).toBe(false); - } finally { - vi.useRealTimers(); - } - - expect(agentSpy).not.toHaveBeenCalled(); - }); - it("bubbles child announce to parent requester when requester subagent already ended", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false); @@ -784,26 +1013,6 @@ describe("subagent announce formatting", () => { expect(agentSpy).not.toHaveBeenCalled(); }); - it("normalizes requesterOrigin for direct announce delivery", async () => { - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); - - const didAnnounce = await runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:test", - childRunId: "run-direct-origin", - requesterSessionKey: "agent:main:main", - requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " }, - requesterDisplayKey: "main", - ...defaultOutcomeAnnounce, - }); - - expect(didAnnounce).toBe(true); - const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; - expect(call?.params?.channel).toBe("whatsapp"); - expect(call?.params?.accountId).toBe("acct-987"); - }); - it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true); @@ -836,35 +1045,6 @@ describe("subagent announce formatting", () => { expect(call?.params?.to).toBe("bluebubbles:chat_guid:123"); }); - it("falls back to persisted deliverable route when requesterOrigin channel is non-deliverable", async () => { - const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); - embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false); - embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false); - sessionStore = { - "agent:main:main": { - sessionId: "session-webchat-origin", - lastChannel: "discord", - lastTo: "discord:channel:123", - lastAccountId: "acct-store", - }, - }; - - const didAnnounce = await runSubagentAnnounceFlow({ - childSessionKey: "agent:main:subagent:test", - childRunId: "run-webchat-origin", - requesterSessionKey: "main", - requesterOrigin: { channel: "webchat", to: "ignored", accountId: "acct-live" }, - requesterDisplayKey: "main", - ...defaultOutcomeAnnounce, - }); - - expect(didAnnounce).toBe(true); - const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; - expect(call?.params?.channel).toBe("discord"); - expect(call?.params?.to).toBe("discord:channel:123"); - expect(call?.params?.accountId).toBe("acct-live"); - }); - it("routes to parent subagent when parent run ended but session still exists (#18037)", async () => { // Scenario: Newton (depth-1) spawns Birdie (depth-2). Newton's agent turn ends // after spawning but Newton's SESSION still exists (waiting for Birdie's result). diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 3f328e62080..389ee114913 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1,6 +1,5 @@ import { resolveQueueSettings } from "../auto-reply/reply/queue.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; -import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, @@ -11,13 +10,14 @@ import { import { callGateway } from "../gateway/call.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; +import { extractTextFromChatContent } from "../shared/chat-content.js"; import { type DeliveryContext, deliveryContextFromSession, mergeDeliveryContext, normalizeDeliveryContext, } from "../utils/delivery-context.js"; -import { isInternalMessageChannel } from "../utils/message-channel.js"; +import { isDeliverableMessageChannel } from "../utils/message-channel.js"; import { buildAnnounceIdFromChildRun, buildAnnounceIdempotencyKey, @@ -30,7 +30,170 @@ import { } from "./pi-embedded.js"; import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; -import { readLatestAssistantReply } from "./tools/agent-step.js"; +import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js"; + +type ToolResultMessage = { + role?: unknown; + content?: unknown; +}; + +type SubagentDeliveryPath = "queued" | "steered" | "direct" | "none"; + +type SubagentAnnounceDeliveryResult = { + delivered: boolean; + path: SubagentDeliveryPath; + error?: string; +}; + +function buildCompletionDeliveryMessage(params: { + findings: string; + subagentName: string; +}): string { + const findingsText = params.findings.trim(); + const hasFindings = findingsText.length > 0 && findingsText !== "(no output)"; + const header = `✅ Subagent ${params.subagentName} finished`; + if (!hasFindings) { + return header; + } + return `${header}\n\n${findingsText}`; +} + +function summarizeDeliveryError(error: unknown): string { + if (error instanceof Error) { + return error.message || "error"; + } + if (typeof error === "string") { + return error; + } + if (error === undefined || error === null) { + return "unknown error"; + } + try { + return JSON.stringify(error); + } catch { + return "error"; + } +} + +function extractToolResultText(content: unknown): string { + if (typeof content === "string") { + return sanitizeTextContent(content); + } + if (content && typeof content === "object" && !Array.isArray(content)) { + const obj = content as { + text?: unknown; + output?: unknown; + content?: unknown; + result?: unknown; + error?: unknown; + summary?: unknown; + }; + if (typeof obj.text === "string") { + return sanitizeTextContent(obj.text); + } + if (typeof obj.output === "string") { + return sanitizeTextContent(obj.output); + } + if (typeof obj.content === "string") { + return sanitizeTextContent(obj.content); + } + if (typeof obj.result === "string") { + return sanitizeTextContent(obj.result); + } + if (typeof obj.error === "string") { + return sanitizeTextContent(obj.error); + } + if (typeof obj.summary === "string") { + return sanitizeTextContent(obj.summary); + } + } + if (!Array.isArray(content)) { + return ""; + } + const joined = extractTextFromChatContent(content, { + sanitizeText: sanitizeTextContent, + normalizeText: (text) => text, + joinWith: "\n", + }); + return joined?.trim() ?? ""; +} + +function extractInlineTextContent(content: unknown): string { + if (!Array.isArray(content)) { + return ""; + } + return ( + extractTextFromChatContent(content, { + sanitizeText: sanitizeTextContent, + normalizeText: (text) => text.trim(), + joinWith: "", + }) ?? "" + ); +} + +function extractSubagentOutputText(message: unknown): string { + if (!message || typeof message !== "object") { + return ""; + } + const role = (message as { role?: unknown }).role; + const content = (message as { content?: unknown }).content; + if (role === "assistant") { + const assistantText = extractAssistantText(message); + if (assistantText) { + return assistantText; + } + if (typeof content === "string") { + return sanitizeTextContent(content); + } + if (Array.isArray(content)) { + return extractInlineTextContent(content); + } + return ""; + } + if (role === "toolResult" || role === "tool") { + return extractToolResultText((message as ToolResultMessage).content); + } + if (typeof content === "string") { + return sanitizeTextContent(content); + } + if (Array.isArray(content)) { + return extractInlineTextContent(content); + } + return ""; +} + +async function readLatestSubagentOutput(sessionKey: string): Promise { + const history = await callGateway<{ messages?: Array }>({ + method: "chat.history", + params: { sessionKey, limit: 50 }, + }); + const messages = Array.isArray(history?.messages) ? history.messages : []; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i]; + const text = extractSubagentOutputText(msg); + if (text) { + return text; + } + } + return undefined; +} + +async function readLatestSubagentOutputWithRetry(params: { + sessionKey: string; + maxWaitMs: number; +}): Promise { + const RETRY_INTERVAL_MS = 100; + const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000)); + let result: string | undefined; + while (Date.now() < deadline) { + result = await readLatestSubagentOutput(params.sessionKey); + if (result?.trim()) { + return result; + } + await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS)); + } + return result; +} function formatDurationShort(valueMs?: number) { if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) { @@ -110,8 +273,8 @@ function resolveAnnounceOrigin( ): DeliveryContext | undefined { const normalizedRequester = normalizeDeliveryContext(requesterOrigin); const normalizedEntry = deliveryContextFromSession(entry); - if (normalizedRequester?.channel && isInternalMessageChannel(normalizedRequester.channel)) { - // Ignore internal channel hints, for example webchat, + if (normalizedRequester?.channel && !isDeliverableMessageChannel(normalizedRequester.channel)) { + // Ignore internal/non-deliverable channel hints (for example webchat) // so a valid persisted route can still be used for outbound delivery. return mergeDeliveryContext( { @@ -121,7 +284,7 @@ function resolveAnnounceOrigin( normalizedEntry, ); } - // requesterOrigin, captured at spawn time, reflects the channel the user is + // requesterOrigin (captured at spawn time) reflects the channel the user is // actually on and must take priority over the session entry, which may carry // stale lastChannel / lastTo values from a previous channel interaction. return mergeDeliveryContext(normalizedRequester, normalizedEntry); @@ -245,6 +408,182 @@ async function maybeQueueSubagentAnnounce(params: { return "none"; } +function queueOutcomeToDeliveryResult( + outcome: "steered" | "queued" | "none", +): SubagentAnnounceDeliveryResult { + if (outcome === "steered") { + return { + delivered: true, + path: "steered", + }; + } + if (outcome === "queued") { + return { + delivered: true, + path: "queued", + }; + } + return { + delivered: false, + path: "none", + }; +} + +async function sendSubagentAnnounceDirectly(params: { + targetRequesterSessionKey: string; + triggerMessage: string; + completionMessage?: string; + expectsCompletionMessage: boolean; + directIdempotencyKey: string; + completionDirectOrigin?: DeliveryContext; + directOrigin?: DeliveryContext; + requesterIsSubagent: boolean; +}): Promise { + const cfg = loadConfig(); + const canonicalRequesterSessionKey = resolveRequesterStoreKey( + cfg, + params.targetRequesterSessionKey, + ); + try { + const completionDirectOrigin = normalizeDeliveryContext(params.completionDirectOrigin); + const completionChannelRaw = + typeof completionDirectOrigin?.channel === "string" + ? completionDirectOrigin.channel.trim() + : ""; + const completionChannel = + completionChannelRaw && isDeliverableMessageChannel(completionChannelRaw) + ? completionChannelRaw + : ""; + const completionTo = + typeof completionDirectOrigin?.to === "string" ? completionDirectOrigin.to.trim() : ""; + const hasCompletionDirectTarget = + !params.requesterIsSubagent && Boolean(completionChannel) && Boolean(completionTo); + + if ( + params.expectsCompletionMessage && + hasCompletionDirectTarget && + params.completionMessage?.trim() + ) { + const completionThreadId = + completionDirectOrigin?.threadId != null && completionDirectOrigin.threadId !== "" + ? String(completionDirectOrigin.threadId) + : undefined; + await callGateway({ + method: "send", + params: { + channel: completionChannel, + to: completionTo, + accountId: completionDirectOrigin?.accountId, + threadId: completionThreadId, + sessionKey: canonicalRequesterSessionKey, + message: params.completionMessage, + idempotencyKey: params.directIdempotencyKey, + }, + timeoutMs: 15_000, + }); + + return { + delivered: true, + path: "direct", + }; + } + + const directOrigin = normalizeDeliveryContext(params.directOrigin); + const threadId = + directOrigin?.threadId != null && directOrigin.threadId !== "" + ? String(directOrigin.threadId) + : undefined; + await callGateway({ + method: "agent", + params: { + sessionKey: canonicalRequesterSessionKey, + message: params.triggerMessage, + deliver: !params.requesterIsSubagent, + channel: params.requesterIsSubagent ? undefined : directOrigin?.channel, + accountId: params.requesterIsSubagent ? undefined : directOrigin?.accountId, + to: params.requesterIsSubagent ? undefined : directOrigin?.to, + threadId: params.requesterIsSubagent ? undefined : threadId, + idempotencyKey: params.directIdempotencyKey, + }, + expectFinal: true, + timeoutMs: 15_000, + }); + + return { + delivered: true, + path: "direct", + }; + } catch (err) { + return { + delivered: false, + path: "direct", + error: summarizeDeliveryError(err), + }; + } +} + +async function deliverSubagentAnnouncement(params: { + requesterSessionKey: string; + announceId?: string; + triggerMessage: string; + completionMessage?: string; + summaryLine?: string; + requesterOrigin?: DeliveryContext; + completionDirectOrigin?: DeliveryContext; + directOrigin?: DeliveryContext; + targetRequesterSessionKey: string; + requesterIsSubagent: boolean; + expectsCompletionMessage: boolean; + directIdempotencyKey: string; +}): Promise { + // Non-completion mode mirrors historical behavior: try queued/steered delivery first, + // then (only if not queued) attempt direct delivery. + if (!params.expectsCompletionMessage) { + const queueOutcome = await maybeQueueSubagentAnnounce({ + requesterSessionKey: params.requesterSessionKey, + announceId: params.announceId, + triggerMessage: params.triggerMessage, + summaryLine: params.summaryLine, + requesterOrigin: params.requesterOrigin, + }); + const queued = queueOutcomeToDeliveryResult(queueOutcome); + if (queued.delivered) { + return queued; + } + } + + // Completion-mode uses direct send first so manual spawns can return immediately + // in the common ready-to-deliver case. + const direct = await sendSubagentAnnounceDirectly({ + targetRequesterSessionKey: params.targetRequesterSessionKey, + triggerMessage: params.triggerMessage, + completionMessage: params.completionMessage, + directIdempotencyKey: params.directIdempotencyKey, + completionDirectOrigin: params.completionDirectOrigin, + directOrigin: params.directOrigin, + requesterIsSubagent: params.requesterIsSubagent, + expectsCompletionMessage: params.expectsCompletionMessage, + }); + if (direct.delivered || !params.expectsCompletionMessage) { + return direct; + } + + // If completion path failed direct delivery, try queueing as a fallback so the + // report can still be delivered once the requester session is idle. + const queueOutcome = await maybeQueueSubagentAnnounce({ + requesterSessionKey: params.requesterSessionKey, + announceId: params.announceId, + triggerMessage: params.triggerMessage, + summaryLine: params.summaryLine, + requesterOrigin: params.requesterOrigin, + }); + if (queueOutcome === "steered" || queueOutcome === "queued") { + return queueOutcomeToDeliveryResult(queueOutcome); + } + + return direct; +} + function loadSessionEntryByKey(sessionKey: string) { const cfg = loadConfig(); const agentId = resolveAgentIdFromSessionKey(sessionKey); @@ -253,65 +592,6 @@ function loadSessionEntryByKey(sessionKey: string) { return store[sessionKey]; } -async function readLatestAssistantReplyWithRetry(params: { - sessionKey: string; - initialReply?: string; - maxWaitMs: number; -}): Promise { - const RETRY_INTERVAL_MS = 100; - let reply = params.initialReply?.trim() ? params.initialReply : undefined; - if (reply) { - return reply; - } - - const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000)); - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS)); - const latest = await readLatestAssistantReply({ sessionKey: params.sessionKey }); - if (latest?.trim()) { - return latest; - } - } - return reply; -} - -function isLikelyWaitingForDescendantResult(reply?: string): boolean { - const text = reply?.trim(); - if (!text) { - return false; - } - const normalized = text.toLowerCase(); - if (!normalized.includes("waiting")) { - return false; - } - return ( - normalized.includes("subagent") || - normalized.includes("child") || - normalized.includes("auto-announce") || - normalized.includes("auto announced") || - normalized.includes("result") - ); -} - -async function waitForAssistantReplyChange(params: { - sessionKey: string; - previousReply?: string; - maxWaitMs: number; -}): Promise { - const RETRY_INTERVAL_MS = 200; - const previous = params.previousReply?.trim() ?? ""; - const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 30_000)); - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS)); - const latest = await readLatestAssistantReply({ sessionKey: params.sessionKey }); - const normalizedLatest = latest?.trim() ?? ""; - if (normalizedLatest && normalizedLatest !== previous) { - return latest; - } - } - return undefined; -} - export function buildSubagentSystemPrompt(params: { requesterSessionKey?: string; requesterOrigin?: DeliveryContext; @@ -328,10 +608,7 @@ export function buildSubagentSystemPrompt(params: { ? params.task.replace(/\s+/g, " ").trim() : "{{TASK_DESCRIPTION}}"; const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1; - const maxSpawnDepth = - typeof params.maxSpawnDepth === "number" - ? params.maxSpawnDepth - : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + const maxSpawnDepth = typeof params.maxSpawnDepth === "number" ? params.maxSpawnDepth : 1; const canSpawn = childDepth < maxSpawnDepth; const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; @@ -415,7 +692,11 @@ function buildAnnounceReplyInstruction(params: { remainingActiveSubagentRuns: number; requesterIsSubagent: boolean; announceType: SubagentAnnounceType; + expectsCompletionMessage?: boolean; }): string { + if (params.expectsCompletionMessage) { + return `A completed ${params.announceType} is ready for user delivery. Convert the result above into your normal assistant voice and send that user-facing update now. Keep this internal context private (don't mention system/log/stats/session details or announce type).`; + } if (params.remainingActiveSubagentRuns > 0) { const activeRunsLabel = params.remainingActiveSubagentRuns === 1 ? "run" : "runs"; return `There are still ${params.remainingActiveSubagentRuns} active subagent ${activeRunsLabel} for this session. If they are part of the same workflow, wait for the remaining results before sending a user update. If they are unrelated, respond normally using only the result above.`; @@ -442,8 +723,10 @@ export async function runSubagentAnnounceFlow(params: { label?: string; outcome?: SubagentRunOutcome; announceType?: SubagentAnnounceType; + expectsCompletionMessage?: boolean; }): Promise { let didAnnounce = false; + const expectsCompletionMessage = params.expectsCompletionMessage === true; let shouldDeleteChildSession = params.cleanup === "delete"; try { let targetRequesterSessionKey = params.requesterSessionKey; @@ -459,7 +742,7 @@ export async function runSubagentAnnounceFlow(params: { let outcome: SubagentRunOutcome | undefined = params.outcome; // Lifecycle "end" can arrive before auto-compaction retries finish. If the // subagent is still active, wait for the embedded run to fully settle. - if (childSessionId && isEmbeddedPiRunActive(childSessionId)) { + if (!expectsCompletionMessage && childSessionId && isEmbeddedPiRunActive(childSessionId)) { const settled = await waitForEmbeddedPiRunEnd(childSessionId, settleTimeoutMs); if (!settled && isEmbeddedPiRunActive(childSessionId)) { // The child run is still active (e.g., compaction retry still in progress). @@ -504,22 +787,26 @@ export async function runSubagentAnnounceFlow(params: { outcome = { status: "timeout" }; } } - reply = await readLatestAssistantReply({ sessionKey: params.childSessionKey }); + reply = await readLatestSubagentOutput(params.childSessionKey); } if (!reply) { - reply = await readLatestAssistantReply({ sessionKey: params.childSessionKey }); + reply = await readLatestSubagentOutput(params.childSessionKey); } if (!reply?.trim()) { - reply = await readLatestAssistantReplyWithRetry({ + reply = await readLatestSubagentOutputWithRetry({ sessionKey: params.childSessionKey, - initialReply: reply, maxWaitMs: params.timeoutMs, }); } - if (!reply?.trim() && childSessionId && isEmbeddedPiRunActive(childSessionId)) { + if ( + !expectsCompletionMessage && + !reply?.trim() && + childSessionId && + isEmbeddedPiRunActive(childSessionId) + ) { // Avoid announcing "(no output)" while the child run is still producing output. shouldDeleteChildSession = false; return false; @@ -536,46 +823,12 @@ export async function runSubagentAnnounceFlow(params: { } catch { // Best-effort only; fall back to direct announce behavior when unavailable. } - if (activeChildDescendantRuns > 0) { + if (!expectsCompletionMessage && activeChildDescendantRuns > 0) { // The finished run still has active descendant subagents. Defer announcing // this run until descendants settle so we avoid posting in-progress updates. shouldDeleteChildSession = false; return false; } - // If the subagent reply is still a "waiting for nested result" placeholder, - // hold this announce and wait for the follow-up turn that synthesizes child output. - let hasAnyChildDescendantRuns = false; - try { - const { listDescendantRunsForRequester } = await import("./subagent-registry.js"); - hasAnyChildDescendantRuns = listDescendantRunsForRequester(params.childSessionKey).length > 0; - } catch { - // Best-effort only; fall back to existing behavior when unavailable. - } - if (hasAnyChildDescendantRuns && isLikelyWaitingForDescendantResult(reply)) { - const followupReply = await waitForAssistantReplyChange({ - sessionKey: params.childSessionKey, - previousReply: reply, - maxWaitMs: settleTimeoutMs, - }); - if (!followupReply?.trim()) { - shouldDeleteChildSession = false; - return false; - } - reply = followupReply; - try { - const { countActiveDescendantRuns } = await import("./subagent-registry.js"); - activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey)); - } catch { - activeChildDescendantRuns = 0; - } - if ( - activeChildDescendantRuns > 0 || - (hasAnyChildDescendantRuns && isLikelyWaitingForDescendantResult(reply)) - ) { - shouldDeleteChildSession = false; - return false; - } - } // Build status label const statusLabel = @@ -590,12 +843,14 @@ export async function runSubagentAnnounceFlow(params: { // Build instructional message for main agent const announceType = params.announceType ?? "subagent task"; const taskLabel = params.label || params.task || "task"; + const subagentName = resolveAgentIdFromSessionKey(params.childSessionKey); const announceSessionId = childSessionId || "unknown"; const findings = reply || "(no output)"; + let completionMessage = ""; let triggerMessage = ""; let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey); - let requesterIsSubagent = requesterDepth >= 1; + let requesterIsSubagent = !expectsCompletionMessage && requesterDepth >= 1; // If the requester subagent has already finished, bubble the announce to its // requester (typically main) so descendant completion is not silently lost. // BUT: only fallback if the parent SESSION is deleted, not just if the current @@ -648,43 +903,31 @@ export async function runSubagentAnnounceFlow(params: { remainingActiveSubagentRuns, requesterIsSubagent, announceType, + expectsCompletionMessage, }); const statsLine = await buildCompactAnnounceStatsLine({ sessionKey: params.childSessionKey, startedAt: params.startedAt, endedAt: params.endedAt, }); - triggerMessage = [ + completionMessage = buildCompletionDeliveryMessage({ + findings, + subagentName, + }); + const internalSummaryMessage = [ `[System Message] [sessionId: ${announceSessionId}] A ${announceType} "${taskLabel}" just ${statusLabel}.`, "", "Result:", findings, "", statsLine, - "", - replyInstruction, ].join("\n"); + triggerMessage = [internalSummaryMessage, "", replyInstruction].join("\n"); const announceId = buildAnnounceIdFromChildRun({ childSessionKey: params.childSessionKey, childRunId: params.childRunId, }); - const queued = await maybeQueueSubagentAnnounce({ - requesterSessionKey: targetRequesterSessionKey, - announceId, - triggerMessage, - summaryLine: taskLabel, - requesterOrigin: targetRequesterOrigin, - }); - if (queued === "steered") { - didAnnounce = true; - return true; - } - if (queued === "queued") { - didAnnounce = true; - return true; - } - // Send to the requester session. For nested subagents this is an internal // follow-up injection (deliver=false) so the orchestrator receives it. let directOrigin = targetRequesterOrigin; @@ -696,26 +939,26 @@ export async function runSubagentAnnounceFlow(params: { // catches duplicates if this announce is also queued by the gateway- // level message queue while the main session is busy (#17122). const directIdempotencyKey = buildAnnounceIdempotencyKey(announceId); - await callGateway({ - method: "agent", - params: { - sessionKey: targetRequesterSessionKey, - message: triggerMessage, - deliver: !requesterIsSubagent, - channel: requesterIsSubagent ? undefined : directOrigin?.channel, - accountId: requesterIsSubagent ? undefined : directOrigin?.accountId, - to: requesterIsSubagent ? undefined : directOrigin?.to, - threadId: - !requesterIsSubagent && directOrigin?.threadId != null && directOrigin.threadId !== "" - ? String(directOrigin.threadId) - : undefined, - idempotencyKey: directIdempotencyKey, - }, - expectFinal: true, - timeoutMs: 15_000, + const delivery = await deliverSubagentAnnouncement({ + requesterSessionKey: targetRequesterSessionKey, + announceId, + triggerMessage, + completionMessage, + summaryLine: taskLabel, + requesterOrigin: targetRequesterOrigin, + completionDirectOrigin: targetRequesterOrigin, + directOrigin, + targetRequesterSessionKey, + requesterIsSubagent, + expectsCompletionMessage: expectsCompletionMessage, + directIdempotencyKey, }); - - didAnnounce = true; + didAnnounce = delivery.delivered; + if (!delivery.delivered && delivery.path === "direct" && delivery.error) { + defaultRuntime.error?.( + `Subagent completion direct announce failed for run ${params.childRunId}: ${delivery.error}`, + ); + } } catch (err) { defaultRuntime.error?.(`Subagent announce failed: ${String(err)}`); // Best-effort follow-ups; ignore failures to avoid breaking the caller response. diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 7064b25b93a..9c2545228e5 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -151,57 +151,4 @@ describe("announce loop guard (#18264)", () => { const stored = runs.find((run) => run.runId === entry.runId); expect(stored?.cleanupCompletedAt).toBeDefined(); }); - - test("does not consume retry budget while descendants are still active", async () => { - announceFn.mockClear(); - registry.resetSubagentRegistryForTests(); - - const now = Date.now(); - const parentEntry = { - runId: "test-parent-ended", - childSessionKey: "agent:main:subagent:parent-ended", - requesterSessionKey: "agent:main:main", - requesterDisplayKey: "agent:main:main", - task: "parent task", - cleanup: "keep" as const, - createdAt: now - 30_000, - startedAt: now - 20_000, - endedAt: now - 10_000, - expectsCompletionMessage: true, - cleanupHandled: false, - }; - const activeDescendant = { - runId: "test-desc-active", - childSessionKey: "agent:main:subagent:parent-ended:subagent:leaf", - requesterSessionKey: "agent:main:subagent:parent-ended", - requesterDisplayKey: "agent:main:subagent:parent-ended", - task: "leaf task", - cleanup: "keep" as const, - createdAt: now - 5_000, - startedAt: now - 5_000, - expectsCompletionMessage: true, - cleanupHandled: false, - }; - - loadSubagentRegistryFromDisk.mockReturnValue( - new Map([ - [parentEntry.runId, parentEntry], - [activeDescendant.runId, activeDescendant], - ]), - ); - - registry.initSubagentRegistry(); - await Promise.resolve(); - await Promise.resolve(); - - expect(announceFn).toHaveBeenCalledWith( - expect.objectContaining({ childRunId: parentEntry.runId }), - ); - const parent = registry - .listSubagentRunsForRequester("agent:main:main") - .find((run) => run.runId === parentEntry.runId); - expect(parent?.announceRetryCount).toBeUndefined(); - expect(parent?.cleanupCompletedAt).toBeUndefined(); - expect(parent?.cleanupHandled).toBe(false); - }); }); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 479a176fb99..0e14a2aaa60 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -102,6 +102,7 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor requesterOrigin, requesterDisplayKey: entry.requesterDisplayKey, task: entry.task, + expectsCompletionMessage: entry.expectsCompletionMessage, timeoutMs: SUBAGENT_ANNOUNCE_TIMEOUT_MS, cleanup: entry.cleanup, waitForCompletion: false, @@ -323,34 +324,12 @@ function finalizeSubagentCleanup(runId: string, cleanup: "delete" | "keep", didA } if (!didAnnounce) { const now = Date.now(); - const endedAgo = typeof entry.endedAt === "number" ? now - entry.endedAt : 0; - // Normal defer: the run ended, but descendant runs are still active. - // Don't consume retry budget in this state or we can give up before - // descendants finish and before the parent synthesizes the final reply. - const activeDescendantRuns = Math.max(0, countActiveDescendantRuns(entry.childSessionKey)); - if (entry.expectsCompletionMessage === true && activeDescendantRuns > 0) { - if (endedAgo > ANNOUNCE_EXPIRY_MS) { - logAnnounceGiveUp(entry, "expiry"); - entry.cleanupCompletedAt = now; - persistSubagentRuns(); - retryDeferredCompletedAnnounces(runId); - return; - } - entry.lastAnnounceRetryAt = now; - entry.cleanupHandled = false; - resumedRuns.delete(runId); - persistSubagentRuns(); - setTimeout(() => { - resumeSubagentRun(runId); - }, MIN_ANNOUNCE_RETRY_DELAY_MS).unref?.(); - return; - } - const retryCount = (entry.announceRetryCount ?? 0) + 1; entry.announceRetryCount = retryCount; entry.lastAnnounceRetryAt = now; // Check if the announce has exceeded retry limits or expired (#18264). + const endedAgo = typeof entry.endedAt === "number" ? now - entry.endedAt : 0; if (retryCount >= MAX_ANNOUNCE_RETRY_COUNT || endedAgo > ANNOUNCE_EXPIRY_MS) { // Give up: mark as completed to break the infinite retry loop. logAnnounceGiveUp(entry, retryCount >= MAX_ANNOUNCE_RETRY_COUNT ? "retry-limit" : "expiry"); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index e65e53a7953..f14e9e50efc 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -1,6 +1,5 @@ import crypto from "node:crypto"; import { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js"; -import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; import { loadConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; @@ -108,8 +107,7 @@ export async function spawnSubagentDirect( }); const callerDepth = getSubagentDepthFromSessionStore(requesterInternalKey, { cfg }); - const maxSpawnDepth = - cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + const maxSpawnDepth = cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; if (callerDepth >= maxSpawnDepth) { return { status: "forbidden", diff --git a/src/agents/system-prompt.e2e.test.ts b/src/agents/system-prompt.e2e.test.ts index 630405313ad..a03ac283365 100644 --- a/src/agents/system-prompt.e2e.test.ts +++ b/src/agents/system-prompt.e2e.test.ts @@ -575,15 +575,14 @@ describe("buildSubagentSystemPrompt", () => { expect(prompt).toContain("instead of full-file `cat`"); }); - it("defaults to depth 1 and maxSpawnDepth 2 when not provided", () => { + it("defaults to depth 1 and maxSpawnDepth 1 when not provided", () => { const prompt = buildSubagentSystemPrompt({ childSessionKey: "agent:main:subagent:abc", task: "basic task", }); - // Default maxSpawnDepth is 2, so depth-1 subagents are orchestrators. - expect(prompt).toContain("## Sub-Agent Spawning"); - expect(prompt).toContain("You CAN spawn your own sub-agents"); + // Should not include spawning guidance (default maxSpawnDepth is 1, depth 1 is leaf) + expect(prompt).not.toContain("## Sub-Agent Spawning"); expect(prompt).toContain("spawned by the main agent"); }); }); diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index 9b0b75ce857..bf88212d6a0 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -7,7 +7,6 @@ import { sortSubagentRuns, type SubagentTargetResolution, } from "../../auto-reply/reply/subagents-utils.js"; -import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js"; import { loadConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js"; @@ -200,8 +199,7 @@ function resolveRequesterKey(params: { // Check if this sub-agent can spawn children (orchestrator). // If so, it should see its own children, not its parent's children. const callerDepth = getSubagentDepthFromSessionStore(callerSessionKey, { cfg: params.cfg }); - const maxSpawnDepth = - params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; + const maxSpawnDepth = params.cfg.agents?.defaults?.subagents?.maxSpawnDepth ?? 1; if (callerDepth < maxSpawnDepth) { // Orchestrator sub-agent: use its own session key as requester // so it sees children it spawned. diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 8ed5c248ca1..07dc5371830 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -136,6 +136,7 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma command: params.command, sessionEntry: params.sessionEntry, sessionKey: params.sessionKey, + parentSessionKey: params.ctx.ParentSessionKey, sessionScope: params.sessionScope, provider: params.provider, model: params.model, diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index fee7efdee72..5cbc406ce92 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -32,6 +32,7 @@ export async function buildStatusReply(params: { command: CommandContext; sessionEntry?: SessionEntry; sessionKey: string; + parentSessionKey?: string; sessionScope?: SessionScope; storePath?: string; provider: string; @@ -51,6 +52,7 @@ export async function buildStatusReply(params: { command, sessionEntry, sessionKey, + parentSessionKey, sessionScope, storePath, provider, @@ -173,6 +175,7 @@ export async function buildStatusReply(params: { agentId: statusAgentId, sessionEntry, sessionKey, + parentSessionKey, sessionScope, sessionStorePath: storePath, groupActivation, diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 59d1308cca4..fe42a2ca9e0 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -168,6 +168,7 @@ export async function applyInlineDirectiveOverrides(params: { command, sessionEntry, sessionKey, + parentSessionKey: ctx.ParentSessionKey, sessionScope, provider, model, diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 4dc6e5e7eec..9a9a18340de 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -277,6 +277,7 @@ export async function handleInlineActions(params: { command, sessionEntry, sessionKey, + parentSessionKey: ctx.ParentSessionKey, sessionScope, provider, model, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 193899919f0..bca4cb3ce8f 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -7,6 +7,7 @@ import { import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; +import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import { type OpenClawConfig, loadConfig } from "../../config/config.js"; import { applyLinkUnderstanding } from "../../link-understanding/apply.js"; import { applyMediaUnderstanding } from "../../media-understanding/apply.js"; @@ -179,6 +180,36 @@ export async function getReplyFromConfig( aliasIndex, }); + const channelModelOverride = resolveChannelModelOverride({ + cfg, + channel: + groupResolution?.channel ?? + sessionEntry.channel ?? + sessionEntry.origin?.provider ?? + (typeof finalized.OriginatingChannel === "string" + ? finalized.OriginatingChannel + : undefined) ?? + finalized.Provider, + groupId: groupResolution?.id ?? sessionEntry.groupId, + groupChannel: sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel, + groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject, + parentSessionKey: sessionCtx.ParentSessionKey, + }); + const hasSessionModelOverride = Boolean( + sessionEntry.modelOverride?.trim() || sessionEntry.providerOverride?.trim(), + ); + if (!hasResolvedHeartbeatModelOverride && !hasSessionModelOverride && channelModelOverride) { + const resolved = resolveModelRefFromString({ + raw: channelModelOverride.model, + defaultProvider, + aliasIndex, + }); + if (resolved) { + provider = resolved.ref.provider; + model = resolved.ref.model; + } + } + const directiveResult = await resolveReplyDirectives({ ctx: finalized, cfg, diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index ed92feb2d28..915c4800e68 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -71,36 +71,6 @@ describe("buildInboundMetaSystemPrompt", () => { const payload = parseInboundMetaPayload(prompt); expect(payload["sender_id"]).toBeUndefined(); }); - - it("includes discord channel topics only for new sessions", () => { - const prompt = buildInboundMetaSystemPrompt({ - OriginatingTo: "discord:channel:123", - OriginatingChannel: "discord", - Provider: "discord", - Surface: "discord", - ChatType: "group", - ChannelTopic: " Shipping updates ", - IsNewSession: "true", - } as TemplateContext); - - const payload = parseInboundMetaPayload(prompt); - expect(payload["channel_topic"]).toBe("Shipping updates"); - }); - - it("omits discord channel topics for existing sessions", () => { - const prompt = buildInboundMetaSystemPrompt({ - OriginatingTo: "discord:channel:123", - OriginatingChannel: "discord", - Provider: "discord", - Surface: "discord", - ChatType: "group", - ChannelTopic: "Shipping updates", - IsNewSession: "false", - } as TemplateContext); - - const payload = parseInboundMetaPayload(prompt); - expect(payload["channel_topic"]).toBeUndefined(); - }); }); describe("buildInboundUserContextPrefix", () => { diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index ca817fda57c..bbcbc5dabac 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -13,15 +13,8 @@ function safeTrim(value: unknown): string | undefined { export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string { const chatType = normalizeChatType(ctx.ChatType); const isDirect = !chatType || chatType === "direct"; - const isNewSession = ctx.IsNewSession === "true"; - const originatingChannel = safeTrim(ctx.OriginatingChannel); - const surface = safeTrim(ctx.Surface); - const provider = safeTrim(ctx.Provider); - const isDiscord = - provider === "discord" || surface === "discord" || originatingChannel === "discord"; - // Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.) - // unless explicitly opted into for new-session context (e.g. Discord channel topics). + // Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.). // Those belong in the user-role "untrusted context" blocks. // Per-message identifiers (message_id, reply_to_id, sender_id) are also excluded here: they change // on every turn and would bust prefix-based prompt caches on local model providers. They are @@ -30,27 +23,25 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string { // Resolve channel identity: prefer explicit channel, then surface, then provider. // For webchat/Hub Chat sessions (when Surface is 'webchat' or undefined with no real channel), // omit the channel field entirely rather than falling back to an unrelated provider. - let channelValue = originatingChannel ?? surface; + let channelValue = safeTrim(ctx.OriginatingChannel) ?? safeTrim(ctx.Surface); if (!channelValue) { // Only fall back to Provider if it represents a real messaging channel. // For webchat/internal sessions, ctx.Provider may be unrelated (e.g., the user's configured // default channel), so skip it to avoid incorrect runtime labels like "channel=whatsapp". + const provider = safeTrim(ctx.Provider); // Check if provider is "webchat" or if we're in an internal/webchat context - if (provider !== "webchat" && surface !== "webchat") { + if (provider !== "webchat" && ctx.Surface !== "webchat") { channelValue = provider; } // Otherwise leave channelValue undefined (no channel label) } - const channelTopic = isNewSession && isDiscord ? safeTrim(ctx.ChannelTopic) : undefined; - const payload = { schema: "openclaw.inbound_meta.v1", chat_id: safeTrim(ctx.OriginatingTo), channel: channelValue, - channel_topic: channelTopic, - provider, - surface, + provider: safeTrim(ctx.Provider), + surface: safeTrim(ctx.Surface), chat_type: chatType ?? (isDirect ? "direct" : undefined), flags: { is_group_chat: !isDirect ? true : undefined, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index f66c39f312a..a8d88a18171 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -90,6 +90,36 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("Queue: collect"); }); + it("notes channel model overrides in status output", () => { + const text = buildStatusMessage({ + config: { + channels: { + modelByChannel: { + discord: { + "123": "openai/gpt-4.1", + }, + }, + }, + } as unknown as OpenClawConfig, + agent: { + model: "openai/gpt-4.1", + }, + sessionEntry: { + sessionId: "abc", + updatedAt: 0, + channel: "discord", + groupId: "123", + }, + sessionKey: "agent:main:discord:channel:123", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + }); + const normalized = normalizeTestText(text); + + expect(normalized).toContain("Model: openai/gpt-4.1"); + expect(normalized).toContain("channel override"); + }); + it("uses per-agent sandbox config when config and session key are provided", () => { const text = buildStatusMessage({ config: { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index d324a8951c5..d9478475527 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -2,10 +2,15 @@ import fs from "node:fs"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveModelAuthMode } from "../agents/model-auth.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { + buildModelAliasIndex, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js"; import type { SkillCommandSpec } from "../agents/skills.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js"; +import { resolveChannelModelOverride } from "../channels/model-overrides.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey, @@ -66,6 +71,7 @@ type StatusArgs = { agentId?: string; sessionEntry?: SessionEntry; sessionKey?: string; + parentSessionKey?: string; sessionScope?: SessionScope; sessionStorePath?: string; groupActivation?: "mention" | "always"; @@ -531,7 +537,46 @@ export function buildStatusMessage(args: StatusArgs): string { state: entry, }); const selectedAuthLabel = selectedAuthLabelValue ? ` · 🔑 ${selectedAuthLabelValue}` : ""; - const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}`; + const channelModelNote = (() => { + if (!args.config || !entry) { + return undefined; + } + if (entry.modelOverride?.trim() || entry.providerOverride?.trim()) { + return undefined; + } + const channelOverride = resolveChannelModelOverride({ + cfg: args.config, + channel: entry.channel ?? entry.origin?.provider, + groupId: entry.groupId, + groupChannel: entry.groupChannel, + groupSubject: entry.subject, + parentSessionKey: args.parentSessionKey, + }); + if (!channelOverride) { + return undefined; + } + const aliasIndex = buildModelAliasIndex({ + cfg: args.config, + defaultProvider: DEFAULT_PROVIDER, + }); + const resolvedOverride = resolveModelRefFromString({ + raw: channelOverride.model, + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + }); + if (!resolvedOverride) { + return undefined; + } + if ( + resolvedOverride.ref.provider !== selectedProvider || + resolvedOverride.ref.model !== selectedModel + ) { + return undefined; + } + return "channel override"; + })(); + const modelNote = channelModelNote ? ` · ${channelModelNote}` : ""; + const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}${modelNote}`; const showFallbackAuth = activeAuthLabelValue && activeAuthLabelValue !== selectedAuthLabelValue; const fallbackLine = fallbackState.active ? `↪️ Fallback: ${activeModelLabel}${ diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index c90adbde2b3..4bc9b517549 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -98,8 +98,6 @@ export type MsgContext = { GroupSubject?: string; /** Human label for channel-like group conversations (e.g. #general, #support). */ GroupChannel?: string; - /** Channel topic/description (trusted metadata for new session context). */ - ChannelTopic?: string; GroupSpace?: string; GroupMembers?: string; GroupSystemPrompt?: string; diff --git a/src/channels/model-overrides.test.ts b/src/channels/model-overrides.test.ts new file mode 100644 index 00000000000..cffdc45c18c --- /dev/null +++ b/src/channels/model-overrides.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveChannelModelOverride } from "./model-overrides.js"; + +describe("resolveChannelModelOverride", () => { + it("matches parent group id when topic suffix is present", () => { + const cfg = { + channels: { + modelByChannel: { + telegram: { + "-100123": "openai/gpt-4.1", + }, + }, + }, + } as unknown as OpenClawConfig; + const resolved = resolveChannelModelOverride({ + cfg, + channel: "telegram", + groupId: "-100123:topic:99", + }); + + expect(resolved?.model).toBe("openai/gpt-4.1"); + expect(resolved?.matchKey).toBe("-100123"); + }); + + it("prefers topic-specific match over parent group id", () => { + const cfg = { + channels: { + modelByChannel: { + telegram: { + "-100123": "openai/gpt-4.1", + "-100123:topic:99": "anthropic/claude-sonnet-4-6", + }, + }, + }, + } as unknown as OpenClawConfig; + const resolved = resolveChannelModelOverride({ + cfg, + channel: "telegram", + groupId: "-100123:topic:99", + }); + + expect(resolved?.model).toBe("anthropic/claude-sonnet-4-6"); + expect(resolved?.matchKey).toBe("-100123:topic:99"); + }); + + it("falls back to parent session key when thread id does not match", () => { + const cfg = { + channels: { + modelByChannel: { + discord: { + "123": "openai/gpt-4.1", + }, + }, + }, + } as unknown as OpenClawConfig; + const resolved = resolveChannelModelOverride({ + cfg, + channel: "discord", + groupId: "999", + parentSessionKey: "agent:main:discord:channel:123:thread:456", + }); + + expect(resolved?.model).toBe("openai/gpt-4.1"); + expect(resolved?.matchKey).toBe("123"); + }); +}); diff --git a/src/channels/model-overrides.ts b/src/channels/model-overrides.ts new file mode 100644 index 00000000000..be57935f992 --- /dev/null +++ b/src/channels/model-overrides.ts @@ -0,0 +1,142 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { normalizeMessageChannel } from "../utils/message-channel.js"; +import { + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveChannelEntryMatchWithFallback, + type ChannelMatchSource, +} from "./channel-config.js"; + +const THREAD_SUFFIX_REGEX = /:(?:thread|topic):[^:]+$/i; + +export type ChannelModelOverride = { + channel: string; + model: string; + matchKey?: string; + matchSource?: ChannelMatchSource; +}; + +type ChannelModelByChannelConfig = Record>; + +type ChannelModelOverrideParams = { + cfg: OpenClawConfig; + channel?: string | null; + groupId?: string | null; + groupChannel?: string | null; + groupSubject?: string | null; + parentSessionKey?: string | null; +}; + +function resolveProviderEntry( + modelByChannel: ChannelModelByChannelConfig | undefined, + channel: string, +): Record | undefined { + const normalized = normalizeMessageChannel(channel) ?? channel.trim().toLowerCase(); + return ( + modelByChannel?.[normalized] ?? + modelByChannel?.[ + Object.keys(modelByChannel ?? {}).find((key) => { + const normalizedKey = normalizeMessageChannel(key) ?? key.trim().toLowerCase(); + return normalizedKey === normalized; + }) ?? "" + ] + ); +} + +function resolveParentGroupId(groupId: string | undefined): string | undefined { + const raw = groupId?.trim(); + if (!raw || !THREAD_SUFFIX_REGEX.test(raw)) { + return undefined; + } + const parent = raw.replace(THREAD_SUFFIX_REGEX, "").trim(); + return parent && parent !== raw ? parent : undefined; +} + +function resolveGroupIdFromSessionKey(sessionKey?: string | null): string | undefined { + const raw = sessionKey?.trim(); + if (!raw) { + return undefined; + } + const parsed = parseAgentSessionKey(raw); + const candidate = parsed?.rest ?? raw; + const match = candidate.match(/(?:^|:)(?:group|channel):([^:]+)(?::|$)/i); + const id = match?.[1]?.trim(); + return id || undefined; +} + +function buildChannelCandidates( + params: Pick< + ChannelModelOverrideParams, + "groupId" | "groupChannel" | "groupSubject" | "parentSessionKey" + >, +) { + const groupId = params.groupId?.trim(); + const parentGroupId = resolveParentGroupId(groupId); + const parentGroupIdFromSession = resolveGroupIdFromSessionKey(params.parentSessionKey); + const parentGroupIdResolved = + resolveParentGroupId(parentGroupIdFromSession) ?? parentGroupIdFromSession; + const groupChannel = params.groupChannel?.trim(); + const groupSubject = params.groupSubject?.trim(); + const channelBare = groupChannel ? groupChannel.replace(/^#/, "") : undefined; + const subjectBare = groupSubject ? groupSubject.replace(/^#/, "") : undefined; + const channelSlug = channelBare ? normalizeChannelSlug(channelBare) : undefined; + const subjectSlug = subjectBare ? normalizeChannelSlug(subjectBare) : undefined; + + return buildChannelKeyCandidates( + groupId, + parentGroupId, + parentGroupIdResolved, + groupChannel, + channelBare, + channelSlug, + groupSubject, + subjectBare, + subjectSlug, + ); +} + +export function resolveChannelModelOverride( + params: ChannelModelOverrideParams, +): ChannelModelOverride | null { + const channel = params.channel?.trim(); + if (!channel) { + return null; + } + const modelByChannel = params.cfg.channels?.modelByChannel as + | ChannelModelByChannelConfig + | undefined; + if (!modelByChannel) { + return null; + } + const providerEntries = resolveProviderEntry(modelByChannel, channel); + if (!providerEntries) { + return null; + } + + const candidates = buildChannelCandidates(params); + if (candidates.length === 0) { + return null; + } + const match = resolveChannelEntryMatchWithFallback({ + entries: providerEntries, + keys: candidates, + wildcardKey: "*", + normalizeKey: (value) => value.trim().toLowerCase(), + }); + const raw = match.entry ?? match.wildcardEntry; + if (typeof raw !== "string") { + return null; + } + const model = raw.trim(); + if (!model) { + return null; + } + + return { + channel: normalizeMessageChannel(channel) ?? channel.trim().toLowerCase(), + model, + matchKey: match.matchKey, + matchSource: match.matchSource, + }; +} diff --git a/src/channels/status-reactions.ts b/src/channels/status-reactions.ts index b31f19d74e6..266f4199e31 100644 --- a/src/channels/status-reactions.ts +++ b/src/channels/status-reactions.ts @@ -50,14 +50,14 @@ export type StatusReactionController = { export const DEFAULT_EMOJIS: Required = { queued: "👀", - thinking: "🧠", - tool: "🛠️", - coding: "💻", - web: "🌐", - done: "✅", - error: "❌", - stallSoft: "⏳", - stallHard: "⚠️", + thinking: "🤔", + tool: "🔥", + coding: "👨‍💻", + web: "⚡", + done: "👍", + error: "😱", + stallSoft: "🥱", + stallHard: "😨", }; export const DEFAULT_TIMING: Required = { diff --git a/src/config/agent-limits.ts b/src/config/agent-limits.ts index aa611992077..53df535ebb1 100644 --- a/src/config/agent-limits.ts +++ b/src/config/agent-limits.ts @@ -2,7 +2,6 @@ import type { OpenClawConfig } from "./types.js"; export const DEFAULT_AGENT_MAX_CONCURRENT = 4; export const DEFAULT_SUBAGENT_MAX_CONCURRENT = 8; -export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 2; export function resolveAgentMaxConcurrent(cfg?: OpenClawConfig): number { const raw = cfg?.agents?.defaults?.maxConcurrent; diff --git a/src/config/config.agent-concurrency-defaults.test.ts b/src/config/config.agent-concurrency-defaults.test.ts index acffd872a92..d2fc3853914 100644 --- a/src/config/config.agent-concurrency-defaults.test.ts +++ b/src/config/config.agent-concurrency-defaults.test.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { DEFAULT_AGENT_MAX_CONCURRENT, - DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, DEFAULT_SUBAGENT_MAX_CONCURRENT, resolveAgentMaxConcurrent, resolveSubagentMaxConcurrent, @@ -61,7 +60,6 @@ describe("agent concurrency defaults", () => { expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT); expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); - expect(cfg.agents?.defaults?.subagents?.maxSpawnDepth).toBe(DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH); }); }); }); diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index edefe2c8719..6c3d15f9bed 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -1,11 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { - DEFAULT_AGENT_MAX_CONCURRENT, - DEFAULT_SUBAGENT_MAX_CONCURRENT, - DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, -} from "./agent-limits.js"; +import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; import { loadConfig } from "./config.js"; import { withTempHome } from "./home-env.test-harness.js"; @@ -57,7 +53,6 @@ describe("config identity defaults", () => { expect(cfg.agents?.list).toBeUndefined(); expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT); expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT); - expect(cfg.agents?.defaults?.subagents?.maxSpawnDepth).toBe(DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH); expect(cfg.session).toBeUndefined(); }); }); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index bebdeaf8cd3..09605388ac3 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,10 +1,6 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { parseModelRef } from "../agents/model-selection.js"; -import { - DEFAULT_AGENT_MAX_CONCURRENT, - DEFAULT_SUBAGENT_MAX_CONCURRENT, - DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, -} from "./agent-limits.js"; +import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js"; import { resolveTalkApiKey } from "./talk.js"; import type { OpenClawConfig } from "./types.js"; import type { ModelDefinitionConfig } from "./types.models.js"; @@ -303,10 +299,7 @@ export function applyAgentDefaults(cfg: OpenClawConfig): OpenClawConfig { const hasSubMax = typeof defaults?.subagents?.maxConcurrent === "number" && Number.isFinite(defaults.subagents.maxConcurrent); - const hasMaxSpawnDepth = - typeof defaults?.subagents?.maxSpawnDepth === "number" && - Number.isFinite(defaults.subagents.maxSpawnDepth); - if (hasMax && hasSubMax && hasMaxSpawnDepth) { + if (hasMax && hasSubMax) { return cfg; } @@ -322,10 +315,6 @@ export function applyAgentDefaults(cfg: OpenClawConfig): OpenClawConfig { nextSubagents.maxConcurrent = DEFAULT_SUBAGENT_MAX_CONCURRENT; mutated = true; } - if (!hasMaxSpawnDepth) { - nextSubagents.maxSpawnDepth = DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; - mutated = true; - } if (!mutated) { return cfg; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index b47390b8eae..a2ea1122edd 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -352,6 +352,8 @@ export const FIELD_HELP: Record = { "Allow iMessage to write config in response to channel events/commands (default: true).", "channels.msteams.configWrites": "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", + "channels.modelByChannel": + "Map provider -> channel id -> model override (values are provider/model or aliases).", ...IRC_FIELD_HELP, "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', "channels.discord.commands.nativeSkills": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 640656d48a8..e61f7e557ab 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -257,6 +257,7 @@ export const FIELD_LABELS: Record = { "channels.imessage": "iMessage", "channels.bluebubbles": "BlueBubbles", "channels.msteams": "MS Teams", + "channels.modelByChannel": "Channel Model Overrides", ...IRC_FIELD_LABELS, "channels.telegram.botToken": "Telegram Bot Token", "channels.telegram.dmPolicy": "Telegram DM Policy", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 75e715c7316..aa3fbe41958 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -241,7 +241,7 @@ export type AgentDefaultsConfig = { subagents?: { /** Max concurrent sub-agent runs (global lane: "subagent"). Default: 1. */ maxConcurrent?: number; - /** Maximum depth allowed for sessions_spawn chains. Default behavior: 2 (allows nested spawns). */ + /** Maximum depth allowed for sessions_spawn chains. Default behavior: 1 (no nested spawns). */ maxSpawnDepth?: number; /** Maximum active children a single requester session may spawn. Default behavior: 5. */ maxChildrenPerAgent?: number; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index a238756577e..8f679f54107 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -24,6 +24,8 @@ export type ChannelDefaultsConfig = { heartbeat?: ChannelHeartbeatVisibilityConfig; }; +export type ChannelModelByChannelConfig = Record>; + /** * Base type for extension channel config sections. * Extensions can use this as a starting point for their channel config. @@ -41,6 +43,8 @@ export type ExtensionChannelConfig = { export type ChannelsConfig = { defaults?: ChannelDefaultsConfig; + /** Map provider -> channel id -> model override. */ + modelByChannel?: ChannelModelByChannelConfig; whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 201706ac9f0..4ec06f66b38 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -150,7 +150,7 @@ export const AgentDefaultsSchema = z .max(5) .optional() .describe( - "Maximum nesting depth for sub-agent spawning. Default is 2 (sub-agents can spawn sub-sub-agents).", + "Maximum nesting depth for sub-agent spawning. 1 = no nesting (default), 2 = sub-agents can spawn sub-sub-agents.", ), maxChildrenPerAgent: z .number() diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 8bc961b5d7e..07d2a55761a 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -18,6 +18,10 @@ export * from "./zod-schema.providers-core.js"; export * from "./zod-schema.providers-whatsapp.js"; export { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; +const ChannelModelByChannelSchema = z + .record(z.string(), z.record(z.string(), z.string())) + .optional(); + export const ChannelsSchema = z .object({ defaults: z @@ -27,6 +31,7 @@ export const ChannelsSchema = z }) .strict() .optional(), + modelByChannel: ChannelModelByChannelSchema, whatsapp: WhatsAppConfigSchema.optional(), telegram: TelegramConfigSchema.optional(), discord: DiscordConfigSchema.optional(), diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index 75dec708041..ead8313ee2a 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -4,9 +4,11 @@ import type { OpenClawConfig } from "../../config/config.js"; vi.mock("../../config/sessions.js", () => ({ loadSessionStore: vi.fn(), resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"), + evaluateSessionFreshness: vi.fn().mockReturnValue({ fresh: true }), + resolveSessionResetPolicy: vi.fn().mockReturnValue({ mode: "idle", idleMinutes: 60 }), })); -import { loadSessionStore } from "../../config/sessions.js"; +import { loadSessionStore, evaluateSessionFreshness } from "../../config/sessions.js"; import { resolveCronSession } from "./session.js"; const NOW_MS = 1_737_600_000_000; @@ -15,18 +17,25 @@ type SessionStore = ReturnType; type SessionStoreEntry = SessionStore[string]; type MockSessionStoreEntry = Partial; -function resolveWithStoredEntry(params?: { sessionKey?: string; entry?: MockSessionStoreEntry }) { +function resolveWithStoredEntry(params?: { + sessionKey?: string; + entry?: MockSessionStoreEntry; + forceNew?: boolean; + fresh?: boolean; +}) { const sessionKey = params?.sessionKey ?? "webhook:stable-key"; const store: SessionStore = params?.entry ? ({ [sessionKey]: params.entry as SessionStoreEntry } as SessionStore) : {}; vi.mocked(loadSessionStore).mockReturnValue(store); + vi.mocked(evaluateSessionFreshness).mockReturnValue({ fresh: params?.fresh ?? true }); return resolveCronSession({ cfg: {} as OpenClawConfig, sessionKey, agentId: "main", nowMs: NOW_MS, + forceNew: params?.forceNew, }); } @@ -76,51 +85,76 @@ describe("resolveCronSession", () => { expect(result.isNewSession).toBe(true); }); - it("always creates a new sessionId for cron/webhook runs", () => { - const result = resolveWithStoredEntry({ - entry: { - sessionId: "existing-session-id-123", - updatedAt: NOW_MS - 1000, - systemSent: true, - }, + // New tests for session reuse behavior (#18027) + describe("session reuse for webhooks/cron", () => { + it("reuses existing sessionId when session is fresh", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "existing-session-id-123", + updatedAt: NOW_MS - 1000, + systemSent: true, + }, + fresh: true, + }); + + expect(result.sessionEntry.sessionId).toBe("existing-session-id-123"); + expect(result.isNewSession).toBe(false); + expect(result.systemSent).toBe(true); }); - expect(result.sessionEntry.sessionId).not.toBe("existing-session-id-123"); - expect(result.isNewSession).toBe(true); - expect(result.systemSent).toBe(false); - }); + it("creates new sessionId when session is stale", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "old-session-id", + updatedAt: NOW_MS - 86_400_000, // 1 day ago + systemSent: true, + modelOverride: "gpt-4.1-mini", + providerOverride: "openai", + sendPolicy: "allow", + }, + fresh: false, + }); - it("preserves overrides while rolling a new sessionId", () => { - const result = resolveWithStoredEntry({ - entry: { - sessionId: "old-session-id", - updatedAt: NOW_MS - 86_400_000, - systemSent: true, - modelOverride: "gpt-4.1-mini", - providerOverride: "openai", - sendPolicy: "allow", - }, + expect(result.sessionEntry.sessionId).not.toBe("old-session-id"); + expect(result.isNewSession).toBe(true); + expect(result.systemSent).toBe(false); + expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini"); + expect(result.sessionEntry.providerOverride).toBe("openai"); + expect(result.sessionEntry.sendPolicy).toBe("allow"); }); - expect(result.sessionEntry.sessionId).not.toBe("old-session-id"); - expect(result.isNewSession).toBe(true); - expect(result.systemSent).toBe(false); - expect(result.sessionEntry.modelOverride).toBe("gpt-4.1-mini"); - expect(result.sessionEntry.providerOverride).toBe("openai"); - expect(result.sessionEntry.sendPolicy).toBe("allow"); - }); + it("creates new sessionId when forceNew is true", () => { + const result = resolveWithStoredEntry({ + entry: { + sessionId: "existing-session-id-456", + updatedAt: NOW_MS - 1000, + systemSent: true, + modelOverride: "sonnet-4", + providerOverride: "anthropic", + }, + fresh: true, + forceNew: true, + }); - it("creates new sessionId when entry exists but has no sessionId", () => { - const result = resolveWithStoredEntry({ - entry: { - updatedAt: NOW_MS - 1000, - modelOverride: "some-model", - }, + expect(result.sessionEntry.sessionId).not.toBe("existing-session-id-456"); + expect(result.isNewSession).toBe(true); + expect(result.systemSent).toBe(false); + expect(result.sessionEntry.modelOverride).toBe("sonnet-4"); + expect(result.sessionEntry.providerOverride).toBe("anthropic"); }); - expect(result.sessionEntry.sessionId).toBeDefined(); - expect(result.isNewSession).toBe(true); - // Should still preserve other fields from entry - expect(result.sessionEntry.modelOverride).toBe("some-model"); + it("creates new sessionId when entry exists but has no sessionId", () => { + const result = resolveWithStoredEntry({ + entry: { + updatedAt: NOW_MS - 1000, + modelOverride: "some-model", + }, + }); + + expect(result.sessionEntry.sessionId).toBeDefined(); + expect(result.isNewSession).toBe(true); + // Should still preserve other fields from entry + expect(result.sessionEntry.modelOverride).toBe("some-model"); + }); }); }); diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index f9fec3ce822..0f23c836c6d 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -1,12 +1,19 @@ import crypto from "node:crypto"; import type { OpenClawConfig } from "../../config/config.js"; -import { loadSessionStore, resolveStorePath, type SessionEntry } from "../../config/sessions.js"; +import { + evaluateSessionFreshness, + loadSessionStore, + resolveSessionResetPolicy, + resolveStorePath, + type SessionEntry, +} from "../../config/sessions.js"; export function resolveCronSession(params: { cfg: OpenClawConfig; sessionKey: string; nowMs: number; agentId: string; + forceNew?: boolean; }) { const sessionCfg = params.cfg.session; const storePath = resolveStorePath(sessionCfg?.store, { @@ -14,8 +21,42 @@ export function resolveCronSession(params: { }); const store = loadSessionStore(storePath); const entry = store[params.sessionKey]; - const sessionId = crypto.randomUUID(); - const systemSent = false; + + // Check if we can reuse an existing session + let sessionId: string; + let isNewSession: boolean; + let systemSent: boolean; + + if (!params.forceNew && entry?.sessionId) { + // Evaluate freshness using the configured reset policy + // Cron/webhook sessions use "direct" reset type (1:1 conversation style) + const resetPolicy = resolveSessionResetPolicy({ + sessionCfg, + resetType: "direct", + }); + const freshness = evaluateSessionFreshness({ + updatedAt: entry.updatedAt, + now: params.nowMs, + policy: resetPolicy, + }); + + if (freshness.fresh) { + // Reuse existing session + sessionId = entry.sessionId; + isNewSession = false; + systemSent = entry.systemSent ?? false; + } else { + // Session expired, create new + sessionId = crypto.randomUUID(); + isNewSession = true; + systemSent = false; + } + } else { + // No existing session or forced new + sessionId = crypto.randomUUID(); + isNewSession = true; + systemSent = false; + } const sessionEntry: SessionEntry = { // Preserve existing per-session overrides even when rolling to a new sessionId. @@ -25,5 +66,5 @@ export function resolveCronSession(params: { updatedAt: params.nowMs, systemSent, }; - return { storePath, store, sessionEntry, systemSent, isNewSession: true }; + return { storePath, store, sessionEntry, systemSent, isNewSession }; } diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 244fba304f8..20cc76aa31e 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -4,36 +4,23 @@ import { MessageCreateListener, MessageReactionAddListener, MessageReactionRemoveListener, - MessageUpdateListener, PresenceUpdateListener, type User, } from "@buape/carbon"; -import type { DmPolicy, GroupPolicy } from "../../config/types.base.js"; import { danger } from "../../globals.js"; import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { - allowListMatches, - isDiscordGroupAllowedByPolicy, - normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, - resolveDiscordMemberAccessState, - resolveGroupDmAllow, shouldEmitDiscordReactionNotification, } from "./allow-list.js"; -import { - formatDiscordReactionEmoji, - formatDiscordUserTag, - resolveDiscordSystemLocation, -} from "./format.js"; -import { resolveDiscordChannelInfo, resolveDiscordMessageChannelId } from "./message-utils.js"; +import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; +import { resolveDiscordChannelInfo } from "./message-utils.js"; import { setPresence } from "./presence-cache.js"; -import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js"; type LoadedConfig = ReturnType; type RuntimeEnv = import("../../runtime.js").RuntimeEnv; @@ -43,8 +30,6 @@ export type DiscordMessageEvent = Parameters[0] export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client) => Promise; -export type DiscordMessageUpdateEvent = Parameters[0]; - type DiscordReactionEvent = Parameters[0]; type DiscordReactionListenerParams = { @@ -56,16 +41,6 @@ type DiscordReactionListenerParams = { logger: Logger; }; -type DiscordMessageUpdateListenerParams = DiscordReactionListenerParams & { - dmEnabled: boolean; - dmPolicy: DmPolicy; - allowFrom?: string[]; - groupPolicy: GroupPolicy; - groupDmEnabled: boolean; - groupDmChannels?: string[]; - allowBots: boolean; -}; - const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000; const discordEventQueueLog = createSubsystemLogger("discord/event-queue"); @@ -128,22 +103,6 @@ export class DiscordMessageListener extends MessageCreateListener { } } -export class DiscordMessageUpdateListener extends MessageUpdateListener { - constructor(private params: DiscordMessageUpdateListenerParams) { - super(); - } - - async handle(data: DiscordMessageUpdateEvent, client: Client) { - await runDiscordMessageUpdateHandler({ - data, - client, - handlerParams: this.params, - listener: this.constructor.name, - event: this.type, - }); - } -} - export class DiscordReactionListener extends MessageReactionAddListener { constructor(private params: DiscordReactionListenerParams) { super(); @@ -178,30 +137,6 @@ export class DiscordReactionRemoveListener extends MessageReactionRemoveListener } } -async function runDiscordMessageUpdateHandler(params: { - data: DiscordMessageUpdateEvent; - client: Client; - handlerParams: DiscordMessageUpdateListenerParams; - listener: string; - event: string; -}): Promise { - const startedAt = Date.now(); - try { - await handleDiscordMessageUpdateEvent({ - data: params.data, - client: params.client, - handlerParams: params.handlerParams, - }); - } finally { - logSlowDiscordListener({ - logger: params.handlerParams.logger, - listener: params.listener, - event: params.event, - durationMs: Date.now() - startedAt, - }); - } -} - async function runDiscordReactionHandler(params: { data: DiscordReactionEvent; client: Client; @@ -232,223 +167,6 @@ async function runDiscordReactionHandler(params: { } } -async function handleDiscordMessageUpdateEvent(params: { - data: DiscordMessageUpdateEvent; - client: Client; - handlerParams: DiscordMessageUpdateListenerParams; -}) { - const { data, client, handlerParams } = params; - try { - const message = data.message; - if (!message) { - return; - } - const editedTimestamp = - message.editedTimestamp ?? - (data as { edited_timestamp?: string | null }).edited_timestamp ?? - null; - if (!editedTimestamp) { - return; - } - - const author = - message.author ?? (message as { rawData?: { author?: User | null } }).rawData?.author; - const authorId = author?.id ? String(author.id) : ""; - if (handlerParams.botUserId && authorId && authorId === handlerParams.botUserId) { - return; - } - if (author?.bot && !handlerParams.allowBots) { - return; - } - - const messageChannelId = resolveDiscordMessageChannelId({ - message, - eventChannelId: data.channel_id, - }); - if (!messageChannelId) { - return; - } - - const channelInfo = await resolveDiscordChannelInfo(client, messageChannelId); - const isGuildMessage = Boolean(data.guild_id); - if (!channelInfo && !isGuildMessage) { - return; - } - - const isDirectMessage = channelInfo?.type === ChannelType.DM; - const isGroupDm = channelInfo?.type === ChannelType.GroupDM; - - if (isDirectMessage) { - if (!handlerParams.dmEnabled) { - return; - } - if (handlerParams.dmPolicy === "disabled") { - return; - } - if (!authorId) { - return; - } - if (handlerParams.dmPolicy !== "open") { - const storeAllowFrom = await readChannelAllowFromStore("discord").catch(() => []); - const effectiveAllowFrom = [...(handlerParams.allowFrom ?? []), ...storeAllowFrom]; - const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [ - "discord:", - "user:", - "pk:", - ]); - if (!allowList) { - return; - } - const authorTag = author ? formatDiscordUserTag(author as User) : undefined; - const allowed = allowListMatches(allowList, { - id: authorId, - name: author?.username ?? undefined, - tag: authorTag, - }); - if (!allowed) { - return; - } - } - } - - if (isGroupDm) { - if (!handlerParams.groupDmEnabled) { - return; - } - const channelName = channelInfo?.name ?? undefined; - const displayChannelName = channelName ?? messageChannelId; - const displayChannelSlug = displayChannelName ? normalizeDiscordSlug(displayChannelName) : ""; - const groupDmAllowed = resolveGroupDmAllow({ - channels: handlerParams.groupDmChannels, - channelId: messageChannelId, - channelName: displayChannelName, - channelSlug: displayChannelSlug, - }); - if (!groupDmAllowed) { - return; - } - } - - let threadParentId: string | undefined; - let threadParentName: string | undefined; - const threadChannel = resolveDiscordThreadChannel({ - isGuildMessage, - message, - channelInfo, - messageChannelId, - }); - if (threadChannel) { - const parentInfo = await resolveDiscordThreadParentInfo({ - client, - threadChannel, - channelInfo, - }); - threadParentId = parentInfo.id; - threadParentName = parentInfo.name; - } - - const guildInfo = isGuildMessage - ? resolveDiscordGuildEntry({ - guild: data.guild ?? undefined, - guildEntries: handlerParams.guildEntries, - }) - : null; - if ( - isGuildMessage && - handlerParams.guildEntries && - Object.keys(handlerParams.guildEntries).length > 0 && - !guildInfo - ) { - return; - } - - const channelName = channelInfo?.name ?? threadChannel?.name ?? undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; - const parentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : ""; - const channelConfig = isGuildMessage - ? resolveDiscordChannelConfigWithFallback({ - guildInfo, - channelId: messageChannelId, - channelName, - channelSlug, - parentId: threadParentId, - parentName: threadParentName, - parentSlug, - scope: threadChannel ? "thread" : "channel", - }) - : null; - - if (isGuildMessage && channelConfig?.enabled === false) { - return; - } - - const channelAllowlistConfigured = - Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; - const channelAllowed = channelConfig?.allowed !== false; - if ( - isGuildMessage && - !isDiscordGroupAllowedByPolicy({ - groupPolicy: handlerParams.groupPolicy, - guildAllowlisted: Boolean(guildInfo), - channelAllowlistConfigured, - channelAllowed, - }) - ) { - return; - } - if (isGuildMessage && channelConfig?.allowed === false) { - return; - } - - const memberRoles = (data as { member?: { roles?: string[] } }).member?.roles; - const memberRoleIds = Array.isArray(memberRoles) - ? memberRoles.map((roleId) => String(roleId)) - : []; - - const senderTag = author ? formatDiscordUserTag(author as User) : undefined; - const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ - channelConfig, - guildInfo, - memberRoleIds, - sender: { - id: authorId, - name: author?.username ?? undefined, - tag: senderTag, - }, - }); - if (isGuildMessage && hasAccessRestrictions && !memberAllowed) { - return; - } - - const route = resolveAgentRoute({ - cfg: handlerParams.cfg, - channel: "discord", - accountId: handlerParams.accountId, - guildId: data.guild_id ?? undefined, - memberRoleIds, - peer: { - kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", - id: isDirectMessage ? authorId : messageChannelId, - }, - parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, - }); - - const location = resolveDiscordSystemLocation({ - isDirectMessage, - isGroupDm, - guild: data.guild ?? undefined, - channelName: channelName ?? messageChannelId, - }); - const text = `Discord message edited in ${location}.`; - enqueueSystemEvent(text, { - sessionKey: route.sessionKey, - contextKey: `discord:message:edited:${messageChannelId}:${message.id}`, - }); - } catch (err) { - handlerParams.logger.error(danger(`discord message update handler failed: ${String(err)}`)); - } -} - async function handleDiscordReactionEvent(params: { data: DiscordReactionEvent; client: Client; diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index cf0c0de900e..0badfe48369 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -173,7 +173,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null; const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined; const groupSubject = isDirectMessage ? undefined : groupChannel; - const channelTopic = isGuildMessage ? channelInfo?.topic : undefined; const untrustedChannelMetadata = isGuildMessage ? buildUntrustedChannelMetadata({ source: "discord", @@ -335,7 +334,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) SenderTag: senderTag, GroupSubject: groupSubject, GroupChannel: groupChannel, - ChannelTopic: channelTopic, UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined, GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined, GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, diff --git a/src/discord/monitor/message-update.test.ts b/src/discord/monitor/message-update.test.ts deleted file mode 100644 index 753ea51c248..00000000000 --- a/src/discord/monitor/message-update.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ChannelType } from "@buape/carbon"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { DiscordMessageUpdateListener, type DiscordMessageUpdateEvent } from "./listeners.js"; -import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js"; - -vi.mock("../../infra/system-events.js", () => ({ - enqueueSystemEvent: vi.fn(), -})); - -describe("DiscordMessageUpdateListener", () => { - const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent); - - beforeEach(() => { - enqueueSystemEventMock.mockReset(); - __resetDiscordChannelInfoCacheForTest(); - }); - - it("enqueues system event for edited DMs", async () => { - const cfg = { channels: { discord: {} } } as OpenClawConfig; - const listener = new DiscordMessageUpdateListener({ - cfg, - accountId: "default", - runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv, - botUserId: "bot-1", - guildEntries: undefined, - logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType< - typeof import("../../logging/subsystem.js").createSubsystemLogger - >, - dmEnabled: true, - dmPolicy: "open", - allowFrom: [], - groupPolicy: "open", - groupDmEnabled: false, - groupDmChannels: undefined, - allowBots: false, - }); - - const message = { - id: "msg-1", - channelId: "dm-1", - editedTimestamp: "2026-02-20T00:00:00.000Z", - author: { id: "user-1", username: "Ada", discriminator: "0001", bot: false }, - } as unknown as import("@buape/carbon").Message; - - const client = { - fetchChannel: vi.fn(async () => ({ type: ChannelType.DM })), - } as unknown as import("@buape/carbon").Client; - - await listener.handle( - { - channel_id: "dm-1", - message, - } as DiscordMessageUpdateEvent, - client, - ); - - expect(enqueueSystemEventMock).toHaveBeenCalledWith( - "Discord message edited in DM.", - expect.objectContaining({ - contextKey: "discord:message:edited:dm-1:msg-1", - }), - ); - }); - - it("skips system event when guild allowlist blocks sender", async () => { - const cfg = { channels: { discord: {} } } as OpenClawConfig; - const listener = new DiscordMessageUpdateListener({ - cfg, - accountId: "default", - runtime: { error: vi.fn() } as unknown as import("../../runtime.js").RuntimeEnv, - botUserId: "bot-1", - guildEntries: { - "guild-1": { users: ["user-allowed"] }, - }, - logger: { error: vi.fn(), warn: vi.fn() } as unknown as ReturnType< - typeof import("../../logging/subsystem.js").createSubsystemLogger - >, - dmEnabled: true, - dmPolicy: "open", - allowFrom: [], - groupPolicy: "open", - groupDmEnabled: false, - groupDmChannels: undefined, - allowBots: false, - }); - - const message = { - id: "msg-2", - channelId: "channel-1", - editedTimestamp: "2026-02-20T00:00:00.000Z", - author: { id: "user-blocked", username: "Ada", discriminator: "0001", bot: false }, - } as unknown as import("@buape/carbon").Message; - - const client = { - fetchChannel: vi.fn(async () => ({ type: ChannelType.GuildText })), - } as unknown as import("@buape/carbon").Client; - - await listener.handle( - { - channel_id: "channel-1", - guild_id: "guild-1", - guild: { id: "guild-1", name: "Test Guild" }, - member: { roles: [] }, - message, - } as DiscordMessageUpdateEvent, - client, - ); - - expect(enqueueSystemEventMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index e3c0a20962b..3a44da89882 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -59,7 +59,6 @@ import { createDiscordGatewayPlugin } from "./gateway-plugin.js"; import { registerGateway, unregisterGateway } from "./gateway-registry.js"; import { DiscordMessageListener, - DiscordMessageUpdateListener, DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, @@ -606,24 +605,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger)); - registerDiscordListener( - client.listeners, - new DiscordMessageUpdateListener({ - cfg, - accountId: account.accountId, - runtime, - botUserId, - guildEntries, - logger, - dmEnabled, - dmPolicy, - allowFrom, - groupPolicy, - groupDmEnabled, - groupDmChannels, - allowBots: discordCfg.allowBots ?? false, - }), - ); registerDiscordListener( client.listeners, new DiscordReactionListener({ diff --git a/src/discord/send.components.test.ts b/src/discord/send.components.test.ts deleted file mode 100644 index 0f1bea1cc56..00000000000 --- a/src/discord/send.components.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ChannelType } from "discord-api-types/v10"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerDiscordComponentEntries } from "./components-registry.js"; -import { sendDiscordComponentMessage } from "./send.components.js"; -import { makeDiscordRest } from "./send.test-harness.js"; - -const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } }))); - -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: () => loadConfigMock(), - }; -}); - -vi.mock("./components-registry.js", () => ({ - registerDiscordComponentEntries: vi.fn(), -})); - -describe("sendDiscordComponentMessage", () => { - const registerMock = vi.mocked(registerDiscordComponentEntries); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("maps DM channel targets to direct-session component entries", async () => { - const { rest, postMock, getMock } = makeDiscordRest(); - getMock.mockResolvedValueOnce({ - type: ChannelType.DM, - recipients: [{ id: "user-1" }], - }); - postMock.mockResolvedValueOnce({ id: "msg1", channel_id: "dm-1" }); - - await sendDiscordComponentMessage( - "channel:dm-1", - { - blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }], - }, - { - rest, - token: "t", - sessionKey: "agent:main:discord:channel:dm-1", - agentId: "main", - }, - ); - - expect(registerMock).toHaveBeenCalledTimes(1); - const args = registerMock.mock.calls[0]?.[0]; - expect(args?.entries[0]?.sessionKey).toBe("agent:main:main"); - }); -}); diff --git a/src/discord/send.components.ts b/src/discord/send.components.ts index 85ace3d4520..0afd1f83379 100644 --- a/src/discord/send.components.ts +++ b/src/discord/send.components.ts @@ -8,7 +8,6 @@ import type { APIChannel } from "discord-api-types/v10"; import { ChannelType, Routes } from "discord-api-types/v10"; import { loadConfig } from "../config/config.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; -import { buildAgentSessionKey } from "../routing/resolve-route.js"; import { loadWebMedia } from "../web/media.js"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; @@ -30,50 +29,6 @@ import type { DiscordSendResult } from "./send.types.js"; const DISCORD_FORUM_LIKE_TYPES = new Set([ChannelType.GuildForum, ChannelType.GuildMedia]); -type DiscordRecipient = Awaited>; - -function resolveDiscordDmRecipientId(channel?: APIChannel): string | undefined { - if (!channel || channel.type !== ChannelType.DM) { - return undefined; - } - const recipients = (channel as { recipients?: Array<{ id?: string }> }).recipients; - const recipientId = recipients?.[0]?.id; - if (typeof recipientId !== "string") { - return undefined; - } - const trimmed = recipientId.trim(); - return trimmed ? trimmed : undefined; -} - -function resolveDiscordComponentSessionKey(params: { - cfg: ReturnType; - accountId: string; - agentId?: string; - sessionKey?: string; - recipient: DiscordRecipient; - channel?: APIChannel; -}): string | undefined { - if (!params.sessionKey || !params.agentId) { - return params.sessionKey; - } - if (params.recipient.kind !== "channel") { - return params.sessionKey; - } - const recipientId = resolveDiscordDmRecipientId(params.channel); - if (!recipientId) { - return params.sessionKey; - } - // DM channel IDs should map back to the user session for component interactions. - return buildAgentSessionKey({ - agentId: params.agentId, - channel: "discord", - accountId: params.accountId, - peer: { kind: "direct", id: recipientId }, - dmScope: params.cfg.session?.dmScope, - identityLinks: params.cfg.session?.identityLinks, - }); -} - function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] { const names: string[] = []; for (const block of spec.blocks ?? []) { @@ -108,10 +63,9 @@ export async function sendDiscordComponentMessage( const recipient = await parseAndResolveRecipient(to, opts.accountId); const { channelId } = await resolveChannelId(rest, recipient, request); - let channel: APIChannel | undefined; let channelType: number | undefined; try { - channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined; + const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined; channelType = channel?.type; } catch { channelType = undefined; @@ -121,18 +75,9 @@ export async function sendDiscordComponentMessage( throw new Error("Discord components are not supported in forum-style channels"); } - const componentSessionKey = resolveDiscordComponentSessionKey({ - cfg, - accountId: accountInfo.accountId, - agentId: opts.agentId, - sessionKey: opts.sessionKey, - recipient, - channel, - }); - const buildResult = buildDiscordComponentMessage({ spec, - sessionKey: componentSessionKey, + sessionKey: opts.sessionKey, agentId: opts.agentId, accountId: accountInfo.accountId, }); diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index 16e021b571e..c21841dc38a 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -24,9 +24,7 @@ async function withAudioFixture( await fs.writeFile(tmpPath, Buffer.from("RIFF")); const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" }; const media = normalizeMediaAttachments(ctx); - const cache = createMediaAttachmentCache(media, { - localPathRoots: [os.tmpdir()], - }); + const cache = createMediaAttachmentCache(media); try { await run({ ctx, media, cache }); diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 3782956632c..ac7082adbf4 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -17,9 +17,7 @@ describe("runCapability deepgram provider options", () => { await fs.writeFile(tmpPath, Buffer.from("RIFF")); const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" }; const media = normalizeMediaAttachments(ctx); - const cache = createMediaAttachmentCache(media, { - localPathRoots: [os.tmpdir()], - }); + const cache = createMediaAttachmentCache(media); let seenQuery: Record | undefined; let seenBaseUrl: string | undefined; diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index d184cd5f10c..1e51e0dbca5 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -453,140 +453,6 @@ export const registerTelegramHandlers = ({ return false; }; - const buildTelegramEditSenderLabel = (msg: Message) => { - const senderChat = msg.sender_chat; - const senderName = - [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || - msg.from?.username || - senderChat?.title || - senderChat?.username; - const senderUsername = msg.from?.username ?? senderChat?.username; - const senderUsernameLabel = senderUsername ? `@${senderUsername}` : undefined; - let senderLabel = senderName; - if (senderName && senderUsernameLabel) { - senderLabel = `${senderName} (${senderUsernameLabel})`; - } else if (!senderName && senderUsernameLabel) { - senderLabel = senderUsernameLabel; - } - const senderId = msg.from?.id ?? senderChat?.id; - if (!senderLabel && senderId != null) { - senderLabel = `id:${senderId}`; - } - return senderLabel || "unknown"; - }; - - const handleTelegramEditedMessage = async (params: { - ctx: TelegramUpdateKeyContext; - msg: Message; - requireConfiguredGroup: boolean; - }) => { - try { - if (shouldSkipUpdate(params.ctx)) { - return; - } - - const msg = params.msg; - if (msg.from?.is_bot) { - return; - } - - const chatId = msg.chat.id; - const isChannelPost = msg.chat.type === "channel"; - const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup" || isChannelPost; - const isForum = msg.chat.is_forum === true; - const messageThreadId = msg.message_thread_id; - const senderId = - msg.from?.id != null - ? String(msg.from.id) - : msg.sender_chat?.id != null - ? String(msg.sender_chat.id) - : String(chatId); - const senderUsername = msg.from?.username ?? msg.sender_chat?.username ?? ""; - const groupAllowContext = await resolveTelegramGroupAllowFromContext({ - chatId, - accountId, - isForum, - messageThreadId, - groupAllowFrom, - resolveTelegramGroupConfig, - }); - const { - resolvedThreadId, - storeAllowFrom, - groupConfig, - topicConfig, - effectiveGroupAllow, - hasGroupAllowOverride, - } = groupAllowContext; - - if (params.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) { - logVerbose(`Blocked telegram channel ${chatId} (channel disabled)`); - return; - } - - if ( - shouldSkipGroupMessage({ - isGroup, - chatId, - chatTitle: msg.chat.title, - resolvedThreadId, - senderId, - senderUsername, - effectiveGroupAllow, - hasGroupAllowOverride, - groupConfig, - topicConfig, - }) - ) { - return; - } - - if (!isGroup) { - const dmPolicy = telegramCfg.dmPolicy ?? "pairing"; - if (dmPolicy === "disabled") { - return; - } - const effectiveDmAllow = normalizeAllowFromWithStore({ - allowFrom: telegramCfg.allowFrom, - storeAllowFrom, - }); - if (dmPolicy !== "open") { - const allowed = isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername); - if (!allowed) { - return; - } - } - } - - const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId); - const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); - const route = resolveAgentRoute({ - cfg: loadConfig(), - channel: "telegram", - accountId, - peer: { kind: isGroup ? "group" : "direct", id: peerId }, - parentPeer, - }); - const sessionKey = route.sessionKey; - const senderLabel = buildTelegramEditSenderLabel(msg); - const chatLabel = isGroup - ? msg.chat.title?.trim() || (isChannelPost ? "Telegram channel" : "Telegram group") - : senderLabel !== "unknown" - ? `DM with ${senderLabel}` - : "DM"; - const text = `Telegram message edited in ${chatLabel}.`; - enqueueSystemEvent(text, { - sessionKey, - contextKey: `telegram:message:edited:${chatId}:${resolvedThreadId ?? "main"}:${ - msg.message_id - }`, - }); - logVerbose(`telegram: edit event enqueued: ${text}`); - } catch (err) { - runtime.error?.(danger(`telegram edit handler failed: ${String(err)}`)); - } - }; - // Handle emoji reactions to messages. bot.on("message_reaction", async (ctx) => { try { @@ -678,35 +544,6 @@ export const registerTelegramHandlers = ({ runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`)); } }); - - bot.on("edited_message", async (ctx) => { - const msg = - (ctx as { editedMessage?: Message }).editedMessage ?? ctx.update?.edited_message ?? undefined; - if (!msg) { - return; - } - await handleTelegramEditedMessage({ - ctx, - msg, - requireConfiguredGroup: false, - }); - }); - - bot.on("edited_channel_post", async (ctx) => { - const msg = - (ctx as { editedChannelPost?: Message }).editedChannelPost ?? - ctx.update?.edited_channel_post ?? - undefined; - if (!msg) { - return; - } - await handleTelegramEditedMessage({ - ctx, - msg, - requireConfiguredGroup: true, - }); - }); - const processInboundMessage = async (params: { ctx: TelegramContext; msg: Message; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index db5a33814d0..6c37766198c 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -746,43 +746,6 @@ describe("createTelegramBot", () => { expect(reactionHandler).toBeDefined(); }); - it("enqueues system event for edited messages", async () => { - onSpy.mockReset(); - enqueueSystemEventSpy.mockReset(); - - loadConfig.mockReturnValue({ - channels: { - telegram: { dmPolicy: "open" }, - }, - }); - - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("edited_message") as ( - ctx: Record, - ) => Promise; - - const editedMessage = { - chat: { id: 1234, type: "private" }, - message_id: 88, - from: { id: 9, first_name: "Ada", username: "ada_bot" }, - date: 1736380800, - text: "edited", - }; - - await handler({ - update: { update_id: 550, edited_message: editedMessage }, - editedMessage, - }); - - expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); - expect(enqueueSystemEventSpy).toHaveBeenCalledWith( - "Telegram message edited in DM with Ada (@ada_bot).", - expect.objectContaining({ - contextKey: expect.stringContaining("telegram:message:edited:1234:main:88"), - }), - ); - }); - it("enqueues system event for reaction", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); diff --git a/src/web/media.test.ts b/src/web/media.test.ts index a2395d6817c..ea50ecd1c73 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -6,7 +6,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest import { resolveStateDir } from "../config/paths.js"; import { sendVoiceMessageDiscord } from "../discord/send.js"; import * as ssrf from "../infra/net/ssrf.js"; -import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { optimizeImageToPng } from "../media/image-ops.js"; import { captureEnv } from "../test-utils/env.js"; import { @@ -51,9 +50,7 @@ async function createLargeTestJpeg(): Promise<{ buffer: Buffer; file: string }> } beforeAll(async () => { - fixtureRoot = await fs.mkdtemp( - path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-test-"), - ); + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-test-")); largeJpegBuffer = await sharp({ create: { width: 400, @@ -337,9 +334,7 @@ describe("local media root guard", () => { }); it("allows local paths under an explicit root", async () => { - const result = await loadWebMedia(tinyPngFile, 1024 * 1024, { - localRoots: [resolvePreferredOpenClawTmpDir()], - }); + const result = await loadWebMedia(tinyPngFile, 1024 * 1024, { localRoots: [os.tmpdir()] }); expect(result.kind).toBe("image"); });