diff --git a/CHANGELOG.md b/CHANGELOG.md index 3706aa8b5c3..ce244328210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Cron/Telegram: preserve direct-chat thread IDs and optional account IDs when inferring reminder delivery from Telegram direct-thread session keys. Fixes #44270; carries forward #44325, #44351, #44412, and #72657. Thanks @RunMintOn, @arkyu2077, @0xsline, and @vincentkoc. - Cron: omit synthetic `delivery.resolved` errors from `--no-deliver` run records while preserving explicit no-deliver target traces for agent-initiated messages. Fixes #72210; carries forward #72219. Thanks @hatemclawbot-collab and @xydigit-sj. - Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui. +- Subagents: keep the delegated task only in the subagent system prompt and send a short initial kickoff message, avoiding duplicate task tokens while preserving multiline task formatting. Fixes #72019; carries forward #72053. Thanks @Wizongod and @ly85206559. - Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9. - Gateway/install: add a validated `--wrapper`/`OPENCLAW_WRAPPER` service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun `ProgramArguments`. Fixes #69400. (#72445) Thanks @willtmc. - Plugins: fail plugin registration when loader-owned acceptance gates reject missing hook names or memory-only capability registration from non-memory plugins, surfacing the issue through plugin status and doctor instead of silently dropping the registration. Fixes #72459. Thanks @1fanwang and @amknight. diff --git a/src/agents/subagent-initial-user-message.test.ts b/src/agents/subagent-initial-user-message.test.ts new file mode 100644 index 00000000000..4481770ee35 --- /dev/null +++ b/src/agents/subagent-initial-user-message.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js"; +import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js"; + +describe("buildSubagentInitialUserMessage", () => { + it("does not embed a task string already present in the system prompt (#72019)", () => { + const msg = buildSubagentInitialUserMessage({ + childDepth: 1, + maxSpawnDepth: 3, + persistentSession: false, + }); + + expect(msg).not.toContain("[Subagent Task]:"); + expect(msg).toContain("**Your Role**"); + expect(msg).toContain("depth 1/3"); + }); + + it("includes the persistent session note when requested", () => { + const msg = buildSubagentInitialUserMessage({ + childDepth: 2, + maxSpawnDepth: 4, + persistentSession: true, + }); + + expect(msg).toContain("persistent and remains available"); + }); + + it("keeps the delegated task single-sourced across system and first user text", () => { + const task = "UNIQUE_SUBAGENT_TASK_TOKEN\n preserve indentation"; + const system = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:test", + task, + childDepth: 1, + maxSpawnDepth: 2, + }); + const user = buildSubagentInitialUserMessage({ + childDepth: 1, + maxSpawnDepth: 2, + persistentSession: false, + }); + + expect(system).toContain("UNIQUE_SUBAGENT_TASK_TOKEN"); + expect(user).not.toContain("UNIQUE_SUBAGENT_TASK_TOKEN"); + expect(`${system}\n${user}`.match(/UNIQUE_SUBAGENT_TASK_TOKEN/g)).toHaveLength(1); + }); +}); diff --git a/src/agents/subagent-initial-user-message.ts b/src/agents/subagent-initial-user-message.ts new file mode 100644 index 00000000000..df5888bcb12 --- /dev/null +++ b/src/agents/subagent-initial-user-message.ts @@ -0,0 +1,26 @@ +/** + * First user turn for a native `sessions_spawn` / subagent run. + * + * Keep the full task out of this message: `buildSubagentSystemPrompt` already + * places it under **Your Role**, and repeating it here doubles first-request + * input tokens (#72019). + */ +export function buildSubagentInitialUserMessage(params: { + childDepth: number; + maxSpawnDepth: number; + /** When true, this subagent uses a persistent session for follow-up messages. */ + persistentSession: boolean; +}): string { + const lines = [ + `[Subagent Context] You are running as a subagent (depth ${params.childDepth}/${params.maxSpawnDepth}). Results auto-announce to your requester; do not busy-poll for status.`, + ]; + if (params.persistentSession) { + lines.push( + "[Subagent Context] This subagent session is persistent and remains available for thread follow-up messages.", + ); + } + lines.push( + "Begin. Your assigned task is in the system prompt under **Your Role**; execute it to completion.", + ); + return lines.join("\n\n"); +} diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 52b2f061d3d..9ebb4d6006c 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -288,6 +288,42 @@ describe("spawnSubagentDirect seam flow", () => { }); }); + it("does not duplicate long subagent task text in the initial user message (#72019)", async () => { + const calls: Array<{ method?: string; params?: unknown }> = []; + hoisted.callGatewayMock.mockImplementation( + async (request: { method?: string; params?: unknown }) => { + calls.push(request); + if (request.method === "agent") { + return { runId: "run-no-dup", status: "accepted", acceptedAt: 1000 }; + } + if (request.method?.startsWith("sessions.")) { + return { ok: true }; + } + return {}; + }, + ); + installSessionStoreCaptureMock(hoisted.updateSessionStoreMock); + + const task = "UNIQUE_LONG_SUBAGENT_TASK_TOKEN\n keep indentation"; + const result = await spawnSubagentDirect( + { + task, + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "discord", + }, + ); + + expect(result.status).toBe("accepted"); + const agentCall = calls.find((call) => call.method === "agent"); + const params = agentCall?.params as { message?: string; extraSystemPrompt?: string }; + expect(params.message).not.toContain("UNIQUE_LONG_SUBAGENT_TASK_TOKEN"); + expect(params.message).not.toContain("[Subagent Task]:"); + expect(params.message).toContain("**Your Role**"); + expect(params.extraSystemPrompt).toBe("system-prompt"); + }); + it("returns an error when the initial child session patch is rejected", async () => { hoisted.callGatewayMock.mockImplementation( async (request: { method?: string; params?: unknown }) => { diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 7c6f1f52e6c..328f7779b26 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -26,6 +26,7 @@ import { } from "./subagent-attachments.js"; import { resolveSubagentCapabilities } from "./subagent-capabilities.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; +import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js"; import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js"; import { resolveSubagentSpawnAcceptedNote } from "./subagent-spawn-accepted-note.js"; export { @@ -974,15 +975,11 @@ export async function spawnSubagentDirect( ? "lightweight" : undefined; - const childTaskMessage = [ - `[Subagent Context] You are running as a subagent (depth ${childDepth}/${maxSpawnDepth}). Results auto-announce to your requester; do not busy-poll for status.`, - spawnMode === "session" - ? "[Subagent Context] This subagent session is persistent and remains available for thread follow-up messages." - : undefined, - `[Subagent Task]: ${task}`, - ] - .filter((line): line is string => Boolean(line)) - .join("\n\n"); + const childTaskMessage = buildSubagentInitialUserMessage({ + childDepth, + maxSpawnDepth, + persistentSession: spawnMode === "session", + }); const toolSpawnMetadata = mapToolContextToSpawnedRunMetadata({ agentGroupId: ctx.agentGroupId, diff --git a/src/agents/subagent-system-prompt.ts b/src/agents/subagent-system-prompt.ts index d1a1b3f79f9..555230b17dd 100644 --- a/src/agents/subagent-system-prompt.ts +++ b/src/agents/subagent-system-prompt.ts @@ -16,10 +16,9 @@ export function buildSubagentSystemPrompt(params: { /** Config value: max allowed spawn depth. */ maxSpawnDepth?: number; }) { - const taskText = - typeof params.task === "string" && params.task.trim() - ? params.task.replace(/\s+/g, " ").trim() - : "{{TASK_DESCRIPTION}}"; + const taskRaw = typeof params.task === "string" ? params.task : ""; + const taskBody = taskRaw.trim(); + const hasTask = taskBody !== ""; const childDepth = typeof params.childDepth === "number" ? params.childDepth : 1; const maxSpawnDepth = typeof params.maxSpawnDepth === "number" @@ -31,17 +30,33 @@ export function buildSubagentSystemPrompt(params: { ); const canSpawn = childDepth < maxSpawnDepth; const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; + const roleLines = + hasTask && taskBody.includes("\n") + ? [ + "## Your Role", + "- You were created to handle the following task (verbatim; line breaks preserved):", + "", + "```", + taskBody, + "```", + "- Complete this task. That's your entire purpose.", + `- You are NOT the ${parentLabel}. Don't try to be.`, + "", + ] + : [ + "## Your Role", + `- You were created to handle: ${hasTask ? taskBody : "{{TASK_DESCRIPTION}}"}`, + "- Complete this task. That's your entire purpose.", + `- You are NOT the ${parentLabel}. Don't try to be.`, + "", + ]; const lines = [ "# Subagent Context", "", `You are a **subagent** spawned by the ${parentLabel} for a specific task.`, "", - "## Your Role", - `- You were created to handle: ${taskText}`, - "- Complete this task. That's your entire purpose.", - `- You are NOT the ${parentLabel}. Don't try to be.`, - "", + ...roleLines, "## Rules", "1. **Stay focused** - Do your assigned task, nothing else", `2. **Complete the task** - Your final message will be automatically reported to the ${parentLabel}`, diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index f2ea67fd90e..98777275937 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -988,6 +988,22 @@ describe("buildSubagentSystemPrompt", () => { expect(prompt).toContain("instead of full-file `cat`"); }); + it("keeps multiline and indented task text verbatim in the system prompt (#72019)", () => { + const task = "line one\n line two\n line three"; + const prompt = buildSubagentSystemPrompt({ + childSessionKey: "agent:main:subagent:abc", + task, + childDepth: 1, + maxSpawnDepth: 1, + }); + + expect(prompt).toContain("```"); + expect(prompt).toContain("line one"); + expect(prompt).toContain(" line two"); + expect(prompt).toContain(" line three"); + expect(prompt).not.toContain("line one line two"); + }); + it("omits ACP spawning guidance when ACP is disabled", () => { const prompt = buildSubagentSystemPrompt({ childSessionKey: "agent:main:subagent:abc",