Skip to content

Commit 8e4035d

Browse files
committed
Agents: add inferred commitments
1 parent 95a1356 commit 8e4035d

24 files changed

Lines changed: 2660 additions & 18 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2229,6 +2229,7 @@ Docs: https://docs.openclaw.ai
22292229
- Memory/active-memory: default QMD recall to search and surface better search-path telemetry so memory-backed recall works more predictably out of the box. (#65068) Thanks @Takhoffman.
22302230
- Docs/providers: expand bundled provider docs with richer capability, env-var, and setup guidance across provider pages.
22312231
- Docs/memory-wiki: add the recommended QMD + bridge-mode hybrid recipe plus zero-artifact troubleshooting guidance for `memory-wiki` bridge setups. (#63165) Thanks @sercada and @vincentkoc.
2232+
- Agents/commitments: add default-on inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
22322233

22332234
### Fixes
22342235

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
b6640810820e0f54631e8006fa35798f84139b162ee472d150994571b730226a config-baseline.json
2-
d63d3aa51c0c38a315cadbff01715844b73ecc35909b6bbb6cd318af59f3d2cc config-baseline.core.json
1+
bc53a2242782d03e6392671c154481fb4cd8dc5b35cc41a69b056d3ead28be97 config-baseline.json
2+
861a230a4e66cb8986270a85f63e857077506a3bc75ec3754dfebd17a3ea9f0c config-baseline.core.json
33
9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json
44
c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json

src/auto-reply/reply/agent-runner.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { resolveModelAuthMode } from "../../agents/model-auth.js";
66
import { isCliProvider } from "../../agents/model-selection.js";
77
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded-runner/runs.js";
88
import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js";
9+
import { enqueueCommitmentExtraction } from "../../commitments/runtime.js";
10+
import type { OpenClawConfig } from "../../config/config.js";
911
import {
1012
loadSessionStore,
1113
resolveSessionPluginStatusLines,
@@ -792,6 +794,71 @@ function buildInlineRawTracePayload(params: {
792794
};
793795
}
794796

797+
function joinCommitmentAssistantText(payloads: ReplyPayload[]): string {
798+
return payloads
799+
.filter((payload) => !payload.isError && !payload.isReasoning && !payload.isCompactionNotice)
800+
.map((payload) => payload.text?.trim())
801+
.filter((text): text is string => Boolean(text))
802+
.join("\n")
803+
.trim();
804+
}
805+
806+
function enqueueCommitmentExtractionForTurn(params: {
807+
cfg: OpenClawConfig;
808+
commandBody: string;
809+
isHeartbeat: boolean;
810+
followupRun: FollowupRun;
811+
sessionCtx: TemplateContext;
812+
sessionKey?: string;
813+
replyToChannel?: string;
814+
payloads: ReplyPayload[];
815+
runId: string;
816+
}): void {
817+
if (params.isHeartbeat) {
818+
return;
819+
}
820+
const userText =
821+
params.commandBody.trim() ||
822+
params.sessionCtx.BodyStripped?.trim() ||
823+
params.sessionCtx.BodyForCommands?.trim() ||
824+
params.sessionCtx.CommandBody?.trim() ||
825+
params.sessionCtx.RawBody?.trim() ||
826+
params.sessionCtx.Body?.trim() ||
827+
"";
828+
const assistantText = joinCommitmentAssistantText(params.payloads);
829+
const sessionKey = params.sessionKey ?? params.followupRun.run.sessionKey;
830+
const channel =
831+
params.replyToChannel ??
832+
params.followupRun.run.messageProvider ??
833+
params.sessionCtx.Surface ??
834+
params.sessionCtx.Provider;
835+
if (!userText || !assistantText || !sessionKey || !channel) {
836+
return;
837+
}
838+
const to = resolveOriginMessageTo({
839+
originatingTo: params.sessionCtx.OriginatingTo,
840+
to: params.sessionCtx.To,
841+
});
842+
enqueueCommitmentExtraction({
843+
cfg: params.cfg,
844+
agentId: params.followupRun.run.agentId,
845+
sessionKey,
846+
channel,
847+
...(params.sessionCtx.AccountId ? { accountId: params.sessionCtx.AccountId } : {}),
848+
...(to ? { to } : {}),
849+
...(params.sessionCtx.MessageThreadId !== undefined
850+
? { threadId: String(params.sessionCtx.MessageThreadId) }
851+
: {}),
852+
...(params.followupRun.run.senderId ? { senderId: params.followupRun.run.senderId } : {}),
853+
userText,
854+
assistantText,
855+
...(params.sessionCtx.MessageSidFull || params.sessionCtx.MessageSid
856+
? { sourceMessageId: params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid }
857+
: {}),
858+
sourceRunId: params.runId,
859+
});
860+
}
861+
795862
function refreshSessionEntryFromStore(params: {
796863
storePath?: string;
797864
sessionKey?: string;
@@ -1378,6 +1445,18 @@ export async function runReplyAgent(params: {
13781445
? appendUnscheduledReminderNote(replyPayloads)
13791446
: replyPayloads;
13801447

1448+
enqueueCommitmentExtractionForTurn({
1449+
cfg,
1450+
commandBody,
1451+
isHeartbeat,
1452+
followupRun,
1453+
sessionCtx,
1454+
sessionKey,
1455+
replyToChannel,
1456+
payloads: replyPayloads,
1457+
runId,
1458+
});
1459+
13811460
await signalTypingIfNeeded(guardedReplyPayloads, typingSignals);
13821461

13831462
if (isDiagnosticsEnabled(cfg) && hasNonzeroUsage(usage)) {

src/cli/program/register.status-health-sessions.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
88
sessionsCommand: vi.fn(),
99
sessionsCleanupCommand: vi.fn(),
1010
exportTrajectoryCommand: vi.fn(),
11+
commitmentsListCommand: vi.fn(),
12+
commitmentsDismissCommand: vi.fn(),
1113
tasksListCommand: vi.fn(),
1214
tasksAuditCommand: vi.fn(),
1315
tasksMaintenanceCommand: vi.fn(),
@@ -30,6 +32,8 @@ const healthCommand = mocks.healthCommand;
3032
const sessionsCommand = mocks.sessionsCommand;
3133
const sessionsCleanupCommand = mocks.sessionsCleanupCommand;
3234
const exportTrajectoryCommand = mocks.exportTrajectoryCommand;
35+
const commitmentsListCommand = mocks.commitmentsListCommand;
36+
const commitmentsDismissCommand = mocks.commitmentsDismissCommand;
3337
const tasksListCommand = mocks.tasksListCommand;
3438
const tasksAuditCommand = mocks.tasksAuditCommand;
3539
const tasksMaintenanceCommand = mocks.tasksMaintenanceCommand;
@@ -62,6 +66,11 @@ vi.mock("../../commands/export-trajectory.js", () => ({
6266
exportTrajectoryCommand: mocks.exportTrajectoryCommand,
6367
}));
6468

69+
vi.mock("../../commands/commitments.js", () => ({
70+
commitmentsListCommand: mocks.commitmentsListCommand,
71+
commitmentsDismissCommand: mocks.commitmentsDismissCommand,
72+
}));
73+
6574
vi.mock("../../commands/tasks.js", () => ({
6675
tasksListCommand: mocks.tasksListCommand,
6776
tasksAuditCommand: mocks.tasksAuditCommand,
@@ -100,6 +109,8 @@ describe("registerStatusHealthSessionsCommands", () => {
100109
sessionsCommand.mockResolvedValue(undefined);
101110
sessionsCleanupCommand.mockResolvedValue(undefined);
102111
exportTrajectoryCommand.mockResolvedValue(undefined);
112+
commitmentsListCommand.mockResolvedValue(undefined);
113+
commitmentsDismissCommand.mockResolvedValue(undefined);
103114
tasksListCommand.mockResolvedValue(undefined);
104115
tasksAuditCommand.mockResolvedValue(undefined);
105116
tasksMaintenanceCommand.mockResolvedValue(undefined);
@@ -406,6 +417,31 @@ describe("registerStatusHealthSessionsCommands", () => {
406417
);
407418
});
408419

420+
it("runs commitments list with filters", async () => {
421+
await runCli(["commitments", "--json", "--agent", "work", "--status", "snoozed"]);
422+
423+
expect(commitmentsListCommand).toHaveBeenCalledWith(
424+
expect.objectContaining({
425+
json: true,
426+
agent: "work",
427+
status: "snoozed",
428+
all: false,
429+
}),
430+
runtime,
431+
);
432+
});
433+
434+
it("runs commitments dismiss with id forwarding", async () => {
435+
await runCli(["commitments", "dismiss", "cm_1", "cm_2"]);
436+
437+
expect(commitmentsDismissCommand).toHaveBeenCalledWith(
438+
expect.objectContaining({
439+
ids: ["cm_1", "cm_2"],
440+
}),
441+
runtime,
442+
);
443+
});
444+
409445
it("does not register the legacy top-level flows command", () => {
410446
const program = new Command();
411447
registerStatusHealthSessionsCommands(program);

src/cli/program/register.status-health-sessions.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Command } from "commander";
2+
import { commitmentsDismissCommand, commitmentsListCommand } from "../../commands/commitments.js";
23
import { exportTrajectoryCommand } from "../../commands/export-trajectory.js";
34
import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "../../commands/flows.js";
45
import { healthCommand } from "../../commands/health.js";
@@ -258,6 +259,79 @@ export function registerStatusHealthSessionsCommands(program: Command) {
258259
});
259260
});
260261

262+
const commitmentsCmd = program
263+
.command("commitments")
264+
.description("List and manage inferred follow-up commitments")
265+
.option("--json", "Output JSON instead of text", false)
266+
.option("--agent <id>", "Agent id to inspect")
267+
.option("--status <status>", "Filter by status (pending, sent, dismissed, snoozed, expired)")
268+
.option("--all", "Show all statuses", false)
269+
.addHelpText(
270+
"after",
271+
() =>
272+
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
273+
["openclaw commitments", "List pending inferred follow-ups."],
274+
["openclaw commitments --all", "List all inferred follow-ups."],
275+
["openclaw commitments --agent work", "List one agent's inferred follow-ups."],
276+
["openclaw commitments dismiss cm_abc123", "Dismiss a follow-up."],
277+
])}`,
278+
)
279+
.action(async (opts) => {
280+
await runCommandWithRuntime(defaultRuntime, async () => {
281+
await commitmentsListCommand(
282+
{
283+
json: Boolean(opts.json),
284+
agent: opts.agent as string | undefined,
285+
status: opts.status as string | undefined,
286+
all: Boolean(opts.all),
287+
},
288+
defaultRuntime,
289+
);
290+
});
291+
});
292+
commitmentsCmd.enablePositionalOptions();
293+
294+
commitmentsCmd
295+
.command("list")
296+
.description("List inferred follow-up commitments")
297+
.option("--json", "Output JSON instead of text", false)
298+
.option("--agent <id>", "Agent id to inspect")
299+
.option("--status <status>", "Filter by status (pending, sent, dismissed, snoozed, expired)")
300+
.option("--all", "Show all statuses", false)
301+
.action(async (opts, command) => {
302+
const parentOpts = command.parent?.opts() as
303+
| { json?: boolean; agent?: string; status?: string; all?: boolean }
304+
| undefined;
305+
await runCommandWithRuntime(defaultRuntime, async () => {
306+
await commitmentsListCommand(
307+
{
308+
json: Boolean(opts.json || parentOpts?.json),
309+
agent: (opts.agent as string | undefined) ?? parentOpts?.agent,
310+
status: (opts.status as string | undefined) ?? parentOpts?.status,
311+
all: Boolean(opts.all || parentOpts?.all),
312+
},
313+
defaultRuntime,
314+
);
315+
});
316+
});
317+
318+
commitmentsCmd
319+
.command("dismiss <ids...>")
320+
.description("Dismiss inferred follow-up commitments")
321+
.option("--json", "Output JSON instead of text", false)
322+
.action(async (ids: string[], opts, command) => {
323+
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
324+
await runCommandWithRuntime(defaultRuntime, async () => {
325+
await commitmentsDismissCommand(
326+
{
327+
ids,
328+
json: Boolean(opts.json || parentOpts?.json),
329+
},
330+
defaultRuntime,
331+
);
332+
});
333+
});
334+
261335
const tasksCmd = program
262336
.command("tasks")
263337
.description("Inspect durable background tasks and TaskFlow state")

0 commit comments

Comments
 (0)