principles-disciple 1.109.0 → 1.110.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 +30 -17
- package/src/hooks/llm.ts +11 -2
- package/src/hooks/pain.ts +3 -2
- package/src/hooks/raw-observation-adapter.ts +58 -74
- package/src/hooks/triage-adapter.ts +3 -42
- package/tests/hooks/pain.test.ts +54 -36
- package/tests/hooks/triage-adapter.test.ts +109 -38
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "principles-disciple",
|
|
3
3
|
"name": "Principles Disciple",
|
|
4
4
|
"description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.110.0",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onCapabilities": [
|
|
8
8
|
"hook"
|
package/package.json
CHANGED
|
@@ -25,7 +25,8 @@ import { recordEvolutionSuccess, recordEvolutionFailure } from '../core/evolutio
|
|
|
25
25
|
import type { PluginHookAfterToolCallEvent } from '../openclaw-sdk.js';
|
|
26
26
|
import { isCooldownActive as isTriggerCooldownActive, markEpisodeAsDiagnosed, clearCooldownState } from './trigger-cooldown-tracker.js';
|
|
27
27
|
import { sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.js';
|
|
28
|
-
import {
|
|
28
|
+
import { resolveSourceKind, buildToolFailureObservation, type RawObservation } from './raw-observation-adapter.js';
|
|
29
|
+
import { evaluateEvidenceTriage } from './triage-adapter.js';
|
|
29
30
|
import { evaluateTriggerController } from '@principles/core/runtime-v2';
|
|
30
31
|
import { buildTrajectoryEvidence } from './trajectory-evidence.js';
|
|
31
32
|
import type { ToolCallOutcome, ToolCallObservation, PainAdmissionDecision } from './after-tool-call-types.js';
|
|
@@ -55,10 +56,19 @@ export function classifyToolCallOutcome(event: PluginHookAfterToolCallEvent): To
|
|
|
55
56
|
: 0;
|
|
56
57
|
const isFailure = !!event.error || exitCode !== 0;
|
|
57
58
|
|
|
59
|
+
// PRI-360 S1: Use centralized builder for tool failure classification
|
|
60
|
+
// All dispatch/tool_failure rules live in raw-observation-adapter.ts
|
|
61
|
+
const obs = buildToolFailureObservation({
|
|
62
|
+
toolName: event.toolName,
|
|
63
|
+
error: event.error,
|
|
64
|
+
exitCode,
|
|
65
|
+
});
|
|
66
|
+
const failureSource = isFailure ? obs.failureSource : undefined;
|
|
67
|
+
|
|
58
68
|
return {
|
|
59
69
|
isFailure,
|
|
60
70
|
exitCode,
|
|
61
|
-
failureSource
|
|
71
|
+
failureSource,
|
|
62
72
|
};
|
|
63
73
|
}
|
|
64
74
|
|
|
@@ -372,8 +382,23 @@ export function evaluatePainAdmissionForToolCall(
|
|
|
372
382
|
TRIGGER_COOLDOWN_MAP,
|
|
373
383
|
);
|
|
374
384
|
|
|
385
|
+
// PRI-360 S1: Build RawObservation for unified source mapping
|
|
386
|
+
const rawObs: RawObservation = {
|
|
387
|
+
observedAt: new Date().toISOString(),
|
|
388
|
+
workspaceId: workspaceDir,
|
|
389
|
+
sessionId,
|
|
390
|
+
toolName: event.toolName,
|
|
391
|
+
failureSource: outcome.failureSource,
|
|
392
|
+
// Infer toolNotFound from failureSource for resolveSourceKind compatibility
|
|
393
|
+
toolNotFound: outcome.failureSource === 'dispatch_error',
|
|
394
|
+
// Extract exit code from outcome for triage (nonZeroExit)
|
|
395
|
+
nonZeroExit: outcome.exitCode !== 0,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// PRI-360 S1: Use unified resolveSourceKind instead of resolveSourceKindFromToolFailure
|
|
399
|
+
const sourceKind = resolveSourceKind(rawObs);
|
|
400
|
+
|
|
375
401
|
// PEAT-B1: Evidence triage (with consecutiveErrors and isRisky for upgrade logic)
|
|
376
|
-
const sourceKind = resolveSourceKindFromToolFailure(event.toolName, failureSource);
|
|
377
402
|
const triage = evaluateEvidenceTriage(sourceKind, observation.painScore, {
|
|
378
403
|
consecutiveErrors: (latestFailureState ?? sessionState)?.consecutiveErrors,
|
|
379
404
|
isRisky: observation.isRisk,
|
|
@@ -554,20 +579,8 @@ export function resetTriggerCooldownForTest(): void {
|
|
|
554
579
|
|
|
555
580
|
// ── Source Classification ────────────────────────────────────────────────────
|
|
556
581
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
*
|
|
560
|
-
* Pure function — no I/O, no side effects.
|
|
561
|
-
* Determines whether a tool failure is a dispatch error (tool not found)
|
|
562
|
-
* or a regular tool execution failure.
|
|
563
|
-
*/
|
|
564
|
-
export function classifyToolFailureSource(toolName: string | undefined, error: unknown): 'dispatch_error' | 'tool_failure' {
|
|
565
|
-
if (!toolName || toolName.trim() === '') return 'dispatch_error';
|
|
566
|
-
const msg = String(error ?? '');
|
|
567
|
-
if (/\btool\s+(?:\S+\s+)?not\s+found\b/i.test(msg)) return 'dispatch_error';
|
|
568
|
-
if (/\bunknown\s+tool\b/i.test(msg)) return 'dispatch_error';
|
|
569
|
-
return 'tool_failure';
|
|
570
|
-
}
|
|
582
|
+
// classifyToolFailureSource logic is now in resolveSourceKind (PRI-360 S1)
|
|
583
|
+
// This function is removed to avoid duplication.
|
|
571
584
|
|
|
572
585
|
/**
|
|
573
586
|
* Extract error type classification from error value.
|
package/src/hooks/llm.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { atomicWriteFileSync } from '../utils/io.js';
|
|
|
12
12
|
import { emitPainDetectedEvent, buildTrajectoryEvidence } from './pain.js';
|
|
13
13
|
import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
|
|
14
14
|
import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
|
|
15
|
-
import {
|
|
15
|
+
import { resolveSourceKind, type RawObservation } from './raw-observation-adapter.js';
|
|
16
|
+
import { evaluateEvidenceTriage } from './triage-adapter.js';
|
|
16
17
|
|
|
17
18
|
export interface EmpathySignal {
|
|
18
19
|
detected: boolean;
|
|
@@ -255,7 +256,15 @@ export function handleLlmOutput(
|
|
|
255
256
|
let triageAdmitted = true;
|
|
256
257
|
const llmTriageFlag = loadFeatureFlagFromConfig(ctx.workspaceDir!, 'painEvidenceAdmission');
|
|
257
258
|
if (llmTriageFlag.enabled) {
|
|
258
|
-
|
|
259
|
+
// PRI-360 S1: Build RawObservation for unified source mapping
|
|
260
|
+
const rawObs: RawObservation = {
|
|
261
|
+
observedAt: new Date().toISOString(),
|
|
262
|
+
workspaceId: ctx.workspaceDir,
|
|
263
|
+
sessionId: ctx.sessionId,
|
|
264
|
+
detectionSource: source,
|
|
265
|
+
isGfiTriggered,
|
|
266
|
+
};
|
|
267
|
+
const sourceKind = resolveSourceKind(rawObs);
|
|
259
268
|
const triage = evaluateEvidenceTriage(sourceKind, painScore);
|
|
260
269
|
if (triage.decision !== 'admit') {
|
|
261
270
|
triageAdmitted = false;
|
package/src/hooks/pain.ts
CHANGED
|
@@ -128,9 +128,10 @@ function createPainId(sessionId: string): string {
|
|
|
128
128
|
return `pain_${Date.now()}_${computeHash(sessionId).slice(0, 8)}`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
// ── Source Classification
|
|
131
|
+
// ── Source Classification ────────────────────────────────────────────────────
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// PRI-360 S1: classifyToolFailureSource is removed; source mapping is now unified
|
|
134
|
+
// through resolveSourceKind in raw-observation-adapter.ts
|
|
134
135
|
|
|
135
136
|
// ── Main Hook ───────────────────────────────────────────────────────────────
|
|
136
137
|
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
import type { SourceKind } from '@principles/core/runtime-v2';
|
|
27
27
|
import type { RawObservation } from './raw-observation-types.js';
|
|
28
28
|
|
|
29
|
+
// Re-export RawObservation for plugin consumers
|
|
30
|
+
export type { RawObservation } from './raw-observation-types.js';
|
|
31
|
+
|
|
29
32
|
/**
|
|
30
33
|
* Resolve SourceKind from a unified RawObservation.
|
|
31
34
|
*
|
|
@@ -135,97 +138,78 @@ export function resolveSourceKind(observation: RawObservation): SourceKind {
|
|
|
135
138
|
return 'unknown';
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
*
|
|
144
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
145
|
-
*/
|
|
146
|
-
export function resolveSourceKindFromToolFailure(
|
|
147
|
-
toolName: string | undefined,
|
|
148
|
-
failureSource: 'tool_failure' | 'dispatch_error',
|
|
149
|
-
provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook',
|
|
150
|
-
): SourceKind {
|
|
151
|
-
const observation: RawObservation = {
|
|
152
|
-
observedAt: new Date().toISOString(),
|
|
153
|
-
toolName,
|
|
154
|
-
failureSource,
|
|
155
|
-
provenance,
|
|
156
|
-
};
|
|
157
|
-
return resolveSourceKind(observation);
|
|
158
|
-
}
|
|
141
|
+
// ── Builder Functions ──────────────────────────────────────────────────────
|
|
142
|
+
//
|
|
143
|
+
// PRI-360 S1: These builders construct RawObservation from specific contexts,
|
|
144
|
+
// centralizing source classification rules in the adapter layer.
|
|
145
|
+
// Hooks should NOT hold source classification logic — use these builders.
|
|
159
146
|
|
|
160
147
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* This is a thin wrapper around resolveSourceKind for compatibility.
|
|
148
|
+
* Classify error message as dispatch_error vs tool_failure.
|
|
164
149
|
*
|
|
165
|
-
*
|
|
150
|
+
* This centralizes the regex-based classification that was previously
|
|
151
|
+
* scattered in classifyToolFailureSource and after-tool-call-helpers.
|
|
152
|
+
* Now hooks call this builder + resolveSourceKind instead of holding rules.
|
|
166
153
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
isGfiTriggered,
|
|
175
|
-
};
|
|
176
|
-
return resolveSourceKind(observation);
|
|
154
|
+
function classifyErrorForDispatch(error: unknown): 'dispatch_error' | 'tool_failure' {
|
|
155
|
+
if (!error) return 'tool_failure';
|
|
156
|
+
const msg = String(error);
|
|
157
|
+
if (/\btool\s+(?:\S+\s+)?not\s+found\b/i.test(msg) || /\bunknown\s+tool\b/i.test(msg)) {
|
|
158
|
+
return 'dispatch_error';
|
|
159
|
+
}
|
|
160
|
+
return 'tool_failure';
|
|
177
161
|
}
|
|
178
162
|
|
|
179
163
|
/**
|
|
180
|
-
*
|
|
164
|
+
* Build a RawObservation for a tool failure context.
|
|
181
165
|
*
|
|
182
|
-
*
|
|
166
|
+
* This replaces classifyToolFailureSource and the inline classification
|
|
167
|
+
* in after-tool-call-helpers. All tool error → dispatch/tool_failure
|
|
168
|
+
* classification is centralized here.
|
|
183
169
|
*/
|
|
184
|
-
export function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
170
|
+
export function buildToolFailureObservation(options: {
|
|
171
|
+
toolName: string | undefined;
|
|
172
|
+
error: unknown;
|
|
173
|
+
exitCode?: number;
|
|
174
|
+
provenance?: RawObservation['provenance'];
|
|
175
|
+
}): RawObservation {
|
|
176
|
+
const { toolName, error, provenance } = options;
|
|
177
|
+
const nonZeroExit = typeof options.exitCode === 'number' && options.exitCode !== 0;
|
|
178
|
+
|
|
179
|
+
// Classify dispatch vs tool_failure centrally
|
|
180
|
+
let failureSource: 'dispatch_error' | 'tool_failure' | undefined;
|
|
181
|
+
|
|
182
|
+
if (!toolName || toolName.trim() === '') {
|
|
183
|
+
// Empty/whitespace tool name → dispatch error
|
|
184
|
+
failureSource = 'dispatch_error';
|
|
185
|
+
} else {
|
|
186
|
+
failureSource = classifyErrorForDispatch(error);
|
|
187
|
+
}
|
|
191
188
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
*/
|
|
197
|
-
export function resolveSourceKindFromCommand(): SourceKind {
|
|
198
|
-
const observation: RawObservation = {
|
|
199
|
-
observedAt: new Date().toISOString(),
|
|
200
|
-
isManualEntry: true,
|
|
201
|
-
};
|
|
202
|
-
return resolveSourceKind(observation);
|
|
203
|
-
}
|
|
189
|
+
// If neither error nor non-zero exit, this is not a failure context
|
|
190
|
+
if (!error && !nonZeroExit) {
|
|
191
|
+
failureSource = undefined;
|
|
192
|
+
}
|
|
204
193
|
|
|
205
|
-
|
|
206
|
-
* Resolve SourceKind from provider context (legacy wrapper).
|
|
207
|
-
*
|
|
208
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
209
|
-
*/
|
|
210
|
-
export function resolveSourceKindFromProvider(
|
|
211
|
-
isRateLimit: boolean,
|
|
212
|
-
): SourceKind {
|
|
213
|
-
const observation: RawObservation = {
|
|
194
|
+
return {
|
|
214
195
|
observedAt: new Date().toISOString(),
|
|
215
|
-
|
|
196
|
+
toolName,
|
|
197
|
+
failureSource,
|
|
198
|
+
nonZeroExit,
|
|
199
|
+
provenance,
|
|
216
200
|
};
|
|
217
|
-
return resolveSourceKind(observation);
|
|
218
201
|
}
|
|
219
202
|
|
|
220
203
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
204
|
+
* Build a RawObservation for an LLM detection context.
|
|
224
205
|
*/
|
|
225
|
-
export function
|
|
226
|
-
|
|
206
|
+
export function buildLlmDetectionObservation(options: {
|
|
207
|
+
detectionSource: string;
|
|
208
|
+
isGfiTriggered: boolean;
|
|
209
|
+
}): RawObservation {
|
|
210
|
+
return {
|
|
227
211
|
observedAt: new Date().toISOString(),
|
|
228
|
-
|
|
212
|
+
detectionSource: options.detectionSource,
|
|
213
|
+
isGfiTriggered: options.isGfiTriggered,
|
|
229
214
|
};
|
|
230
|
-
return resolveSourceKind(observation);
|
|
231
215
|
}
|
|
@@ -32,50 +32,11 @@ import {
|
|
|
32
32
|
*
|
|
33
33
|
* This is the unified entry point for source-kind classification.
|
|
34
34
|
* It replaces the scattered resolveSourceKindFrom* functions.
|
|
35
|
-
*/
|
|
36
|
-
export { resolveSourceKind } from './raw-observation-adapter.js';
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Map after_tool_call hook context to SourceKind.
|
|
40
|
-
*
|
|
41
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
42
|
-
*/
|
|
43
|
-
export { resolveSourceKindFromToolFailure } from './raw-observation-adapter.js';
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Map empathy/semantic detection context to SourceKind.
|
|
47
|
-
*
|
|
48
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
49
|
-
*/
|
|
50
|
-
export { resolveSourceKindFromLlmDetection } from './raw-observation-adapter.js';
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Map gate-block context to SourceKind.
|
|
54
|
-
*
|
|
55
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
56
|
-
*/
|
|
57
|
-
export { resolveSourceKindFromGateBlock } from './raw-observation-adapter.js';
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Map /pd-pain command to SourceKind.
|
|
61
|
-
*
|
|
62
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
63
|
-
*/
|
|
64
|
-
export { resolveSourceKindFromCommand } from './raw-observation-adapter.js';
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Map provider/rate-limit failure to SourceKind.
|
|
68
|
-
*
|
|
69
|
-
* @deprecated Use resolveSourceKind directly with RawObservation.
|
|
70
|
-
*/
|
|
71
|
-
export { resolveSourceKindFromProvider } from './raw-observation-adapter.js';
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Map subagent error to SourceKind.
|
|
75
35
|
*
|
|
76
|
-
*
|
|
36
|
+
* PRI-360 S1: All source-kind resolution now goes through this single entry point.
|
|
77
37
|
*/
|
|
78
|
-
export {
|
|
38
|
+
export { resolveSourceKind, buildToolFailureObservation, buildLlmDetectionObservation, type RawObservation } from './raw-observation-adapter.js';
|
|
39
|
+
// All callers should use resolveSourceKind with RawObservation.
|
|
79
40
|
|
|
80
41
|
// ── Triage Evaluation ───────────────────────────────────────────────────────
|
|
81
42
|
|
package/tests/hooks/pain.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { handleAfterToolCall
|
|
2
|
+
import { handleAfterToolCall } from '../../src/hooks/pain.js';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import * as os from 'os';
|
|
@@ -8,6 +8,7 @@ import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
|
8
8
|
import { EventLogService } from '../../src/core/event-log.js';
|
|
9
9
|
import { setInjectedProbationIds, clearSession } from '../../src/core/session-tracker.js';
|
|
10
10
|
import { resetTriggerCooldownForTest } from '../../src/hooks/after-tool-call-helpers.js';
|
|
11
|
+
import { buildToolFailureObservation, resolveSourceKind } from '../../src/hooks/raw-observation-adapter.js';
|
|
11
12
|
import { loadFeatureFlagFromConfig } from '../../src/core/pd-config-loader.js';
|
|
12
13
|
|
|
13
14
|
vi.mock('fs');
|
|
@@ -31,74 +32,91 @@ const mockEmitSync = vi.fn();
|
|
|
31
32
|
const mockRecordProbationFeedback = vi.fn();
|
|
32
33
|
const mockUpdatePrincipleValueMetrics = vi.fn();
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
// PRI-360 S1: classifyToolFailureSource tests migrated to resolveSourceKind + buildToolFailureObservation
|
|
36
|
+
// See triage-adapter.test.ts for the unified RawObservation path tests
|
|
37
|
+
|
|
38
|
+
describe('buildToolFailureObservation + resolveSourceKind (replaces classifyToolFailureSource)', () => {
|
|
35
39
|
it('empty toolName -> dispatch_error', () => {
|
|
36
|
-
|
|
37
|
-
expect(
|
|
40
|
+
const obs = buildToolFailureObservation({ toolName: undefined, error: 'tool not found', exitCode: 1 });
|
|
41
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
42
|
+
const obs2 = buildToolFailureObservation({ toolName: '', error: 'tool not found', exitCode: 1 });
|
|
43
|
+
expect(resolveSourceKind(obs2)).toBe('dispatch_error');
|
|
38
44
|
});
|
|
39
45
|
|
|
40
46
|
it('"Tool not found" (case insensitive) -> dispatch_error', () => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
const cases = [
|
|
48
|
+
'error: tool not found',
|
|
49
|
+
'Tool Not Found',
|
|
50
|
+
'Tool read_file not found',
|
|
51
|
+
];
|
|
52
|
+
for (const err of cases) {
|
|
53
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: err, exitCode: 1 });
|
|
54
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
55
|
+
}
|
|
45
56
|
});
|
|
46
57
|
|
|
47
58
|
it('"Unknown tool" (case insensitive) -> dispatch_error', () => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
const cases = ['error: unknown tool', 'Unknown Tool', 'failed: unknown tool read_file'];
|
|
60
|
+
for (const err of cases) {
|
|
61
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: err, exitCode: 1 });
|
|
62
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
63
|
+
}
|
|
51
64
|
});
|
|
52
65
|
|
|
53
66
|
it('Warning-style messages containing "tool not found" -> dispatch_error', () => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
const cases = ['Warning: tool not found was suppressed', 'Warning: tool not found - already handled'];
|
|
68
|
+
for (const err of cases) {
|
|
69
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: err, exitCode: 1 });
|
|
70
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
71
|
+
}
|
|
57
72
|
});
|
|
58
73
|
|
|
59
74
|
it('real execution errors (ENOENT, EACCES) -> tool_failure', () => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
const cases = [
|
|
76
|
+
{ toolName: 'read' as const, error: 'ENOENT: no such file or directory' },
|
|
77
|
+
{ toolName: 'write' as const, error: 'EACCES: permission denied' },
|
|
78
|
+
{ toolName: 'edit' as const, error: 'Error: EIO: I/O error' },
|
|
79
|
+
];
|
|
80
|
+
for (const { toolName, error } of cases) {
|
|
81
|
+
const obs = buildToolFailureObservation({ toolName, error, exitCode: 1 });
|
|
82
|
+
expect(resolveSourceKind(obs)).toBe('tool_failure');
|
|
83
|
+
}
|
|
63
84
|
});
|
|
64
85
|
|
|
65
|
-
it('edge cases: null/undefined/empty error', () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
expect(
|
|
69
|
-
expect(classifyToolFailureSource('read', 123)).toBe('tool_failure');
|
|
86
|
+
it('edge cases: null/undefined/empty error -> tool_failure', () => {
|
|
87
|
+
// With valid toolName and non-zero exit, no error message → tool_failure
|
|
88
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: undefined, exitCode: 1 });
|
|
89
|
+
expect(resolveSourceKind(obs)).toBe('tool_failure');
|
|
70
90
|
});
|
|
71
91
|
|
|
72
92
|
it('word-boundary: "report_tool_not_found" does NOT match dispatch pattern', () => {
|
|
73
|
-
|
|
93
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'report_tool_not_found', exitCode: 1 });
|
|
94
|
+
expect(resolveSourceKind(obs)).toBe('tool_failure');
|
|
74
95
|
});
|
|
75
96
|
|
|
76
97
|
it('word-boundary: "atoolnotfound" (no spaces) does NOT match dispatch pattern', () => {
|
|
77
|
-
|
|
98
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'atoolnotfound', exitCode: 1 });
|
|
99
|
+
expect(resolveSourceKind(obs)).toBe('tool_failure');
|
|
78
100
|
});
|
|
79
101
|
|
|
80
102
|
it('word-boundary: "unknown_tool" (underscore, no space) does NOT match dispatch pattern', () => {
|
|
81
|
-
|
|
103
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'unknown_tool', exitCode: 1 });
|
|
104
|
+
expect(resolveSourceKind(obs)).toBe('tool_failure');
|
|
82
105
|
});
|
|
83
106
|
|
|
84
107
|
it('whitespace-only toolName -> dispatch_error', () => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
it('numeric error value -> tool_failure', () => {
|
|
89
|
-
expect(classifyToolFailureSource('read', 42)).toBe('tool_failure');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('object error value -> tool_failure', () => {
|
|
93
|
-
expect(classifyToolFailureSource('read', { code: 'ENOENT' })).toBe('tool_failure');
|
|
108
|
+
const obs = buildToolFailureObservation({ toolName: ' ', error: 'tool not found', exitCode: 1 });
|
|
109
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
94
110
|
});
|
|
95
111
|
|
|
96
112
|
it('"tool <name> not found" with multi-word tool name -> dispatch_error', () => {
|
|
97
|
-
|
|
113
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'tool my_custom_tool not found', exitCode: 1 });
|
|
114
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
98
115
|
});
|
|
99
116
|
|
|
100
117
|
it('partial match "not found" without "tool" prefix -> tool_failure', () => {
|
|
101
|
-
|
|
118
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'file not found', exitCode: 1 });
|
|
119
|
+
expect(resolveSourceKind(obs)).toBe('tool_failure');
|
|
102
120
|
});
|
|
103
121
|
});
|
|
104
122
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Triage Adapter Tests — PEAT-B1
|
|
2
|
+
* Triage Adapter Tests — PEAT-B1 / PRI-360 S1
|
|
3
3
|
*
|
|
4
|
-
* Tests the
|
|
5
|
-
* and
|
|
4
|
+
* Tests the unified RawObservation → SourceKind resolution path
|
|
5
|
+
* and the evidence triage policy.
|
|
6
|
+
*
|
|
7
|
+
* All legacy resolveSourceKindFrom* wrappers have been removed.
|
|
8
|
+
* Every test uses resolveSourceKind(RawObservation) directly.
|
|
6
9
|
*
|
|
7
10
|
* ERR checklist:
|
|
8
11
|
* - ERR-001: Source kind resolved from runtime values, not `as` casts.
|
|
@@ -12,98 +15,166 @@
|
|
|
12
15
|
|
|
13
16
|
import { describe, it, expect } from 'vitest';
|
|
14
17
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
resolveSourceKindFromProvider,
|
|
20
|
-
resolveSourceKindFromSubagent,
|
|
18
|
+
resolveSourceKind,
|
|
19
|
+
buildToolFailureObservation,
|
|
20
|
+
buildLlmDetectionObservation,
|
|
21
|
+
type RawObservation,
|
|
21
22
|
evaluateEvidenceTriage,
|
|
22
23
|
isHighConfidenceUnsafeAction,
|
|
23
24
|
} from '../../src/hooks/triage-adapter.js';
|
|
24
25
|
|
|
25
|
-
// ──
|
|
26
|
+
// ── resolveSourceKind: Tool Failure Path ─────────────────────────────────────
|
|
26
27
|
|
|
27
|
-
describe('
|
|
28
|
+
describe('resolveSourceKind: tool failure path', () => {
|
|
28
29
|
it('maps pain tool to agent_on_owner_request with openclaw_context_bound', () => {
|
|
29
|
-
|
|
30
|
+
const obs: RawObservation = { observedAt: 't', toolName: 'pain', failureSource: 'tool_failure', provenance: 'openclaw_context_bound' };
|
|
31
|
+
expect(resolveSourceKind(obs)).toBe('agent_on_owner_request');
|
|
30
32
|
});
|
|
31
33
|
|
|
32
34
|
it('maps pain tool to owner_reported without openclaw_context_bound', () => {
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
35
|
+
expect(resolveSourceKind({ observedAt: 't', toolName: 'pain', failureSource: 'tool_failure' })).toBe('owner_reported');
|
|
36
|
+
expect(resolveSourceKind({ observedAt: 't', toolName: 'pain', failureSource: 'tool_failure', provenance: 'automatic_hook' })).toBe('owner_reported');
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
it('maps skill:pain to agent_on_owner_request with openclaw_context_bound', () => {
|
|
38
|
-
expect(
|
|
40
|
+
expect(resolveSourceKind({ observedAt: 't', toolName: 'skill:pain', failureSource: 'tool_failure', provenance: 'openclaw_context_bound' })).toBe('agent_on_owner_request');
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
it('maps dispatch_error to dispatch_error', () => {
|
|
42
|
-
expect(
|
|
44
|
+
expect(resolveSourceKind({ observedAt: 't', toolName: 'read', failureSource: 'dispatch_error' })).toBe('dispatch_error');
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
it('maps regular tool failure to tool_failure', () => {
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
48
|
+
expect(resolveSourceKind({ observedAt: 't', toolName: 'write', failureSource: 'tool_failure' })).toBe('tool_failure');
|
|
49
|
+
expect(resolveSourceKind({ observedAt: 't', toolName: 'exec', failureSource: 'tool_failure' })).toBe('tool_failure');
|
|
48
50
|
});
|
|
49
51
|
|
|
50
|
-
it('maps undefined tool name with tool_failure to
|
|
51
|
-
expect(
|
|
52
|
+
it('maps undefined tool name with tool_failure to dispatch_error via toolNotFound', () => {
|
|
53
|
+
expect(resolveSourceKind({ observedAt: 't', toolName: undefined, failureSource: 'tool_failure' })).toBe('tool_failure');
|
|
52
54
|
});
|
|
53
55
|
});
|
|
54
56
|
|
|
55
|
-
// ──
|
|
57
|
+
// ── resolveSourceKind: LLM Detection Path ────────────────────────────────────
|
|
56
58
|
|
|
57
|
-
describe('
|
|
59
|
+
describe('resolveSourceKind: LLM detection path', () => {
|
|
58
60
|
it('maps gfi triggered to gfi_threshold', () => {
|
|
59
|
-
expect(
|
|
61
|
+
expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_some_rule', isGfiTriggered: true })).toBe('gfi_threshold');
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
it('maps llm_paralysis to llm_paralysis', () => {
|
|
63
|
-
expect(
|
|
65
|
+
expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_paralysis', isGfiTriggered: false })).toBe('llm_paralysis');
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
it('maps llm_* detection rules to semantic', () => {
|
|
67
|
-
expect(
|
|
68
|
-
expect(
|
|
69
|
+
expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_repetition', isGfiTriggered: false })).toBe('semantic');
|
|
70
|
+
expect(resolveSourceKind({ observedAt: 't', detectionSource: 'llm_loop', isGfiTriggered: false })).toBe('semantic');
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
it('maps user_empathy to empathy_inferred', () => {
|
|
72
|
-
expect(
|
|
74
|
+
expect(resolveSourceKind({ observedAt: 't', detectionSource: 'user_empathy', isGfiTriggered: false })).toBe('empathy_inferred');
|
|
73
75
|
});
|
|
74
76
|
|
|
75
77
|
it('maps unknown source to unknown', () => {
|
|
76
|
-
expect(
|
|
78
|
+
expect(resolveSourceKind({ observedAt: 't', detectionSource: 'something_else', isGfiTriggered: false })).toBe('unknown');
|
|
77
79
|
});
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
// ── Other
|
|
82
|
+
// ── resolveSourceKind: Other Context Paths ───────────────────────────────────
|
|
81
83
|
|
|
82
|
-
describe('
|
|
84
|
+
describe('resolveSourceKind: gate block path', () => {
|
|
83
85
|
it('returns rulehost_block', () => {
|
|
84
|
-
expect(
|
|
86
|
+
expect(resolveSourceKind({ observedAt: 't', isGateBlock: true })).toBe('rulehost_block');
|
|
85
87
|
});
|
|
86
88
|
});
|
|
87
89
|
|
|
88
|
-
describe('
|
|
90
|
+
describe('resolveSourceKind: manual command path', () => {
|
|
89
91
|
it('returns owner_reported', () => {
|
|
90
|
-
expect(
|
|
92
|
+
expect(resolveSourceKind({ observedAt: 't', isManualEntry: true })).toBe('owner_reported');
|
|
91
93
|
});
|
|
92
94
|
});
|
|
93
95
|
|
|
94
|
-
describe('
|
|
96
|
+
describe('resolveSourceKind: provider path', () => {
|
|
95
97
|
it('returns provider_failure for non-rate-limit', () => {
|
|
96
|
-
expect(
|
|
98
|
+
expect(resolveSourceKind({ observedAt: 't', isRateLimit: false })).toBe('provider_failure');
|
|
97
99
|
});
|
|
98
100
|
|
|
99
101
|
it('returns rate_limit for rate-limit', () => {
|
|
100
|
-
expect(
|
|
102
|
+
expect(resolveSourceKind({ observedAt: 't', isRateLimit: true })).toBe('rate_limit');
|
|
101
103
|
});
|
|
102
104
|
});
|
|
103
105
|
|
|
104
|
-
describe('
|
|
106
|
+
describe('resolveSourceKind: subagent path', () => {
|
|
105
107
|
it('returns subagent_error', () => {
|
|
106
|
-
expect(
|
|
108
|
+
expect(resolveSourceKind({ observedAt: 't', isSubagentError: true })).toBe('subagent_error');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── buildToolFailureObservation ──────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe('buildToolFailureObservation', () => {
|
|
115
|
+
it('classifies empty tool name as dispatch_error', () => {
|
|
116
|
+
const obs = buildToolFailureObservation({ toolName: undefined, error: 'tool not found', exitCode: 1 });
|
|
117
|
+
expect(obs.failureSource).toBe('dispatch_error');
|
|
118
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('classifies "tool not found" error as dispatch_error', () => {
|
|
122
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'tool read_file not found', exitCode: 1 });
|
|
123
|
+
expect(obs.failureSource).toBe('dispatch_error');
|
|
124
|
+
expect(resolveSourceKind(obs)).toBe('dispatch_error');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('classifies "Unknown tool" error as dispatch_error', () => {
|
|
128
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'Unknown Tool', exitCode: 1 });
|
|
129
|
+
expect(obs.failureSource).toBe('dispatch_error');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('classifies real errors (ENOENT) as tool_failure', () => {
|
|
133
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'ENOENT: no such file', exitCode: 1 });
|
|
134
|
+
expect(obs.failureSource).toBe('tool_failure');
|
|
135
|
+
expect(resolveSourceKind(obs)).toBe('tool_failure');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('classifies no error + no exit as non-failure (undefined failureSource)', () => {
|
|
139
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: undefined, exitCode: 0 });
|
|
140
|
+
expect(obs.failureSource).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('classifies whitespace-only tool name as dispatch_error', () => {
|
|
144
|
+
const obs = buildToolFailureObservation({ toolName: ' ', error: 'tool not found', exitCode: 1 });
|
|
145
|
+
expect(obs.failureSource).toBe('dispatch_error');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('word-boundary: "report_tool_not_found" does NOT match dispatch pattern', () => {
|
|
149
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'report_tool_not_found', exitCode: 1 });
|
|
150
|
+
expect(obs.failureSource).toBe('tool_failure');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('word-boundary: "atoolnotfound" does NOT match dispatch pattern', () => {
|
|
154
|
+
const obs = buildToolFailureObservation({ toolName: 'read', error: 'atoolnotfound', exitCode: 1 });
|
|
155
|
+
expect(obs.failureSource).toBe('tool_failure');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('preserves provenance', () => {
|
|
159
|
+
const obs = buildToolFailureObservation({ toolName: 'pain', error: 'fail', exitCode: 1, provenance: 'openclaw_context_bound' });
|
|
160
|
+
expect(obs.provenance).toBe('openclaw_context_bound');
|
|
161
|
+
expect(resolveSourceKind(obs)).toBe('agent_on_owner_request');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── buildLlmDetectionObservation ─────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe('buildLlmDetectionObservation', () => {
|
|
168
|
+
it('builds observation for GFI-triggered detection', () => {
|
|
169
|
+
const obs = buildLlmDetectionObservation({ detectionSource: 'llm_some_rule', isGfiTriggered: true });
|
|
170
|
+
expect(obs.detectionSource).toBe('llm_some_rule');
|
|
171
|
+
expect(obs.isGfiTriggered).toBe(true);
|
|
172
|
+
expect(resolveSourceKind(obs)).toBe('gfi_threshold');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('builds observation for llm_paralysis', () => {
|
|
176
|
+
const obs = buildLlmDetectionObservation({ detectionSource: 'llm_paralysis', isGfiTriggered: false });
|
|
177
|
+
expect(resolveSourceKind(obs)).toBe('llm_paralysis');
|
|
107
178
|
});
|
|
108
179
|
});
|
|
109
180
|
|