Skip to content

Commit 4f3665d

Browse files
anyechClawsistant
authored andcommitted
Expose subagent final metadata in ended hook
Add privacy-minimal metadata to subagent_ended hook events when a frozen child final result is available. Include only availability, hash, byte length, and capture timestamp so hook consumers can correlate final-ready completion without receiving raw final text. Document and test that the digest covers the exact UTF-8 bytes of frozenResultText for non-blank values.
1 parent 3f045d9 commit 4f3665d

4 files changed

Lines changed: 137 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ Docs: https://docs.openclaw.ai
423423
- Agents/runtime: reuse the startup-loaded plugin registry for request-time providers, tools, channel actions, web/capability/memory/migration helpers, and memoized provider extra-params so stable embedded-run inputs no longer repeat plugin registry resolution while model-specific transport hook patches stay isolated. Thanks @DmitryPogodaev.
424424
- Agents/runtime: memoize transcript replay-policy resolution for stable config and process-env runs while preserving custom-env provider hook behavior. Thanks @DmitryPogodaev.
425425
- Infra/path-guards: add a fast path for canonical absolute POSIX containment checks, avoiding repeated `path.resolve` and `path.relative` work in hot filesystem walkers. Refs #75895, #75575, and #68782. Thanks @Enderfga.
426+
- Subagents/hooks: expose privacy-minimal frozen final metadata on `subagent_ended` hook events so plugins can correlate final-ready completions without receiving raw final text. Thanks @anyech.
426427
- Tools: add a platform-level tool descriptor planner for descriptor-first visibility, generic availability checks, and executor references. Thanks @shakkernerd.
427428
- Plugins/tools: cache plugin tool descriptors captured from `api.registerTool(...)` so repeated prompt-time planning can skip plugin runtime loading while execution still loads the live plugin tool. (#76079) Thanks @shakkernerd.
428429
- Docs/Codex: clarify that ChatGPT/Codex subscription setups should use `openai/gpt-*` with `agentRuntime.id: "codex"` for native Codex runtime, while `openai-codex/*` remains the PI OAuth route. Thanks @pashpashpash.

src/agents/subagent-registry-completion.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
23
import { SUBAGENT_ENDED_REASON_COMPLETE } from "./subagent-lifecycle-events.js";
34
import type { SubagentRunRecord } from "./subagent-registry.types.js";
@@ -40,6 +41,24 @@ describe("emitSubagentEndedHookOnce", () => {
4041
};
4142
};
4243

44+
const readLastSubagentEndedEvent = () => {
45+
const calls = lifecycleMocks.runSubagentEnded.mock.calls as unknown as Array<
46+
[
47+
{
48+
final?: {
49+
frozenResultTextAvailable: true;
50+
textSha256: string;
51+
byteLength: number;
52+
capturedAt?: number;
53+
};
54+
},
55+
]
56+
>;
57+
const event = calls.at(-1)?.[0];
58+
expect(event).toBeDefined();
59+
return event!;
60+
};
61+
4362
beforeAll(async () => {
4463
mod = await import("./subagent-registry-completion.js");
4564
});
@@ -112,6 +131,82 @@ describe("emitSubagentEndedHookOnce", () => {
112131
expect(params.persist).toHaveBeenCalledTimes(1);
113132
});
114133

134+
it("includes privacy-minimal frozen final metadata on subagent_ended", async () => {
135+
lifecycleMocks.getGlobalHookRunner.mockReturnValue({
136+
hasHooks: () => true,
137+
runSubagentEnded: lifecycleMocks.runSubagentEnded,
138+
});
139+
140+
const finalText = " child final answer 雪🚀 \n";
141+
const capturedAt = Date.now() - 100;
142+
const entry = {
143+
...createRunEntry(),
144+
frozenResultText: finalText,
145+
frozenResultCapturedAt: capturedAt,
146+
};
147+
const params = createEmitParams({ entry });
148+
const emitted = await mod.emitSubagentEndedHookOnce(params);
149+
150+
expect(emitted).toBe(true);
151+
expect(lifecycleMocks.runSubagentEnded).toHaveBeenCalledTimes(1);
152+
const event = readLastSubagentEndedEvent();
153+
expect(event).toMatchObject({
154+
final: {
155+
frozenResultTextAvailable: true,
156+
textSha256: createHash("sha256").update(finalText, "utf8").digest("hex"),
157+
byteLength: Buffer.byteLength(finalText, "utf8"),
158+
capturedAt,
159+
},
160+
});
161+
expect(event?.final?.textSha256).not.toBe(
162+
createHash("sha256").update(finalText.trim(), "utf8").digest("hex"),
163+
);
164+
expect(JSON.stringify(event)).not.toContain(finalText);
165+
});
166+
167+
it("includes frozen final metadata when capture timestamp is missing", async () => {
168+
lifecycleMocks.getGlobalHookRunner.mockReturnValue({
169+
hasHooks: () => true,
170+
runSubagentEnded: lifecycleMocks.runSubagentEnded,
171+
});
172+
173+
const finalText = "final without timestamp";
174+
const entry = {
175+
...createRunEntry(),
176+
frozenResultText: finalText,
177+
};
178+
const params = createEmitParams({ entry });
179+
const emitted = await mod.emitSubagentEndedHookOnce(params);
180+
181+
expect(emitted).toBe(true);
182+
const event = readLastSubagentEndedEvent();
183+
expect(event.final).toEqual({
184+
frozenResultTextAvailable: true,
185+
textSha256: createHash("sha256").update(finalText, "utf8").digest("hex"),
186+
byteLength: Buffer.byteLength(finalText, "utf8"),
187+
});
188+
});
189+
190+
it("omits frozen final metadata when no useful final text was captured", async () => {
191+
lifecycleMocks.getGlobalHookRunner.mockReturnValue({
192+
hasHooks: () => true,
193+
runSubagentEnded: lifecycleMocks.runSubagentEnded,
194+
});
195+
196+
const params = createEmitParams({
197+
entry: {
198+
...createRunEntry(),
199+
frozenResultText: " ",
200+
frozenResultCapturedAt: Date.now(),
201+
},
202+
});
203+
const emitted = await mod.emitSubagentEndedHookOnce(params);
204+
205+
expect(emitted).toBe(true);
206+
const event = readLastSubagentEndedEvent();
207+
expect(event.final).toBeUndefined();
208+
});
209+
115210
it("returns false when the global hook runner is not initialized yet", async () => {
116211
lifecycleMocks.getGlobalHookRunner.mockReturnValue(null);
117212

src/agents/subagent-registry-completion.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from "node:crypto";
12
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
23
import type { SubagentRunOutcome } from "./subagent-announce-output.js";
34
import {
@@ -63,6 +64,29 @@ export function resolveLifecycleOutcomeFromRunOutcome(
6364
return SUBAGENT_ENDED_OUTCOME_OK;
6465
}
6566

67+
/**
68+
* Build privacy-minimal metadata for the exact frozen child final string.
69+
*
70+
* Blank/whitespace-only finals are omitted, but non-blank values are not
71+
* trimmed or otherwise canonicalized before hashing. `textSha256` is SHA-256
72+
* over the exact UTF-8 bytes of `entry.frozenResultText`, and `byteLength` is
73+
* that same UTF-8 byte count.
74+
*/
75+
export function buildSubagentEndedFinalMetadata(entry: SubagentRunRecord) {
76+
const text = typeof entry.frozenResultText === "string" ? entry.frozenResultText : undefined;
77+
if (!text?.trim()) {
78+
return undefined;
79+
}
80+
return {
81+
frozenResultTextAvailable: true as const,
82+
textSha256: createHash("sha256").update(text, "utf8").digest("hex"),
83+
byteLength: Buffer.byteLength(text, "utf8"),
84+
...(entry.frozenResultCapturedAt === undefined
85+
? {}
86+
: { capturedAt: entry.frozenResultCapturedAt }),
87+
};
88+
}
89+
6690
export async function emitSubagentEndedHookOnce(params: {
6791
entry: SubagentRunRecord;
6892
reason: SubagentLifecycleEndedReason;
@@ -102,6 +126,7 @@ export async function emitSubagentEndedHookOnce(params: {
102126
endedAt: params.entry.endedAt,
103127
outcome: params.outcome,
104128
error: params.error,
129+
final: buildSubagentEndedFinalMetadata(params.entry),
105130
},
106131
{
107132
runId: params.entry.runId,

src/plugins/hook-types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,22 @@ export type PluginHookSubagentEndedEvent = {
582582
endedAt?: number;
583583
outcome?: "ok" | "error" | "timeout" | "killed" | "reset" | "deleted";
584584
error?: string;
585+
/**
586+
* Privacy-minimal metadata about the frozen child final result, when one was
587+
* captured before the lifecycle hook emitted. Raw final text is intentionally
588+
* not included; consumers that need content must resolve it from an
589+
* explicitly authorized store/contract.
590+
*
591+
* The digest and byte length are computed from the exact UTF-8 bytes of the
592+
* stored frozen final string. Non-blank values are not trimmed or otherwise
593+
* canonicalized before hashing.
594+
*/
595+
final?: {
596+
frozenResultTextAvailable: true;
597+
textSha256: string;
598+
byteLength: number;
599+
capturedAt?: number;
600+
};
585601
};
586602

587603
export type PluginHookGatewayContext = {

0 commit comments

Comments
 (0)