Skip to content

Commit f320771

Browse files
HanchenHanchen
authored andcommitted
feat(memory-core): record terminal task outcomes for dreaming feedback
Add a plugin-sdk seam (task-events) that lets plugins subscribe to the task registry's lifecycle events without owning the singleton observer that tests rely on. Multiple listeners can register in parallel and exceptions inside one listener are swallowed so they cannot break the emit path. Wire memory-core to the seam: when a task transitions into a terminal state (succeeded, failed, timed_out, cancelled, lost), append a JSONL outcome record to <workspace>/memory/.dreams/task-outcomes.jsonl with status, runtime, agent, label, durationMs, and a short summary. Dreaming can pick these up later as candidate "what worked / what failed" entries, closing the missing task -> memory feedback loop. The task-outcomes.jsonl path keeps the new data isolated from the existing MemoryHostEvent log, so the shared schema stays untouched. Thanks @keane-751892
1 parent 82ca6ec commit f320771

9 files changed

Lines changed: 366 additions & 10 deletions

File tree

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
e94362ae9caa948c50ad0dc9a99c801750c9dd24ef687cdbc0e6996cdec1ad2b plugin-sdk-api-baseline.json
2-
83f9fdc048267705b4a5cf5d68860b39bbb00985f3f01dd6d6ba28e12587b997 plugin-sdk-api-baseline.jsonl
1+
851a39b442a4a15e78d27d8a3e1ee66ff61a061356d412051e205f6c07f54c34 plugin-sdk-api-baseline.json
2+
d3106b731a3a13f7dddaa0b1916f223c1757fa8d1df3476914f70502c9532c2f plugin-sdk-api-baseline.jsonl

