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