Skip to content

Commit 609b97d

Browse files
committed
improve(skills): make autocapture reactive and turn-scoped
1 parent 7b68306 commit 609b97d

18 files changed

Lines changed: 1338 additions & 121 deletions

docs/tools/skill-workshop.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,12 +230,20 @@ proposals directly instead.
230230

231231
| Setting | Default | Effect |
232232
| -------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
233-
| `autonomous.enabled` | `false` | Lets OpenClaw create pending proposals from durable conversation signals after a successful turn. |
233+
| `autonomous.enabled` | `false` | Captures durable current-turn corrections as pending proposals. |
234234
| `allowSymlinkTargetWrites` | `false` | Lets apply write through workspace skill symlinks whose real target is listed in `skills.load.allowSymlinkTargets`. |
235235
| `approvalPolicy` | `"pending"` | `"pending"` requires an approval prompt before agent-initiated `apply`, `reject`, or `quarantine`. `"auto"` skips the prompt (the agent still has to call the action). |
236236
| `maxPending` | `50` | Caps pending and quarantined proposals per workspace (1-200). |
237237
| `maxSkillBytes` | `40000` | Caps proposal body size in bytes (1024-200000). |
238238

239+
Autonomous capture runs after successful and failed turns. It recognizes
240+
prospective rules ("from now on…") and reactive corrections ("that's not what I
241+
asked"), groups them per topic (up to 3 proposals per turn), routes them to
242+
matching writable workspace skills, and revises its own pending proposal when a
243+
later turn adds another correction. Signal-free turns skip skill discovery. A
244+
turn that already created a workshop proposal, such as `/learn`, is not captured
245+
again.
246+
239247
Proposal descriptions are always capped at 160 bytes, independent of
240248
`maxSkillBytes`.
241249

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
setActiveEmbeddedRun,
3434
supportsModelTools,
3535
runAgentCleanupStep,
36+
type AgentMessage,
3637
type FastModeAutoProgressState,
3738
type EmbeddedRunAttemptParams,
3839
type EmbeddedRunAttemptResult,
@@ -263,6 +264,7 @@ import {
263264
recordCodexTrajectoryContext,
264265
} from "./trajectory.js";
265266
import {
267+
buildResolvedCodexUserPromptMessage,
266268
buildCodexUserPromptMessage,
267269
createCodexAppServerUserMessagePersistenceNotifier,
268270
mirrorPromptAtTurnStartBestEffort,
@@ -415,8 +417,15 @@ async function runCodexAgentEndHook(
415417
params: EmbeddedRunAttemptParams,
416418
hookParams: CodexAgentEndHookParams,
417419
): Promise<void> {
420+
let currentTurnMessage: AgentMessage;
421+
try {
422+
currentTurnMessage = await buildResolvedCodexUserPromptMessage(params);
423+
} catch {
424+
currentTurnMessage = buildCodexUserPromptMessage(params);
425+
}
418426
const sideEffectParams = {
419427
...hookParams,
428+
currentTurnMessages: [currentTurnMessage],
420429
ctx: { ...hookParams.ctx, config: params.config },
421430
};
422431
if (shouldAwaitCodexAgentEndHook(params)) {

extensions/copilot/src/attempt.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ async function finalizeCopilotAttempt(
227227
: {}),
228228
durationMs: now() - attemptStartedAt,
229229
},
230+
currentTurnMessages: buildCopilotAutoCaptureCurrentTurnMessages(params, now),
230231
ctx,
231232
});
232233
return result;
@@ -1545,6 +1546,22 @@ function getMessagesSnapshotInput(params: AttemptParamsLike): AgentMessage[] {
15451546
return Array.isArray(params.messages) ? [...params.messages] : [];
15461547
}
15471548

1549+
function buildCopilotAutoCaptureCurrentTurnMessages(
1550+
params: AttemptParamsLike,
1551+
now: () => number,
1552+
): AgentMessage[] {
1553+
const messages = getMessagesSnapshotInput(params);
1554+
const userText = readString(params.transcriptPrompt) ?? readString(params.prompt);
1555+
if (!userText) {
1556+
return [];
1557+
}
1558+
const tail = messages.at(-1);
1559+
if (tail?.role === "user" && readTailUserText(messages) === userText) {
1560+
return [tail];
1561+
}
1562+
return [{ role: "user", content: userText, timestamp: now() } as AgentMessage];
1563+
}
1564+
15481565
// Returns the trimmed plain-text content of the tail user message in
15491566
// `messages`, if any. Used to skip synthetic-user injection when the
15501567
// caller already passed the current turn's user prompt as the last

packages/gateway-protocol/src/schema/agents-models-skills.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe("SkillsProposalInspectResultSchema", () => {
110110
schema: "openclaw.skill-workshop.proposal.v1",
111111
createdAt: "2026-05-30T00:00:00.000Z",
112112
updatedAt: "2026-05-30T00:00:00.000Z",
113-
createdBy: "skill-workshop",
113+
createdBy: "skill-autocapture",
114114
proposedVersion: "v1",
115115
draftFile: "PROPOSAL.md",
116116
target: {

packages/gateway-protocol/src/schema/agents-models-skills.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,7 @@ const SkillProposalScanStateSchema = Type.Union([
576576
/** Source that created the skill proposal record. */
577577
const SkillProposalSourceSchema = Type.Union([
578578
Type.Literal("skill-workshop"),
579+
Type.Literal("skill-autocapture"),
579580
Type.Literal("cli"),
580581
Type.Literal("gateway"),
581582
]);

src/agents/cli-runner.reliability.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2559,6 +2559,12 @@ describe("runCliAgent reliability", () => {
25592559
expect(resolved).toBe(false);
25602560
expect(mockAutoCapture).toHaveBeenCalledWith(
25612561
expect.objectContaining({
2562+
currentTurnMessages: [
2563+
expect.objectContaining({
2564+
role: "user",
2565+
content: context.params.prompt,
2566+
}),
2567+
],
25622568
ctx: expect.objectContaining({
25632569
agentId: "main",
25642570
sessionKey: "agent:main:main",

src/agents/cli-runner.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,18 @@ function shouldAwaitCliAgentEndHook(params: RunCliAgentParams): boolean {
248248

249249
async function runCliAgentEndHook(
250250
params: RunCliAgentParams,
251-
hookParams: CliAgentEndHookParams,
251+
hookParams: Omit<CliAgentEndHookParams, "currentTurnMessages">,
252252
): Promise<void> {
253+
// The hook event includes session history; auto-capture must only inspect this run's prompt.
254+
const sideEffectParams = {
255+
...hookParams,
256+
currentTurnMessages: [buildCliHookUserMessage(params.prompt)],
257+
};
253258
if (shouldAwaitCliAgentEndHook(params)) {
254-
await awaitAgentEndSideEffects(hookParams);
259+
await awaitAgentEndSideEffects(sideEffectParams);
255260
return;
256261
}
257-
runAgentEndSideEffects(hookParams);
262+
runAgentEndSideEffects(sideEffectParams);
258263
}
259264

260265
async function persistApprovedCliUserTurnTranscript(params: RunCliAgentParams): Promise<boolean> {

src/agents/embedded-agent-runner/run/attempt.context-engine-helpers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ type AttemptBootstrapContext<TBootstrapFile = unknown, TContextFile = unknown> =
2525
contextFiles: TContextFile[];
2626
};
2727

28+
/** Captures the user-owned turn before compaction can rewrite transcript indexes. */
29+
export function buildAutoCaptureCurrentTurnMessages(params: {
30+
prompt: string;
31+
preparedUserTurnMessage?: Extract<AgentMessage, { role: "user" }>;
32+
}): AgentMessage[] {
33+
if (params.preparedUserTurnMessage) {
34+
return [params.preparedUserTurnMessage];
35+
}
36+
return [
37+
{
38+
role: "user",
39+
content: [{ type: "text", text: params.prompt }],
40+
timestamp: Date.now(),
41+
},
42+
];
43+
}
44+
2845
/**
2946
* Resolves bootstrap/context files for this attempt and reports whether the
3047
* caller should persist a completed bootstrap marker. Continuation-skip mode

src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { SubagentRunRecord } from "../../subagent-registry.types.js";
2121
import { makeAgentAssistantMessage } from "../../test-helpers/agent-message-fixtures.js";
2222
import {
2323
type AttemptContextEngine,
24+
buildAutoCaptureCurrentTurnMessages,
2425
buildLoopPromptCacheInfo,
2526
assembleAttemptContextEngine,
2627
buildContextEnginePromptCacheInfo,
@@ -2764,6 +2765,27 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
27642765
expect(buildContextEnginePromptCacheInfo({})).toBeUndefined();
27652766
});
27662767

2768+
it("captures the current user turn without relying on transcript indexes", () => {
2769+
const prepared = {
2770+
role: "user",
2771+
content: [{ type: "text", text: "Stop using stale transcript corrections." }],
2772+
timestamp: 123,
2773+
} as Extract<AgentMessage, { role: "user" }>;
2774+
2775+
expect(
2776+
buildAutoCaptureCurrentTurnMessages({
2777+
prompt: "model prompt",
2778+
preparedUserTurnMessage: prepared,
2779+
}),
2780+
).toEqual([prepared]);
2781+
expect(buildAutoCaptureCurrentTurnMessages({ prompt: "raw correction" })).toMatchObject([
2782+
{
2783+
role: "user",
2784+
content: [{ type: "text", text: "raw correction" }],
2785+
},
2786+
]);
2787+
});
2788+
27672789
it("does not reuse a prior turn's usage when the current attempt has no assistant", () => {
27682790
const priorAssistant = {
27692791
role: "assistant",

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ import {
378378
import { remapInjectedContextFilesToWorkspace } from "./attempt.bootstrap-context.js";
379379
import {
380380
assembleAttemptContextEngine,
381+
buildAutoCaptureCurrentTurnMessages,
381382
buildLoopPromptCacheInfo,
382383
buildContextEnginePromptCacheInfo,
383384
findCurrentAttemptAssistantMessage,
@@ -2161,6 +2162,10 @@ export async function runEmbeddedAttempt(
21612162

21622163
await prewarmSessionFile(params.sessionFile);
21632164
const preparedUserTurnMessage = await params.userTurnTranscriptRecorder?.resolveMessage();
2165+
const currentTurnMessages = buildAutoCaptureCurrentTurnMessages({
2166+
prompt: params.prompt,
2167+
preparedUserTurnMessage,
2168+
});
21642169
sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
21652170
agentId: sessionAgentId,
21662171
sessionKey: params.sessionKey,
@@ -5336,6 +5341,7 @@ export async function runEmbeddedAttempt(
53365341
error: promptError ? formatErrorMessage(promptError) : undefined,
53375342
durationMs: Date.now() - promptStartedAt,
53385343
},
5344+
currentTurnMessages,
53395345
ctx: {
53405346
runId: params.runId,
53415347
trace: freezeDiagnosticTraceContext(diagnosticTrace),

0 commit comments

Comments
 (0)