principles-disciple 1.93.0 → 1.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/pain.ts +23 -2
- package/src/hooks/after-tool-call-helpers.ts +577 -0
- package/src/hooks/after-tool-call-types.ts +105 -0
- package/src/hooks/pain.ts +176 -482
- package/src/hooks/trajectory-evidence.ts +75 -0
- package/tests/commands/pain.test.ts +180 -1
- package/tests/hooks/pain.test.ts +225 -0
package/src/hooks/pain.ts
CHANGED
|
@@ -1,43 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pain Hook — PRI-326 decomposed
|
|
3
|
+
*
|
|
4
|
+
* After-tool-call hook that captures tool failures and emits pain signals.
|
|
5
|
+
*
|
|
6
|
+
* Pipeline stages (delegated to after-tool-call-helpers):
|
|
7
|
+
* 1. classifyToolCallOutcome — determine failure/success + source
|
|
8
|
+
* 2. buildToolCallObservation — normalize event into observation
|
|
9
|
+
* 3. handleFrictionTracking — GFI, event log, trajectory recording
|
|
10
|
+
* 4. handleProbationFeedback — probation attribution + cleanup
|
|
11
|
+
* 5. evaluatePainAdmission — triage + gate evaluation
|
|
12
|
+
* 6. emitPainIfAdmitted — pain signal emission
|
|
13
|
+
*
|
|
14
|
+
* The manual pain path (toolName === 'pain') remains inline because it
|
|
15
|
+
* has a different control flow (early return, no triage, no gate on cooldown).
|
|
16
|
+
*/
|
|
17
|
+
|
|
1
18
|
import * as fs from 'fs';
|
|
2
|
-
import { isRisky, normalizePath } from '../utils/io.js';
|
|
3
19
|
import { normalizeProfile } from '../core/profile.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { denoiseError, computeHash } from '../utils/hashing.js';
|
|
20
|
+
import { getSession, trackFriction } from '../core/session-tracker.js';
|
|
21
|
+
import { computeHash } from '../utils/hashing.js';
|
|
7
22
|
import { SystemLogger } from '../core/system-logger.js';
|
|
8
23
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
9
24
|
import { getEvolutionLogger, createTraceId } from '../core/evolution-logger.js';
|
|
10
|
-
import { recordEvolutionSuccess, recordEvolutionFailure } from '../core/evolution-engine.js';
|
|
11
25
|
import type { EvolutionLoopEvent } from '../core/evolution-types.js';
|
|
12
26
|
import type { PluginHookAfterToolCallEvent, PluginHookToolContext, OpenClawPluginApi } from '../openclaw-sdk.js';
|
|
13
27
|
import { resolveWorkspaceDirForRuntimeV2 } from '../utils/workspace-resolver.js';
|
|
14
|
-
import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData
|
|
28
|
+
import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData } from '@principles/core/runtime-v2';
|
|
15
29
|
import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
|
|
16
|
-
import {
|
|
17
|
-
import { loadPdConfigForPlugin, loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
|
|
18
|
-
import { resolveSourceKindFromToolFailure, evaluateEvidenceTriage } from './triage-adapter.js';
|
|
30
|
+
import { loadPdConfigForPlugin } from '../core/pd-config-loader.js';
|
|
19
31
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
query?: string;
|
|
31
|
-
input?: string;
|
|
32
|
-
arguments?: string;
|
|
33
|
-
}
|
|
32
|
+
import {
|
|
33
|
+
classifyToolCallOutcome,
|
|
34
|
+
buildToolCallObservation,
|
|
35
|
+
handleFrictionTrackingForFailure,
|
|
36
|
+
handleFrictionTrackingForSuccess,
|
|
37
|
+
recordHygieneTracking,
|
|
38
|
+
handleProbationFeedback,
|
|
39
|
+
evaluatePainAdmissionForToolCall,
|
|
40
|
+
emitPainIfAdmitted,
|
|
41
|
+
} from './after-tool-call-helpers.js';
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
import { buildTrajectoryEvidence } from './trajectory-evidence.js';
|
|
44
|
+
export { buildTrajectoryEvidence };
|
|
45
|
+
|
|
46
|
+
// ── Service Factory ─────────────────────────────────────────────────────────
|
|
36
47
|
|
|
37
48
|
function createPainToPrincipleService(wctx: WorkspaceContext): PainToPrincipleService {
|
|
38
49
|
const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir: wctx.stateDir });
|
|
39
|
-
// PRI-306: Load .pd/config.yaml and pass effectiveConfig to PainToPrincipleService
|
|
40
|
-
// so config-driven runtime binding resolution is used.
|
|
41
50
|
const configResult = loadPdConfigForPlugin(wctx.workspaceDir);
|
|
42
51
|
return new PainToPrincipleService({
|
|
43
52
|
workspaceDir: wctx.workspaceDir,
|
|
@@ -50,64 +59,9 @@ function createPainToPrincipleService(wctx: WorkspaceContext): PainToPrincipleSe
|
|
|
50
59
|
});
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
|
|
54
|
-
const evidence: PainEvidenceEntry[] = [];
|
|
62
|
+
// buildTrajectoryEvidence is in ./trajectory-evidence.ts (re-exported above)
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
evidence.push({
|
|
58
|
-
sourceRef: 'owner_message:unavailable',
|
|
59
|
-
note: `trajectory_unavailable: ${!wctx.trajectory ? 'no_trajectory_db' : 'unknown_session'}`,
|
|
60
|
-
});
|
|
61
|
-
return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const userTurns = wctx.trajectory.listUserTurnsForSession(sessionId) ?? [];
|
|
66
|
-
const lastCorrectionTurn = [...userTurns].reverse().find(t => t.correctionDetected);
|
|
67
|
-
if (lastCorrectionTurn) {
|
|
68
|
-
const sanitizedOwnerMessage = sanitizeAssistantText(
|
|
69
|
-
(lastCorrectionTurn.rawExcerpt ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
|
|
70
|
-
);
|
|
71
|
-
evidence.push({
|
|
72
|
-
sourceRef: `owner_message:${lastCorrectionTurn.createdAt}`,
|
|
73
|
-
note: sanitizedOwnerMessage,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
} catch (e) {
|
|
77
|
-
evidence.push({
|
|
78
|
-
sourceRef: 'owner_message:unavailable',
|
|
79
|
-
note: `trajectory_user_turns_unavailable: ${String(e).slice(0, 100)}`,
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
const assistantTurns = wctx.trajectory.listAssistantTurns(sessionId) ?? [];
|
|
85
|
-
const recentAssistant = assistantTurns.slice(-3);
|
|
86
|
-
for (const turn of recentAssistant) {
|
|
87
|
-
if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
|
|
88
|
-
const sanitizedNote = sanitizeAssistantText(
|
|
89
|
-
(turn.sanitizedText ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
|
|
90
|
-
);
|
|
91
|
-
evidence.push({
|
|
92
|
-
sourceRef: `agent_turn:${turn.createdAt}`,
|
|
93
|
-
note: sanitizedNote,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
} catch (e) {
|
|
97
|
-
if (evidence.length < MAX_EVIDENCE_ENTRIES) {
|
|
98
|
-
evidence.push({
|
|
99
|
-
sourceRef: 'agent_turn:unavailable',
|
|
100
|
-
note: `trajectory_assistant_turns_unavailable: ${String(e).slice(0, 100)}`,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function shouldAttributePrincipleToTool(principle: { contextTags: string[]; trigger: string; }, toolName: string): boolean {
|
|
109
|
-
return principle.contextTags.includes(toolName) || principle.trigger.includes(toolName);
|
|
110
|
-
}
|
|
64
|
+
// ── Pain Event Emission ─────────────────────────────────────────────────────
|
|
111
65
|
|
|
112
66
|
export async function emitPainDetectedEvent(wctx: WorkspaceContext, event: EvolutionLoopEvent): Promise<void> {
|
|
113
67
|
try {
|
|
@@ -160,39 +114,51 @@ function createPainId(sessionId: string): string {
|
|
|
160
114
|
return `pain_${Date.now()}_${computeHash(sessionId).slice(0, 8)}`;
|
|
161
115
|
}
|
|
162
116
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Word-boundary anchors prevent "report_tool_not_found" from matching.
|
|
169
|
-
if (/\btool\s+(?:\S+\s+)?not\s+found\b/i.test(msg)) return 'dispatch_error';
|
|
170
|
-
if (/\bunknown\s+tool\b/i.test(msg)) return 'dispatch_error';
|
|
171
|
-
return 'tool_failure';
|
|
172
|
-
}
|
|
117
|
+
// ── Source Classification (re-exported from helpers) ────────────────────────
|
|
118
|
+
|
|
119
|
+
export { classifyToolFailureSource } from './after-tool-call-helpers.js';
|
|
120
|
+
|
|
121
|
+
// ── Main Hook ───────────────────────────────────────────────────────────────
|
|
173
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Handle after_tool_call hook — decomposed into pipeline stages.
|
|
125
|
+
*
|
|
126
|
+
* Pipeline: classify → record → triage → gate → emit
|
|
127
|
+
*
|
|
128
|
+
* Manual pain (toolName === 'pain') is handled inline with early return.
|
|
129
|
+
*/
|
|
174
130
|
export function handleAfterToolCall(
|
|
175
131
|
event: PluginHookAfterToolCallEvent,
|
|
176
132
|
ctx: PluginHookToolContext & { workspaceDir?: string; pluginConfig?: Record<string, unknown> },
|
|
177
133
|
api?: OpenClawPluginApi
|
|
178
134
|
): void {
|
|
135
|
+
// ── Workspace Resolution ──
|
|
179
136
|
let effectiveWorkspaceDir: string;
|
|
180
137
|
try {
|
|
181
138
|
effectiveWorkspaceDir = resolveWorkspaceDirForRuntimeV2(ctx, api, 'after_tool_call');
|
|
182
|
-
} catch {
|
|
139
|
+
} catch (error) {
|
|
140
|
+
SystemLogger.log(
|
|
141
|
+
(ctx as any).workspaceDir ?? 'unknown',
|
|
142
|
+
'WORKSPACE_RESOLUTION_FAILED',
|
|
143
|
+
JSON.stringify({
|
|
144
|
+
hook: 'after_tool_call',
|
|
145
|
+
sessionId: ctx.sessionId ?? 'unknown',
|
|
146
|
+
toolName: event.toolName,
|
|
147
|
+
reason: 'workspace_resolution_failed',
|
|
148
|
+
nextAction: 'check_plugin_config_workspace_resolution',
|
|
149
|
+
error: String(error).slice(0, 200),
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
183
152
|
return;
|
|
184
153
|
}
|
|
185
154
|
|
|
186
155
|
const wctx = WorkspaceContext.fromHookContextExplicit({ ...ctx, workspaceDir: effectiveWorkspaceDir });
|
|
187
|
-
const {config} = wctx;
|
|
188
|
-
const {eventLog} = wctx;
|
|
156
|
+
const { config } = wctx;
|
|
189
157
|
const sessionId = ctx.sessionId || 'unknown';
|
|
190
158
|
const sessionState = ctx.sessionId ? getSession(ctx.sessionId) : undefined;
|
|
191
159
|
const gfiBefore = sessionState?.currentGfi ?? 0;
|
|
192
|
-
let latestFailureState: SessionState | undefined;
|
|
193
|
-
const params = event.params as ToolParams;
|
|
194
160
|
|
|
195
|
-
//
|
|
161
|
+
// ── Profile Loading (once per call) ──
|
|
196
162
|
const profilePath = wctx.resolve('PROFILE');
|
|
197
163
|
let profile = normalizeProfile({});
|
|
198
164
|
if (fs.existsSync(profilePath)) {
|
|
@@ -208,428 +174,156 @@ export function handleAfterToolCall(
|
|
|
208
174
|
}
|
|
209
175
|
}
|
|
210
176
|
|
|
211
|
-
// ──
|
|
212
|
-
|
|
213
|
-
// 0. Special Case: Manual Pain Intervention
|
|
177
|
+
// ── Manual Pain Early Return ──
|
|
214
178
|
if (event.toolName === 'pain' || event.toolName === 'skill:pain') {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
SystemLogger.log(effectiveWorkspaceDir, 'MANUAL_PAIN', `User manually triggered pain: ${reason}`);
|
|
219
|
-
eventLog.recordPainSignal(sessionId, {
|
|
220
|
-
score: 100,
|
|
221
|
-
source: 'manual',
|
|
222
|
-
reason: `User intervention: ${reason}`,
|
|
223
|
-
isRisky: true
|
|
224
|
-
});
|
|
225
|
-
wctx.trajectory?.recordPainEvent?.({
|
|
226
|
-
sessionId,
|
|
227
|
-
source: 'manual',
|
|
228
|
-
score: 100,
|
|
229
|
-
reason: `User intervention: ${reason}`,
|
|
230
|
-
origin: 'user_manual',
|
|
231
|
-
text: reason, // Store the intervention reason as text
|
|
232
|
-
});
|
|
179
|
+
handleManualPain(event, ctx, wctx, effectiveWorkspaceDir, sessionId);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
233
182
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
evoLogger.logPainDetected({
|
|
237
|
-
traceId,
|
|
238
|
-
source: 'manual',
|
|
239
|
-
reason: `User intervention: ${reason}`,
|
|
240
|
-
score: 100,
|
|
241
|
-
toolName: event.toolName,
|
|
242
|
-
sessionId,
|
|
243
|
-
});
|
|
183
|
+
// ── Stage 1: Classify ──
|
|
184
|
+
const outcome = classifyToolCallOutcome(event);
|
|
244
185
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const gate = evaluatePainDiagnosticGate({
|
|
248
|
-
source: 'manual',
|
|
249
|
-
score: 100,
|
|
250
|
-
currentGfi: session?.currentGfi ?? 0,
|
|
251
|
-
sessionId,
|
|
252
|
-
});
|
|
253
|
-
if (!gate.shouldDiagnose) {
|
|
254
|
-
SystemLogger.log(effectiveWorkspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
|
|
255
|
-
let payload: string;
|
|
256
|
-
try {
|
|
257
|
-
payload = JSON.stringify({
|
|
258
|
-
reason: gate.reason,
|
|
259
|
-
detail: gate.detail,
|
|
260
|
-
source: 'manual',
|
|
261
|
-
sessionId,
|
|
262
|
-
gfi: 0,
|
|
263
|
-
score: 100,
|
|
264
|
-
});
|
|
265
|
-
} catch (e) {
|
|
266
|
-
SystemLogger.log(effectiveWorkspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
|
|
267
|
-
payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
|
|
268
|
-
}
|
|
269
|
-
SystemLogger.log(effectiveWorkspaceDir, 'PAIN_GATE_REJECTED', payload);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
186
|
+
// ── Stage 2: Build Observation ──
|
|
187
|
+
const observation = buildToolCallObservation(event, outcome, effectiveWorkspaceDir, profile);
|
|
272
188
|
|
|
273
|
-
|
|
274
|
-
ts: new Date().toISOString(),
|
|
275
|
-
type: 'pain_detected',
|
|
276
|
-
data: {
|
|
277
|
-
painId: createPainId(sessionId),
|
|
278
|
-
painType: 'user_frustration',
|
|
279
|
-
source: event.toolName,
|
|
280
|
-
reason: `User intervention: ${reason}`,
|
|
281
|
-
score: 100,
|
|
282
|
-
sessionId,
|
|
283
|
-
traceId,
|
|
284
|
-
agentId: ctx.agentId,
|
|
285
|
-
provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
|
|
286
|
-
evidence: buildTrajectoryEvidence(wctx, sessionId),
|
|
287
|
-
},
|
|
288
|
-
});
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
189
|
+
let latestFailureState: import('../core/session-tracker.js').SessionState | undefined;
|
|
291
190
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
: typeof detailExitCode === 'number' ? detailExitCode
|
|
301
|
-
: 0;
|
|
302
|
-
const isFailure = !!event.error || exitCode !== 0;
|
|
303
|
-
|
|
304
|
-
if (isFailure) {
|
|
305
|
-
const failureSource = classifyToolFailureSource(event.toolName, event.error);
|
|
306
|
-
const errorText = String(event.error ?? (typeof event.result === 'string' ? event.result : JSON.stringify(event.result)));
|
|
307
|
-
const denoised = denoiseError(errorText);
|
|
308
|
-
const hash = computeHash(denoised);
|
|
309
|
-
|
|
310
|
-
const deltaF = config.get('scores.tool_failure_friction') || 30;
|
|
311
|
-
const updatedState = trackFriction(sessionId, deltaF, hash, effectiveWorkspaceDir, { source: failureSource });
|
|
312
|
-
latestFailureState = updatedState;
|
|
313
|
-
|
|
314
|
-
// ── Trust Engine: Record failure ──
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const errorType = extractErrorType(event.error || errorText);
|
|
318
|
-
const filePath = params.file_path || params.path || params.file;
|
|
319
|
-
const relPath = typeof filePath === 'string' ? normalizePath(filePath, effectiveWorkspaceDir) : 'unknown';
|
|
320
|
-
|
|
321
|
-
// Use profile loaded at function scope (1MB guard already applied)
|
|
322
|
-
const isRisk = isRisky(relPath, profile.risk_paths);
|
|
323
|
-
|
|
324
|
-
recordEvolutionFailure(effectiveWorkspaceDir, event.toolName, {
|
|
325
|
-
filePath: relPath,
|
|
326
|
-
reason: isRisk ? 'risky' : 'tool',
|
|
327
|
-
sessionId,
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// Record tool call failure event
|
|
331
|
-
eventLog.recordToolCall(sessionId, {
|
|
332
|
-
toolName: event.toolName,
|
|
333
|
-
filePath: typeof filePath === 'string' ? filePath : undefined,
|
|
334
|
-
error: event.error ? String(event.error).substring(0, 200) : undefined,
|
|
335
|
-
errorType,
|
|
336
|
-
gfi: updatedState.currentGfi,
|
|
337
|
-
consecutiveErrors: updatedState.consecutiveErrors,
|
|
338
|
-
exitCode: exitCode as number | undefined,
|
|
339
|
-
gfiBefore,
|
|
340
|
-
gfiAfter: updatedState.currentGfi,
|
|
341
|
-
});
|
|
342
|
-
wctx.trajectory?.recordToolCall?.({
|
|
343
|
-
sessionId,
|
|
344
|
-
toolName: event.toolName,
|
|
345
|
-
outcome: 'failure',
|
|
346
|
-
durationMs: event.durationMs,
|
|
347
|
-
exitCode: exitCode as number | undefined,
|
|
348
|
-
errorType,
|
|
349
|
-
errorMessage: event.error ? String(event.error) : undefined,
|
|
350
|
-
gfiBefore,
|
|
351
|
-
gfiAfter: updatedState.currentGfi,
|
|
352
|
-
paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
|
|
356
|
-
for (const id of injectedProbationIds) {
|
|
357
|
-
const principle = wctx.evolutionReducer.getPrincipleById(id);
|
|
358
|
-
const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
|
|
359
|
-
if (shouldAttribute) {
|
|
360
|
-
wctx.evolutionReducer.recordProbationFeedback(id, false);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
|
|
191
|
+
if (outcome.isFailure) {
|
|
192
|
+
// ── Stage 3a: Friction + Recording (Failure) ──
|
|
193
|
+
latestFailureState = handleFrictionTrackingForFailure(
|
|
194
|
+
sessionId, event, outcome, observation, gfiBefore, effectiveWorkspaceDir, config, wctx
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// ── Stage 4: Probation Feedback (Failure) ──
|
|
198
|
+
handleProbationFeedback(sessionId, event.toolName, effectiveWorkspaceDir, wctx, false);
|
|
364
199
|
} else {
|
|
365
|
-
// ──
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const toolFailureGfi = session?.gfiBySource?.tool_failure || 0;
|
|
370
|
-
const dispatchErrorGfi = session?.gfiBySource?.dispatch_error || 0;
|
|
371
|
-
|
|
372
|
-
let resetState: SessionState = session || resetFriction(sessionId, effectiveWorkspaceDir);
|
|
373
|
-
if (toolFailureGfi > 0 || dispatchErrorGfi > 0) {
|
|
374
|
-
// Relieve both sources proportionally (50% relief each)
|
|
375
|
-
if (toolFailureGfi > 0) {
|
|
376
|
-
const reliefAmount = toolFailureGfi * 0.5;
|
|
377
|
-
resetState = resetFriction(sessionId, effectiveWorkspaceDir, {
|
|
378
|
-
source: 'tool_failure',
|
|
379
|
-
amount: reliefAmount,
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
if (dispatchErrorGfi > 0) {
|
|
383
|
-
const reliefAmount = dispatchErrorGfi * 0.5;
|
|
384
|
-
resetState = resetFriction(sessionId, effectiveWorkspaceDir, {
|
|
385
|
-
source: 'dispatch_error',
|
|
386
|
-
amount: reliefAmount,
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
recordEvolutionSuccess(effectiveWorkspaceDir, event.toolName, {
|
|
392
|
-
sessionId,
|
|
393
|
-
reason: 'tool_success',
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
|
|
397
|
-
for (const id of injectedProbationIds) {
|
|
398
|
-
const principle = wctx.evolutionReducer.getPrincipleById(id);
|
|
399
|
-
const shouldAttribute = !!principle && shouldAttributePrincipleToTool(principle, event.toolName);
|
|
400
|
-
if (shouldAttribute) {
|
|
401
|
-
wctx.evolutionReducer.recordProbationFeedback(id, true);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
clearInjectedProbationIds(sessionId, effectiveWorkspaceDir);
|
|
405
|
-
wctx.trajectory?.recordToolCall?.({
|
|
406
|
-
sessionId,
|
|
407
|
-
toolName: event.toolName,
|
|
408
|
-
outcome: 'success',
|
|
409
|
-
durationMs: event.durationMs,
|
|
410
|
-
exitCode,
|
|
411
|
-
gfiBefore,
|
|
412
|
-
gfiAfter: resetState.currentGfi,
|
|
413
|
-
paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
const filePath = params.file_path || params.path || params.file;
|
|
417
|
-
eventLog.recordToolCall(sessionId, {
|
|
418
|
-
toolName: event.toolName,
|
|
419
|
-
filePath: typeof filePath === 'string' ? filePath : undefined,
|
|
420
|
-
gfi: resetState.currentGfi,
|
|
421
|
-
gfiBefore,
|
|
422
|
-
gfiAfter: resetState.currentGfi,
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
// ── Hygiene Tracking: Record persistence actions ──
|
|
426
|
-
const normalized = typeof filePath === 'string' ? filePath.replace(/\\/g, '/') : '';
|
|
427
|
-
const isMemory = /(?:^|\/)memory\//.test(normalized) || normalized.endsWith('/MEMORY.md') || normalized === 'MEMORY.md';
|
|
428
|
-
const isPlan = normalized === 'PLAN.md' || normalized.endsWith('/PLAN.md');
|
|
429
|
-
|
|
430
|
-
if (isMemory || isPlan) {
|
|
431
|
-
const content = params.content || params.new_string || '';
|
|
432
|
-
wctx.hygiene.recordPersistence({
|
|
433
|
-
ts: new Date().toISOString(),
|
|
434
|
-
tool: event.toolName,
|
|
435
|
-
path: typeof filePath === 'string' ? filePath : 'unknown',
|
|
436
|
-
type: isMemory ? 'memory' : 'plan',
|
|
437
|
-
contentLength: content.length,
|
|
438
|
-
});
|
|
439
|
-
}
|
|
200
|
+
// ── Stage 3b: Friction + Recording (Success) ──
|
|
201
|
+
handleFrictionTrackingForSuccess(
|
|
202
|
+
sessionId, event, outcome, observation, gfiBefore, effectiveWorkspaceDir, wctx
|
|
203
|
+
);
|
|
440
204
|
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
tool: event.toolName,
|
|
447
|
-
path: 'DATABASE',
|
|
448
|
-
type: 'memory',
|
|
449
|
-
contentLength: text.length,
|
|
450
|
-
});
|
|
451
|
-
}
|
|
205
|
+
// ── Stage 4: Probation Feedback (Success) ──
|
|
206
|
+
handleProbationFeedback(sessionId, event.toolName, effectiveWorkspaceDir, wctx, true);
|
|
207
|
+
|
|
208
|
+
// ── Stage 5b: Hygiene Tracking (Success only) ──
|
|
209
|
+
recordHygieneTracking(event, observation, wctx);
|
|
452
210
|
}
|
|
453
211
|
|
|
454
|
-
// ──
|
|
455
|
-
|
|
212
|
+
// ── Stage 6: Pain Admission ──
|
|
213
|
+
const admission = evaluatePainAdmissionForToolCall(
|
|
214
|
+
event, observation, outcome, latestFailureState, sessionState, sessionId, effectiveWorkspaceDir, config
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (admission.stage === 'not_applicable') {
|
|
456
218
|
return;
|
|
457
219
|
}
|
|
458
220
|
|
|
459
|
-
|
|
221
|
+
// ── Stage 7: Emit Pain (only if admitted) ──
|
|
222
|
+
emitPainIfAdmitted(
|
|
223
|
+
wctx, event, observation, outcome, admission, sessionId, ctx.agentId, effectiveWorkspaceDir, emitPainDetectedEvent
|
|
224
|
+
);
|
|
225
|
+
}
|
|
460
226
|
|
|
461
|
-
|
|
462
|
-
const relPath = typeof filePath === 'string' ? normalizePath(filePath, effectiveWorkspaceDir) : 'unknown';
|
|
227
|
+
// ── Manual Pain Handler ─────────────────────────────────────────────────────
|
|
463
228
|
|
|
464
|
-
|
|
465
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Handle manual pain intervention (toolName === 'pain' or 'skill:pain').
|
|
231
|
+
*
|
|
232
|
+
* This path is separate because:
|
|
233
|
+
* - It always records pain at score 100
|
|
234
|
+
* - It uses a different GFI track (manual_pain)
|
|
235
|
+
* - It has its own cooldown via PainDiagnosticGate
|
|
236
|
+
* - It does NOT go through evidence triage
|
|
237
|
+
*/
|
|
238
|
+
function handleManualPain(
|
|
239
|
+
event: PluginHookAfterToolCallEvent,
|
|
240
|
+
ctx: PluginHookToolContext & { workspaceDir?: string },
|
|
241
|
+
wctx: WorkspaceContext,
|
|
242
|
+
workspaceDir: string,
|
|
243
|
+
sessionId: string,
|
|
244
|
+
): void {
|
|
245
|
+
const rawParams = event.params;
|
|
246
|
+
const params: { input?: string; arguments?: string } =
|
|
247
|
+
(rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams))
|
|
248
|
+
? rawParams as { input?: string; arguments?: string }
|
|
249
|
+
: {};
|
|
250
|
+
const reason = params.input || params.arguments || 'Manual intervention';
|
|
466
251
|
const traceId = createTraceId();
|
|
467
252
|
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const sourceKind = resolveSourceKindFromToolFailure(event.toolName, failureSource);
|
|
472
|
-
const triage = evaluateEvidenceTriage(sourceKind, painScore);
|
|
473
|
-
if (triage.decision !== 'admit') {
|
|
474
|
-
SystemLogger.log(effectiveWorkspaceDir, 'TRIAGE_EVIDENCE_ONLY', JSON.stringify({
|
|
475
|
-
sourceKind: triage.sourceKind,
|
|
476
|
-
decision: triage.decision,
|
|
477
|
-
reason: triage.reason,
|
|
478
|
-
nextAction: triage.nextAction,
|
|
479
|
-
tool: event.toolName,
|
|
480
|
-
path: relPath,
|
|
481
|
-
}));
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
253
|
+
// Track friction at max score
|
|
254
|
+
trackFriction(sessionId, 100, 'manual_pain', workspaceDir, { source: 'manual_pain' });
|
|
255
|
+
SystemLogger.log(workspaceDir, 'MANUAL_PAIN', `User manually triggered pain: ${reason}`);
|
|
485
256
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
isRisky: isRisk,
|
|
492
|
-
errorHash: latestFailureState?.lastErrorHash,
|
|
493
|
-
sessionId,
|
|
494
|
-
thresholds: {
|
|
495
|
-
painTrigger: config.get('thresholds.pain_trigger') || 40,
|
|
496
|
-
highSeverity: config.get('severity_thresholds.high') || 70,
|
|
497
|
-
repeatedFailure: config.get('thresholds.stuck_loops_trigger') || 4,
|
|
498
|
-
},
|
|
257
|
+
wctx.eventLog.recordPainSignal(sessionId, {
|
|
258
|
+
score: 100,
|
|
259
|
+
source: 'manual',
|
|
260
|
+
reason: `User intervention: ${reason}`,
|
|
261
|
+
isRisky: true
|
|
499
262
|
});
|
|
500
263
|
|
|
501
|
-
|
|
502
|
-
SystemLogger.log(
|
|
503
|
-
effectiveWorkspaceDir,
|
|
504
|
-
'PAIN_DIAGNOSE_SKIPPED',
|
|
505
|
-
`Tool failure recorded as friction only: ${diagnosticGate.detail}; tool=${event.toolName}; path=${relPath}`,
|
|
506
|
-
);
|
|
507
|
-
// Structured gate rejection event for traceability
|
|
508
|
-
let rejectPayload: string;
|
|
509
|
-
try {
|
|
510
|
-
rejectPayload = JSON.stringify({
|
|
511
|
-
reason: diagnosticGate.reason,
|
|
512
|
-
detail: diagnosticGate.detail,
|
|
513
|
-
source: failureSource,
|
|
514
|
-
sessionId: sessionId,
|
|
515
|
-
gfi: (latestFailureState ?? getSession(sessionId) ?? sessionState)?.currentGfi ?? 0,
|
|
516
|
-
score: painScore,
|
|
517
|
-
});
|
|
518
|
-
} catch (e) {
|
|
519
|
-
SystemLogger.log(effectiveWorkspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
|
|
520
|
-
rejectPayload = JSON.stringify({ reason: diagnosticGate.reason, detail: '(log serialization failed)' });
|
|
521
|
-
}
|
|
522
|
-
SystemLogger.log(effectiveWorkspaceDir, 'PAIN_GATE_REJECTED', rejectPayload);
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// Record to trajectory before Runtime V2 diagnosis so the compiler can later
|
|
527
|
-
// resolve derivedFromPainIds to the originating failed action.
|
|
528
|
-
wctx.trajectory?.recordPainEvent({
|
|
264
|
+
wctx.trajectory?.recordPainEvent?.({
|
|
529
265
|
sessionId,
|
|
530
|
-
source:
|
|
531
|
-
score:
|
|
532
|
-
reason: `
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
text: sanitizeForEvidence(params.text ?? params.content, effectiveWorkspaceDir) || undefined,
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
// Pain signal emitted via emitPainDetectedEvent below — no .pain_flag file written (M8: single-path chain)
|
|
539
|
-
|
|
540
|
-
// Observe: track which principles would have prevented this pain (Phase 1, observation-only)
|
|
541
|
-
try {
|
|
542
|
-
trackPrincipleValue(
|
|
543
|
-
effectiveWorkspaceDir,
|
|
544
|
-
{
|
|
545
|
-
reason: `Tool ${event.toolName} failed on ${relPath}. Error: ${event.error ?? 'Non-zero exit code'}`,
|
|
546
|
-
source: failureSource,
|
|
547
|
-
score: String(painScore),
|
|
548
|
-
},
|
|
549
|
-
() => wctx.evolutionReducer.getActivePrinciples().map((p) => ({
|
|
550
|
-
id: p.id,
|
|
551
|
-
trigger: p.trigger,
|
|
552
|
-
valueMetrics: p.valueMetrics,
|
|
553
|
-
})),
|
|
554
|
-
(id, metrics) => {
|
|
555
|
-
const principle = wctx.evolutionReducer.getPrincipleById(id);
|
|
556
|
-
if (principle) {
|
|
557
|
-
principle.valueMetrics = metrics;
|
|
558
|
-
// Persist to training state (best-effort, non-critical)
|
|
559
|
-
try {
|
|
560
|
-
wctx.principleTreeLedger.updatePrincipleValueMetrics(id, {
|
|
561
|
-
principleId: id,
|
|
562
|
-
painPreventedCount: metrics.painPreventedCount,
|
|
563
|
-
lastPainPreventedAt: metrics.lastPainPreventedAt,
|
|
564
|
-
calculatedAt: metrics.calculatedAt,
|
|
565
|
-
avgPainSeverityPrevented: 0,
|
|
566
|
-
totalOpportunities: 0,
|
|
567
|
-
adheredCount: 0,
|
|
568
|
-
violatedCount: 0,
|
|
569
|
-
implementationCost: 0,
|
|
570
|
-
benefitScore: 0,
|
|
571
|
-
});
|
|
572
|
-
} catch (e) {
|
|
573
|
-
// Non-critical — metrics tracked in memory
|
|
574
|
-
SystemLogger.log(effectiveWorkspaceDir, 'METRICS_UPDATE_SKIP', String(e));
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
},
|
|
578
|
-
);
|
|
579
|
-
} catch (e) {
|
|
580
|
-
// Observation only — never disrupt the pain pipeline
|
|
581
|
-
SystemLogger.log(effectiveWorkspaceDir, ' PRINCIPLE_TRACK_SKIP', String(e));
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
eventLog.recordPainSignal(sessionId, {
|
|
585
|
-
score: painScore,
|
|
586
|
-
source: failureSource,
|
|
587
|
-
reason: `Tool ${event.toolName} failed on ${relPath}`,
|
|
588
|
-
isRisky: isRisk,
|
|
266
|
+
source: 'manual',
|
|
267
|
+
score: 100,
|
|
268
|
+
reason: `User intervention: ${reason}`,
|
|
269
|
+
origin: 'user_manual',
|
|
270
|
+
text: reason,
|
|
589
271
|
});
|
|
590
272
|
|
|
591
273
|
// Log to EvolutionLogger
|
|
592
|
-
const evoLogger = getEvolutionLogger(
|
|
274
|
+
const evoLogger = getEvolutionLogger(workspaceDir, wctx.trajectory);
|
|
593
275
|
evoLogger.logPainDetected({
|
|
594
276
|
traceId,
|
|
595
|
-
source:
|
|
596
|
-
reason: `
|
|
597
|
-
score:
|
|
277
|
+
source: 'manual',
|
|
278
|
+
reason: `User intervention: ${reason}`,
|
|
279
|
+
score: 100,
|
|
598
280
|
toolName: event.toolName,
|
|
599
|
-
filePath: relPath,
|
|
600
281
|
sessionId,
|
|
601
282
|
});
|
|
602
283
|
|
|
284
|
+
// Apply PainDiagnosticGate with cooldown
|
|
285
|
+
const session = getSession(sessionId);
|
|
286
|
+
const gate = evaluatePainDiagnosticGate({
|
|
287
|
+
source: 'manual',
|
|
288
|
+
score: 100,
|
|
289
|
+
currentGfi: session?.currentGfi ?? 0,
|
|
290
|
+
sessionId,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (!gate.shouldDiagnose) {
|
|
294
|
+
SystemLogger.log(workspaceDir, 'MANUAL_PAIN_SKIPPED', `Manual pain within cooldown: ${gate.detail}`);
|
|
295
|
+
let payload: string;
|
|
296
|
+
try {
|
|
297
|
+
payload = JSON.stringify({
|
|
298
|
+
reason: gate.reason,
|
|
299
|
+
detail: gate.detail,
|
|
300
|
+
source: 'manual',
|
|
301
|
+
sessionId,
|
|
302
|
+
gfi: 0,
|
|
303
|
+
score: 100,
|
|
304
|
+
});
|
|
305
|
+
} catch (e) {
|
|
306
|
+
SystemLogger.log(workspaceDir, 'PAYLOAD_SERIALIZE_FAILED', String(e));
|
|
307
|
+
payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
|
|
308
|
+
}
|
|
309
|
+
SystemLogger.log(workspaceDir, 'PAIN_GATE_REJECTED', payload);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
603
313
|
emitPainDetectedEvent(wctx, {
|
|
604
314
|
ts: new Date().toISOString(),
|
|
605
315
|
type: 'pain_detected',
|
|
606
316
|
data: {
|
|
607
317
|
painId: createPainId(sessionId),
|
|
608
|
-
painType:
|
|
318
|
+
painType: 'user_frustration',
|
|
609
319
|
source: event.toolName,
|
|
610
|
-
reason: `
|
|
611
|
-
score:
|
|
320
|
+
reason: `User intervention: ${reason}`,
|
|
321
|
+
score: 100,
|
|
612
322
|
sessionId,
|
|
613
323
|
traceId,
|
|
614
324
|
agentId: ctx.agentId,
|
|
615
|
-
provenance: '
|
|
325
|
+
provenance: (sessionId && sessionId !== 'unknown') ? 'openclaw_context_bound' : 'owner_reported_no_host_trace',
|
|
616
326
|
evidence: buildTrajectoryEvidence(wctx, sessionId),
|
|
617
327
|
},
|
|
618
328
|
});
|
|
619
329
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
function extractErrorType(error: unknown): string {
|
|
623
|
-
if (!error) return 'Unknown';
|
|
624
|
-
const msg = String(error);
|
|
625
|
-
if (msg.includes('EACCES') || msg.includes('permission denied')) return 'EACCES';
|
|
626
|
-
if (msg.includes('ENOENT') || msg.includes('no such file')) return 'ENOENT';
|
|
627
|
-
if (msg.includes('EISDIR')) return 'EISDIR';
|
|
628
|
-
if (msg.includes('ENOSPC')) return 'ENOSPC';
|
|
629
|
-
if (msg.includes('SyntaxError')) return 'SyntaxError';
|
|
630
|
-
if (msg.includes('TypeError')) return 'TypeError';
|
|
631
|
-
if (msg.includes('ReferenceError')) return 'ReferenceError';
|
|
632
|
-
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) return 'Timeout';
|
|
633
|
-
if (msg.includes('network') || msg.includes('ECONNREFUSED')) return 'Network';
|
|
634
|
-
return 'Other';
|
|
635
|
-
}
|