diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 1dc5fb8cca..b44d892be5 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -169,6 +169,7 @@ Behavior: - 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 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). diff --git a/docs/tools/index.md b/docs/tools/index.md index 54453cea5d..1ff08702b5 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -475,6 +475,8 @@ Notes: - `sessions_send` waits for final completion when `timeoutSeconds > 0`. - Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered. - `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat. + - Reply format includes `Status`, `Result`, and compact stats. + - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback. - `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately. - `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5). - After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 857d001e61..af952b8417 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -78,7 +78,7 @@ Text + native (when enabled): - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) -- `/subagents list|kill|log|info|send|steer` (inspect, kill, log, or steer sub-agent runs for the current session) +- `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session) - `/kill ` (immediately abort one or all running sub-agents for this session; no confirmation message) - `/steer ` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message) - `/tell ` (alias for `/steer`) diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 3dd66d6608..3ff71f1f90 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -19,9 +19,24 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session* - `/subagents log [limit] [tools]` - `/subagents info ` - `/subagents send ` +- `/subagents steer ` +- `/subagents spawn [--model ] [--thinking ]` `/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup). +### Spawn behavior + +`/subagents spawn` starts a background sub-agent as a user command, not an internal relay, and it sends one final completion update back to the requester chat when the run finishes. + +- The spawn command is non-blocking; it returns a run id immediately. +- On completion, the sub-agent announces a summary/result message back to the requester chat channel. +- The completion message is a system message and includes: + - `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. +- Use `info`/`log` to inspect details and output after completion. + Primary goals: - Parallelize "research / long task / slow tool" work without blocking the main run. diff --git a/docs/zh-CN/tools/slash-commands.md b/docs/zh-CN/tools/slash-commands.md index 2f55997fa9..66d2bf6306 100644 --- a/docs/zh-CN/tools/slash-commands.md +++ b/docs/zh-CN/tools/slash-commands.md @@ -76,7 +76,7 @@ x-i18n: - `/approve allow-once|allow-always|deny`(解决 exec 审批提示) - `/context [list|detail|json]`(解释"上下文";`detail` 显示每个文件 + 每个工具 + 每个 Skill + 系统提示词大小) - `/whoami`(显示你的发送者 ID;别名:`/id`) -- `/subagents list|stop|log|info|send`(检查、停止、记录或向当前会话的子智能体运行发送消息) +- `/subagents list|kill|log|info|send|steer|spawn`(检查、控制或创建当前会话的子智能体运行) - `/config show|get|set|unset`(将配置持久化到磁盘,仅所有者;需要 `commands.config: true`) - `/debug show|set|unset|reset`(运行时覆盖,仅所有者;需要 `commands.debug: true`) - `/usage off|tokens|full|cost`(每响应使用量页脚或本地成本摘要) diff --git a/docs/zh-CN/tools/subagents.md b/docs/zh-CN/tools/subagents.md index d843084096..8db615622e 100644 --- a/docs/zh-CN/tools/subagents.md +++ b/docs/zh-CN/tools/subagents.md @@ -22,13 +22,24 @@ x-i18n: 使用 `/subagents` 检查或控制**当前会话**的子智能体运行: - `/subagents list` -- `/subagents stop ` +- `/subagents kill ` - `/subagents log [limit] [tools]` - `/subagents info ` - `/subagents send ` +- `/subagents steer ` +- `/subagents spawn [--model ] [--thinking ]` `/subagents info` 显示运行元数据(状态、时间戳、会话 id、转录路径、清理)。 +### 启动行为 + +`/subagents spawn` 以用户命令方式启动后台子智能体,任务完成后会向请求者聊天频道回发一条最终完成消息。 + +- 该命令非阻塞,先返回 `runId`。 +- 完成后,子智能体会将汇总/结果消息发布到请求者聊天渠道。 +- `--model` 与 `--thinking` 可仅对本次运行做覆盖设置。 +- 可在完成后通过 `info`/`log` 查看详细信息和输出。 + 主要目标: - 并行化"研究 / 长任务 / 慢工具"工作,而不阻塞主运行。 diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 44c7c2cc27..872f845e8e 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -23,6 +23,7 @@ const subagentRegistryMock = { countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0), 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: { @@ -66,6 +67,9 @@ vi.mock("../gateway/call.js", () => ({ 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 {}; } @@ -114,6 +118,7 @@ describe("subagent announce formatting", () => { subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0); subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null); readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply"); + chatHistoryMock.mockReset().mockResolvedValue({ messages: [] }); sessionStore = {}; configOverride = { session: { @@ -197,6 +202,36 @@ describe("subagent announce formatting", () => { ); }); + it("falls back to latest toolResult output when assistant reply 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 line 1" }], + }, + ], + }); + readLatestAssistantReplyMock.mockResolvedValue(""); + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-tool-fallback", + 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("tool output line 1"); + }); + it("keeps full findings and includes compact stats", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); sessionStore = { diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 56cfdf5284..8bd2950bb9 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -10,6 +10,7 @@ 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, @@ -29,6 +30,67 @@ import { import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import { readLatestAssistantReply } from "./tools/agent-step.js"; +import { sanitizeTextContent } from "./tools/sessions-helpers.js"; + +type ToolResultMessage = { + role?: unknown; + content?: unknown; +}; + +function extractToolResultText(content: unknown): string { + if (typeof content === "string") { + return sanitizeTextContent(content); + } + if (!Array.isArray(content)) { + return ""; + } + const joined = extractTextFromChatContent(content, { + sanitizeText: sanitizeTextContent, + normalizeText: (text) => text, + joinWith: "\n", + }); + return joined?.trim() ?? ""; +} + +async function readLatestToolResult(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]; + if (!msg || typeof msg !== "object") { + continue; + } + const candidate = msg as ToolResultMessage; + if (candidate.role !== "toolResult") { + continue; + } + const text = extractToolResultText(candidate.content); + if (text) { + return text; + } + } + return undefined; +} + +async function readLatestToolResultWithRetry(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 readLatestToolResult(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) { @@ -470,6 +532,13 @@ export async function runSubagentAnnounceFlow(params: { }); } + if (!reply?.trim()) { + reply = await readLatestToolResultWithRetry({ + sessionKey: params.childSessionKey, + maxWaitMs: params.timeoutMs, + }); + } + if (!reply?.trim() && childSessionId && isEmbeddedPiRunActive(childSessionId)) { // Avoid announcing "(no output)" while the child run is still producing output. shouldDeleteChildSession = false; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 7b5ad60fed..ff7c70efde 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -1,8 +1,8 @@ import { Type } from "@sinclair/typebox"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; +import type { AnyAgentTool } from "./common.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { spawnSubagentDirect } from "../subagent-spawn.js"; -import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; const SessionsSpawnToolSchema = Type.Object({ @@ -66,6 +66,7 @@ export function createSessionsSpawnTool(opts?: { thinking: thinkingOverrideRaw, runTimeoutSeconds, cleanup, + expectsCompletionMessage: true, }, { agentSessionKey: opts?.agentSessionKey,