mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:48:39 +00:00
fix(subagents): return completion message for manual session spawns
This commit is contained in:
@@ -169,6 +169,7 @@ Behavior:
|
|||||||
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
- Sub-agents are not allowed to call `sessions_spawn` (no sub-agent → sub-agent spawning).
|
||||||
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
- 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.
|
- 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.
|
- 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).
|
- 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).
|
- Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
|
||||||
|
|||||||
@@ -475,6 +475,8 @@ Notes:
|
|||||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
- `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.
|
- 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.
|
- `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_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).
|
- `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.
|
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
|
||||||
|
|||||||
@@ -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)
|
- `/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)
|
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/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 <id|#|all>` (immediately abort one or all running sub-agents for this session; no confirmation message)
|
- `/kill <id|#|all>` (immediately abort one or all running sub-agents for this session; no confirmation message)
|
||||||
- `/steer <id|#> <message>` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message)
|
- `/steer <id|#> <message>` (steer a running sub-agent immediately: in-run when possible, otherwise abort current work and restart on the steer message)
|
||||||
- `/tell <id|#> <message>` (alias for `/steer`)
|
- `/tell <id|#> <message>` (alias for `/steer`)
|
||||||
|
|||||||
@@ -19,9 +19,24 @@ Use `/subagents` to inspect or control sub-agent runs for the **current session*
|
|||||||
- `/subagents log <id|#> [limit] [tools]`
|
- `/subagents log <id|#> [limit] [tools]`
|
||||||
- `/subagents info <id|#>`
|
- `/subagents info <id|#>`
|
||||||
- `/subagents send <id|#> <message>`
|
- `/subagents send <id|#> <message>`
|
||||||
|
- `/subagents steer <id|#> <message>`
|
||||||
|
- `/subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]`
|
||||||
|
|
||||||
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
|
`/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:
|
Primary goals:
|
||||||
|
|
||||||
- Parallelize "research / long task / slow tool" work without blocking the main run.
|
- Parallelize "research / long task / slow tool" work without blocking the main run.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ x-i18n:
|
|||||||
- `/approve <id> allow-once|allow-always|deny`(解决 exec 审批提示)
|
- `/approve <id> allow-once|allow-always|deny`(解决 exec 审批提示)
|
||||||
- `/context [list|detail|json]`(解释"上下文";`detail` 显示每个文件 + 每个工具 + 每个 Skill + 系统提示词大小)
|
- `/context [list|detail|json]`(解释"上下文";`detail` 显示每个文件 + 每个工具 + 每个 Skill + 系统提示词大小)
|
||||||
- `/whoami`(显示你的发送者 ID;别名:`/id`)
|
- `/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`)
|
- `/config show|get|set|unset`(将配置持久化到磁盘,仅所有者;需要 `commands.config: true`)
|
||||||
- `/debug show|set|unset|reset`(运行时覆盖,仅所有者;需要 `commands.debug: true`)
|
- `/debug show|set|unset|reset`(运行时覆盖,仅所有者;需要 `commands.debug: true`)
|
||||||
- `/usage off|tokens|full|cost`(每响应使用量页脚或本地成本摘要)
|
- `/usage off|tokens|full|cost`(每响应使用量页脚或本地成本摘要)
|
||||||
|
|||||||
@@ -22,13 +22,24 @@ x-i18n:
|
|||||||
使用 `/subagents` 检查或控制**当前会话**的子智能体运行:
|
使用 `/subagents` 检查或控制**当前会话**的子智能体运行:
|
||||||
|
|
||||||
- `/subagents list`
|
- `/subagents list`
|
||||||
- `/subagents stop <id|#|all>`
|
- `/subagents kill <id|#|all>`
|
||||||
- `/subagents log <id|#> [limit] [tools]`
|
- `/subagents log <id|#> [limit] [tools]`
|
||||||
- `/subagents info <id|#>`
|
- `/subagents info <id|#>`
|
||||||
- `/subagents send <id|#> <message>`
|
- `/subagents send <id|#> <message>`
|
||||||
|
- `/subagents steer <id|#> <message>`
|
||||||
|
- `/subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]`
|
||||||
|
|
||||||
`/subagents info` 显示运行元数据(状态、时间戳、会话 id、转录路径、清理)。
|
`/subagents info` 显示运行元数据(状态、时间戳、会话 id、转录路径、清理)。
|
||||||
|
|
||||||
|
### 启动行为
|
||||||
|
|
||||||
|
`/subagents spawn` 以用户命令方式启动后台子智能体,任务完成后会向请求者聊天频道回发一条最终完成消息。
|
||||||
|
|
||||||
|
- 该命令非阻塞,先返回 `runId`。
|
||||||
|
- 完成后,子智能体会将汇总/结果消息发布到请求者聊天渠道。
|
||||||
|
- `--model` 与 `--thinking` 可仅对本次运行做覆盖设置。
|
||||||
|
- 可在完成后通过 `info`/`log` 查看详细信息和输出。
|
||||||
|
|
||||||
主要目标:
|
主要目标:
|
||||||
|
|
||||||
- 并行化"研究 / 长任务 / 慢工具"工作,而不阻塞主运行。
|
- 并行化"研究 / 长任务 / 慢工具"工作,而不阻塞主运行。
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const subagentRegistryMock = {
|
|||||||
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
|
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
|
||||||
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
|
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
|
||||||
};
|
};
|
||||||
|
const chatHistoryMock = vi.fn(async (_sessionKey: string) => ({ messages: [] as Array<unknown> }));
|
||||||
let sessionStore: Record<string, Record<string, unknown>> = {};
|
let sessionStore: Record<string, Record<string, unknown>> = {};
|
||||||
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
|
||||||
session: {
|
session: {
|
||||||
@@ -66,6 +67,9 @@ vi.mock("../gateway/call.js", () => ({
|
|||||||
if (typed.method === "agent.wait") {
|
if (typed.method === "agent.wait") {
|
||||||
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
|
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") {
|
if (typed.method === "sessions.patch") {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -114,6 +118,7 @@ describe("subagent announce formatting", () => {
|
|||||||
subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0);
|
subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0);
|
||||||
subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null);
|
subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null);
|
||||||
readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply");
|
readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply");
|
||||||
|
chatHistoryMock.mockReset().mockResolvedValue({ messages: [] });
|
||||||
sessionStore = {};
|
sessionStore = {};
|
||||||
configOverride = {
|
configOverride = {
|
||||||
session: {
|
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 () => {
|
it("keeps full findings and includes compact stats", async () => {
|
||||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||||
sessionStore = {
|
sessionStore = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
import { normalizeMainKey } from "../routing/session-key.js";
|
import { normalizeMainKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { extractTextFromChatContent } from "../shared/chat-content.js";
|
||||||
import {
|
import {
|
||||||
type DeliveryContext,
|
type DeliveryContext,
|
||||||
deliveryContextFromSession,
|
deliveryContextFromSession,
|
||||||
@@ -29,6 +30,67 @@ import {
|
|||||||
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
||||||
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
|
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
|
||||||
import { readLatestAssistantReply } from "./tools/agent-step.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<string | undefined> {
|
||||||
|
const history = await callGateway<{ messages?: Array<unknown> }>({
|
||||||
|
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<string | undefined> {
|
||||||
|
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) {
|
function formatDurationShort(valueMs?: number) {
|
||||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
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)) {
|
if (!reply?.trim() && childSessionId && isEmbeddedPiRunActive(childSessionId)) {
|
||||||
// Avoid announcing "(no output)" while the child run is still producing output.
|
// Avoid announcing "(no output)" while the child run is still producing output.
|
||||||
shouldDeleteChildSession = false;
|
shouldDeleteChildSession = false;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||||
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { optionalStringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum } from "../schema/typebox.js";
|
||||||
import { spawnSubagentDirect } from "../subagent-spawn.js";
|
import { spawnSubagentDirect } from "../subagent-spawn.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
|
|
||||||
const SessionsSpawnToolSchema = Type.Object({
|
const SessionsSpawnToolSchema = Type.Object({
|
||||||
@@ -66,6 +66,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
thinking: thinkingOverrideRaw,
|
thinking: thinkingOverrideRaw,
|
||||||
runTimeoutSeconds,
|
runTimeoutSeconds,
|
||||||
cleanup,
|
cleanup,
|
||||||
|
expectsCompletionMessage: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
agentSessionKey: opts?.agentSessionKey,
|
agentSessionKey: opts?.agentSessionKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user