extensions/memory-core/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-a
1212
import { buildPromptSection } from "./src/prompt-section.js";
1313
import { listMemoryCorePublicArtifacts } from "./src/public-artifacts.js";
1414
import { memoryRuntime } from "./src/runtime-provider.js";
15+
import { registerTaskOutcomeRecorder } from "./src/task-outcome-recorder.js";
1516
import { createMemoryGetTool, createMemorySearchTool } from "./src/tools.js";
1617
export {
1718
buildMemoryFlushPlan,
@@ -30,6 +31,7 @@ export default definePluginEntry({
3031
registerBuiltInMemoryEmbeddingProviders(api);
3132
registerShortTermPromotionDreaming(api);
3233
registerDreamingCommand(api);
34+
registerTaskOutcomeRecorder(api);
3335
api.registerMemoryCapability({
3436
promptBuilder: buildPromptSection,
3537
flushPlanResolver: buildMemoryFlushPlan,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { TaskRecord } from "openclaw/plugin-sdk/task-events";
2+
import { describe, expect, it } from "vitest";
3+
import { __testing } from "./task-outcome-recorder.js";
4+
5+
const { buildOutcomeRecord, shouldRecord, summarizeOutcome } = __testing;
6+
7+
function makeTask(overrides: Partial<TaskRecord>): TaskRecord {
8+
return {
9+
taskId: overrides.taskId ?? "task-1",
10+
runtime: overrides.runtime ?? "subagent",
11+
requesterSessionKey: overrides.requesterSessionKey ?? "session:default",
12+
ownerKey: overrides.ownerKey ?? "owner:default",
13+
scopeKind: overrides.scopeKind ?? "session",
14+
task: overrides.task ?? "do thing",
15+
status: overrides.status ?? "queued",
16+
deliveryStatus: overrides.deliveryStatus ?? "pending",
17+
notifyPolicy: overrides.notifyPolicy ?? "done_only",
18+
createdAt: overrides.createdAt ?? 1_000,
19+
...overrides,
20+
};
21+
}
22+
23+
describe("task-outcome-recorder shouldRecord", () => {
24+
it("emits on first transition into a terminal state", () => {
25+
const event = {
26+
kind: "upserted" as const,
27+
task: makeTask({ status: "failed", error: "boom" }),
28+
previous: makeTask({ status: "running" }),
29+
};
30+
const result = shouldRecord(event);
31+
expect(result?.task.taskId).toBe("task-1");
32+
});
33+
34+
it("ignores non-upserted events", () => {
35+
expect(
36+
shouldRecord({
37+
kind: "deleted",
38+
taskId: "task-1",
39+
previous: makeTask({ status: "succeeded" }),
40+
}),
41+
).toBeNull();
42+
expect(shouldRecord({ kind: "restored", tasks: [makeTask({ status: "failed" })] })).toBeNull();
43+
});
44+
45+
it("ignores upserts that stay in a non-terminal state", () => {
46+
expect(
47+
shouldRecord({
48+
kind: "upserted",
49+
task: makeTask({ status: "running" }),
50+
previous: makeTask({ status: "queued" }),
51+
}),
52+
).toBeNull();
53+
});
54+
55+
it("ignores re-emissions of an already-terminal task", () => {
56+
expect(
57+
shouldRecord({
58+
kind: "upserted",
59+
task: makeTask({ status: "failed" }),
60+
previous: makeTask({ status: "failed" }),
61+
}),
62+
).toBeNull();
63+
});
64+
});
65+
66+
describe("task-outcome-recorder summarizeOutcome", () => {
67+
it("uses label when present", () => {
68+
expect(summarizeOutcome(makeTask({ status: "succeeded", label: "Build prod bundle" }))).toBe(
69+
"Task succeeded: Build prod bundle",
70+
);
71+
});
72+
73+
it("falls back to first line of task body", () => {
74+
expect(
75+
summarizeOutcome(
76+
makeTask({ status: "failed", task: "send slack message\n to: #ops", error: "401" }),
77+
),
78+
).toBe("Task failed: send slack message — 401");
79+
});
80+
81+
it("truncates oversized labels", () => {
82+
const long = "x".repeat(500);
83+
const out = summarizeOutcome(makeTask({ status: "succeeded", label: long }));
84+
expect(out.length).toBeLessThan(300);
85+
expect(out.startsWith("Task succeeded: ")).toBe(true);
86+
expect(out.endsWith("…")).toBe(true);
87+
});
88+
});
89+
90+
describe("task-outcome-recorder buildOutcomeRecord", () => {
91+
it("emits a JSON-serializable record with timestamp and durationMs", () => {
92+
const task = makeTask({
93+
taskId: "abc",
94+
status: "succeeded",
95+
runtime: "cli",
96+
agentId: "main",
97+
taskKind: "build",
98+
label: "Build prod bundle",
99+
startedAt: 1_000,
100+
endedAt: 4_500,
101+
});
102+
const record = buildOutcomeRecord(task, 5_000);
103+
expect(record).toMatchObject({
104+
type: "task.outcome",
105+
taskId: "abc",
106+
status: "succeeded",
107+
runtime: "cli",
108+
agentId: "main",
109+
taskKind: "build",
110+
label: "Build prod bundle",
111+
durationMs: 3_500,
112+
});
113+
expect(typeof record.timestamp).toBe("string");
114+
expect(record.summary).toContain("Build prod bundle");
115+
});
116+
117+
it("omits agentId/taskKind/label when missing", () => {
118+
const task = makeTask({ status: "lost" });
119+
const record = buildOutcomeRecord(task, 5_000);
120+
expect(record.agentId).toBeUndefined();
121+
expect(record.taskKind).toBeUndefined();
122+
expect(record.label).toBeUndefined();
123+
expect(record.durationMs).toBeUndefined();
124+
});
125+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { promises as fs } from "node:fs";
2+
import path from "node:path";
3+
import {
4+
resolveAgentWorkspaceDir,
5+
resolveDefaultAgentId,
6+
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
7+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
8+
import {
9+
addTaskRegistryEventListener,
10+
type TaskRecord,
11+
type TaskRegistryObserverEvent,
12+
type TaskStatus,
13+
} from "openclaw/plugin-sdk/task-events";
14+
15+
const TASK_OUTCOME_LOG_RELATIVE_PATH = path.join("memory", ".dreams", "task-outcomes.jsonl");
16+
17+
const TERMINAL_STATUSES = new Set<TaskStatus>([
18+
"succeeded",
19+
"failed",
20+
"timed_out",
21+
"cancelled",
22+
"lost",
23+
]);
24+
25+
type TaskOutcomeRecord = {
26+
type: "task.outcome";
27+
timestamp: string;
28+
taskId: string;
29+
status: TaskStatus;
30+
runtime: TaskRecord["runtime"];
31+
agentId?: string;
32+
taskKind?: string;
33+
label?: string;
34+
summary: string;
35+
durationMs?: number;
36+
error?: string;
37+
};
38+
39+
const MAX_LABEL_LENGTH = 240;
40+
const MAX_ERROR_LENGTH = 480;
41+
42+
function shouldRecord(
43+
event: TaskRegistryObserverEvent,
44+
): { task: TaskRecord; previous?: TaskRecord } | null {
45+
if (event.kind !== "upserted") {
46+
return null;
47+
}
48+
if (!TERMINAL_STATUSES.has(event.task.status)) {
49+
return null;
50+
}
51+
// Only record state transitions; if the task was already terminal we have
52+
// already logged the outcome on the original event.
53+
if (event.previous && TERMINAL_STATUSES.has(event.previous.status)) {
54+
return null;
55+
}
56+
return { task: event.task, previous: event.previous };
57+
}
58+
59+
function truncate(value: string | undefined, max: number): string | undefined {
60+
if (!value) {
61+
return undefined;
62+
}
63+
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
64+
}
65+
66+
function summarizeOutcome(task: TaskRecord): string {
67+
const label =
68+
truncate(task.label?.trim(), MAX_LABEL_LENGTH) ??
69+
truncate(task.task.split("\n", 1)[0]?.trim(), MAX_LABEL_LENGTH) ??
70+
task.taskId;
71+
if (task.status === "succeeded") {
72+
const trailing = task.terminalSummary ? ` — ${truncate(task.terminalSummary, 240)}` : "";
73+
return `Task succeeded: ${label}${trailing}`;
74+
}
75+
const error = task.error ? ` — ${truncate(task.error, MAX_ERROR_LENGTH)}` : "";
76+
return `Task ${task.status}: ${label}${error}`;
77+
}
78+
79+
function buildOutcomeRecord(task: TaskRecord, nowMs: number): TaskOutcomeRecord {
80+
return {
81+
type: "task.outcome",
82+
timestamp: new Date(nowMs).toISOString(),
83+
taskId: task.taskId,
84+
status: task.status,
85+
runtime: task.runtime,
86+
...(task.agentId ? { agentId: task.agentId } : {}),
87+
...(task.taskKind ? { taskKind: task.taskKind } : {}),
88+
...(task.label ? { label: truncate(task.label, MAX_LABEL_LENGTH) } : {}),
89+
summary: summarizeOutcome(task),
90+
...(task.startedAt && task.endedAt
91+
? { durationMs: Math.max(0, task.endedAt - task.startedAt) }
92+
: {}),
93+
...(task.error ? { error: truncate(task.error, MAX_ERROR_LENGTH) } : {}),
94+
};
95+
}
96+
97+
async function appendTaskOutcomeRecord(
98+
workspaceDir: string,
99+
record: TaskOutcomeRecord,
100+
): Promise<void> {
101+
const target = path.join(workspaceDir, TASK_OUTCOME_LOG_RELATIVE_PATH);
102+
await fs.mkdir(path.dirname(target), { recursive: true });
103+
await fs.appendFile(target, `${JSON.stringify(record)}\n`, "utf8");
104+
}
105+
106+
/**
107+
* Record terminal task outcomes (succeeded, failed, timed_out, cancelled, lost)
108+
* to the agent's workspace as JSONL events. Downstream dreaming can read these
109+
* to grow MEMORY entries about what worked and what didn't, closing the
110+
* task → memory feedback loop.
111+
*
112+
* Returns an unsubscribe function so the listener can be cleaned up in tests
113+
* or during plugin reloads.
114+
*/
115+
export function registerTaskOutcomeRecorder(api: OpenClawPluginApi): () => void {
116+
const log = api.logger;
117+
const unsubscribe = addTaskRegistryEventListener((event) => {
118+
const recordable = shouldRecord(event);
119+
if (!recordable) {
120+
return;
121+
}
122+
const { task } = recordable;
123+
const agentId = task.agentId ?? resolveDefaultAgentId(api.config);
124+
if (!agentId) {
125+
return;
126+
}
127+
let workspaceDir: string;
128+
try {
129+
workspaceDir = resolveAgentWorkspaceDir(api.config, agentId);
130+
} catch (error) {
131+
log.debug?.(
132+
`task-outcome-recorder: workspace resolution failed for agent ${agentId}: ${String(error)}`,
133+
);
134+
return;
135+
}
136+
if (!workspaceDir) {
137+
return;
138+
}
139+
const record = buildOutcomeRecord(task, Date.now());
140+
void appendTaskOutcomeRecord(workspaceDir, record).catch((error: unknown) => {
141+
log.debug?.(`task-outcome-recorder: append failed for ${task.taskId}: ${String(error)}`);
142+
});
143+
});
144+
return unsubscribe;
145+
}
146+
147+
export const __testing = {
148+
buildOutcomeRecord,
149+
shouldRecord,
150+
summarizeOutcome,
151+
TASK_OUTCOME_LOG_RELATIVE_PATH,
152+
};

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,10 @@
12061206
"types": "./dist/plugin-sdk/text-autolink-runtime.d.ts",
12071207
"default": "./dist/plugin-sdk/text-autolink-runtime.js"
12081208
},
1209+
"./plugin-sdk/task-events": {
1210+
"types": "./dist/plugin-sdk/task-events.d.ts",
1211+
"default": "./dist/plugin-sdk/task-events.js"
1212+
},
12091213
"./plugin-sdk/tool-payload": {
12101214
"types": "./dist/plugin-sdk/tool-payload.d.ts",
12111215
"default": "./dist/plugin-sdk/tool-payload.js"

scripts/lib/plugin-sdk-entrypoints.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@
284284
"telegram-account",
285285
"telegram-command-config",
286286
"text-autolink-runtime",
287+
"task-events",
287288
"tool-payload",
288289
"tool-send",
289290
"webhook-ingress",

src/plugin-sdk/task-events.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Plugin SDK seam for subscribing to task registry lifecycle events.
3+
*
4+
* Use `addTaskRegistryEventListener` to react to task transitions (queued,
5+
* running, terminal). Listeners run inside the registry's emit path, so they
6+
* must be quick and non-blocking — schedule any heavy work onto a microtask
7+
* or background queue. Exceptions thrown from a listener are swallowed so
8+
* one consumer cannot break the registry's notification path.
9+
*
10+
* The seam intentionally mirrors the internal observer event shape so that
11+
* plugins do not need to keep up with the singleton observer wiring used by
12+
* tests.
13+
*/
14+
export {
15+
addTaskRegistryEventListener,
16+
type TaskRegistryObserverEvent,
17+
} from "../tasks/task-registry.store.js";
18+
export type {
19+
TaskDeliveryStatus,
20+
TaskEventKind,
21+
TaskNotifyPolicy,
22+
TaskRecord,
23+
TaskRuntime,
24+
TaskScopeKind,
25+
TaskStatus,
26+
TaskTerminalOutcome,
27+
} from "../tasks/task-registry.types.js";

0 commit comments

Comments
 (0)