principles-disciple 1.94.0 → 1.96.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
CHANGED
package/package.json
CHANGED
package/src/commands/pain.ts
CHANGED
|
@@ -349,10 +349,31 @@ export async function handlePainReportCommand(ctx: PluginCommandContext): Promis
|
|
|
349
349
|
};
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
if (result.status === 'retried') {
|
|
353
|
+
const errorInfo = result.failureCategory
|
|
354
|
+
? (isZh ? `\n⚠️ **错误类别**: ${result.failureCategory}` : `\n⚠️ **Error category**: ${result.failureCategory}`)
|
|
355
|
+
: '';
|
|
356
|
+
const messageInfo = result.message
|
|
357
|
+
? (isZh ? `\n📝 **详情**: ${result.message}` : `\n📝 **Detail**: ${result.message}`)
|
|
358
|
+
: '';
|
|
359
|
+
return {
|
|
360
|
+
text: isZh
|
|
361
|
+
? `✅ Pain 已记录,诊断任务已进入重试\n\n📋 **Pain ID**: ${result.painId}\n🔧 **Task ID**: ${result.taskId}${errorInfo}${messageInfo}\n\n诊断任务将在后台自动重试。使用 \`/pd-status\` 查看任务状态。`
|
|
362
|
+
: `✅ Pain recorded, diagnosis task entered retry\n\n📋 **Pain ID**: ${result.painId}\n🔧 **Task ID**: ${result.taskId}${errorInfo}${messageInfo}\n\nThe diagnosis task will retry automatically in the background. Use \`/pd-status\` to check task status.`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// status === 'failed' | 'skipped' | 'degraded' — pain was NOT accepted
|
|
367
|
+
const reasonInfo = result.failureCategory
|
|
368
|
+
? (isZh ? `\n⚠️ **原因**: ${result.failureCategory}` : `\n⚠️ **Reason**: ${result.failureCategory}`)
|
|
369
|
+
: '';
|
|
370
|
+
const messageInfo = result.message
|
|
371
|
+
? (isZh ? `\n📝 **详情**: ${result.message}` : `\n📝 **Detail**: ${result.message}`)
|
|
372
|
+
: '';
|
|
352
373
|
return {
|
|
353
374
|
text: isZh
|
|
354
|
-
?
|
|
355
|
-
:
|
|
375
|
+
? `❌ Pain 记录未成功 (status: ${result.status})${reasonInfo}${messageInfo}\n\n请检查系统日志或使用 \`/pd-status\` 查看状态。`
|
|
376
|
+
: `❌ Pain recording not accepted (status: ${result.status})${reasonInfo}${messageInfo}\n\nCheck system logs or use \`/pd-status\` for status.`,
|
|
356
377
|
};
|
|
357
378
|
} catch (err) {
|
|
358
379
|
return {
|
|
@@ -88,6 +88,21 @@ export function resetPainDiagnosticGateForTest(): void {
|
|
|
88
88
|
lastDiagnosedAtByEpisode.clear();
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Check whether cooldown is currently active for a given episode.
|
|
93
|
+
* Used by the trigger controller (PEAT-B2) to align its cooldown decision
|
|
94
|
+
* with the PainDiagnosticGate's cooldown state.
|
|
95
|
+
*/
|
|
96
|
+
export function isCooldownActiveForEpisode(
|
|
97
|
+
source: string,
|
|
98
|
+
sessionId: string | undefined,
|
|
99
|
+
errorHash: string | undefined,
|
|
100
|
+
cooldownMs?: number,
|
|
101
|
+
): boolean {
|
|
102
|
+
const episodeKey = buildEpisodeKey({ source, sessionId, errorHash } as PainDiagnosticGateInput);
|
|
103
|
+
return withinCooldown({ source, sessionId, errorHash, cooldownMs } as PainDiagnosticGateInput, episodeKey);
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
export function evaluatePainDiagnosticGate(input: PainDiagnosticGateInput): PainDiagnosticGateDecision {
|
|
92
107
|
const source = normalizedSource(input.source);
|
|
93
108
|
const episodeKey = buildEpisodeKey(input);
|
|
@@ -23,10 +23,11 @@ import { WorkspaceContext } from '../core/workspace-context.js';
|
|
|
23
23
|
import { getEvolutionLogger, createTraceId } from '../core/evolution-logger.js';
|
|
24
24
|
import { recordEvolutionSuccess, recordEvolutionFailure } from '../core/evolution-engine.js';
|
|
25
25
|
import type { PluginHookAfterToolCallEvent } from '../openclaw-sdk.js';
|
|
26
|
-
import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
|
|
26
|
+
import { evaluatePainDiagnosticGate, isCooldownActiveForEpisode } from '../core/pain-diagnostic-gate.js';
|
|
27
27
|
import { sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.js';
|
|
28
28
|
import { loadFeatureFlagFromConfig } from '../core/pd-config-loader.js';
|
|
29
29
|
import { resolveSourceKindFromToolFailure, evaluateEvidenceTriage } from './triage-adapter.js';
|
|
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';
|
|
32
33
|
|
|
@@ -347,24 +348,44 @@ export function evaluatePainAdmissionForToolCall(
|
|
|
347
348
|
const failureSource = outcome.failureSource ?? 'tool_failure';
|
|
348
349
|
|
|
349
350
|
// PEAT-B1: Evidence triage (feature-flagged)
|
|
351
|
+
// PEAT-B2: Trigger controller adds structured outcome + cooldown awareness
|
|
350
352
|
const painTriageFlag = loadFeatureFlagFromConfig(workspaceDir, 'painEvidenceAdmission');
|
|
351
353
|
if (painTriageFlag.enabled) {
|
|
352
354
|
const sourceKind = resolveSourceKindFromToolFailure(event.toolName, failureSource);
|
|
353
355
|
const triage = evaluateEvidenceTriage(sourceKind, observation.painScore);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
356
|
+
|
|
357
|
+
// PEAT-B2: Evaluate trigger controller for structured decision
|
|
358
|
+
// Compute real cooldown state from PainDiagnosticGate's episode map
|
|
359
|
+
// so trigger decision aligns with the gate's cooldown logic (EP-07).
|
|
360
|
+
const cooldownActive = isCooldownActiveForEpisode(
|
|
361
|
+
failureSource,
|
|
362
|
+
sessionId,
|
|
363
|
+
latestFailureState?.lastErrorHash,
|
|
364
|
+
);
|
|
365
|
+
const triggerDecision = evaluateTriggerController({
|
|
366
|
+
triageResult: triage,
|
|
367
|
+
isOwnerManual: false, // tool failures are never owner manual
|
|
368
|
+
isCooldownActive: cooldownActive,
|
|
369
|
+
isValid: true,
|
|
370
|
+
score: observation.painScore,
|
|
371
|
+
sessionId,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (!triggerDecision.shouldCreateDiagnosticTask) {
|
|
375
|
+
SystemLogger.log(workspaceDir, 'TRIGGER_DECISION', JSON.stringify({
|
|
376
|
+
outcome: triggerDecision.outcome,
|
|
377
|
+
sourceKind: triggerDecision.sourceKind,
|
|
378
|
+
reason: triggerDecision.reason,
|
|
379
|
+
nextAction: triggerDecision.nextAction,
|
|
380
|
+
triageDecision: triggerDecision.triageDecision,
|
|
360
381
|
tool: event.toolName,
|
|
361
382
|
path: observation.relPath,
|
|
362
383
|
}));
|
|
363
384
|
return {
|
|
364
385
|
admitted: false,
|
|
365
386
|
stage: 'triage_evidence_only',
|
|
366
|
-
reason:
|
|
367
|
-
detail: `
|
|
387
|
+
reason: triggerDecision.reason,
|
|
388
|
+
detail: `outcome=${triggerDecision.outcome}, sourceKind=${triggerDecision.sourceKind}, nextAction=${triggerDecision.nextAction}`,
|
|
368
389
|
};
|
|
369
390
|
}
|
|
370
391
|
}
|
package/src/hooks/pain.ts
CHANGED
|
@@ -74,6 +74,20 @@ export async function emitPainDetectedEvent(wctx: WorkspaceContext, event: Evolu
|
|
|
74
74
|
const painData = event.data as PainDetectedData;
|
|
75
75
|
try {
|
|
76
76
|
const service = createPainToPrincipleService(wctx);
|
|
77
|
+
const isManual = painData.source === 'manual' || painData.source === 'pain' || painData.source === 'skill:pain';
|
|
78
|
+
|
|
79
|
+
// PEAT-B2: Record trigger decision for observability
|
|
80
|
+
if (isManual) {
|
|
81
|
+
SystemLogger.log(wctx.workspaceDir, 'TRIGGER_DECISION', JSON.stringify({
|
|
82
|
+
outcome: 'manual_owner_admitted',
|
|
83
|
+
sourceKind: 'owner_reported',
|
|
84
|
+
reason: 'Owner explicit manual pain. Bypasses triage and cooldown.',
|
|
85
|
+
nextAction: 'create_diagnostic_task',
|
|
86
|
+
painId: painData.painId,
|
|
87
|
+
score: painData.score,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
const result = await service.recordPain({
|
|
78
92
|
painId: painData.painId,
|
|
79
93
|
painType: painData.painType,
|
|
@@ -307,6 +321,20 @@ function handleManualPain(
|
|
|
307
321
|
payload = JSON.stringify({ reason: gate.reason, detail: '(log serialization failed)' });
|
|
308
322
|
}
|
|
309
323
|
SystemLogger.log(workspaceDir, 'PAIN_GATE_REJECTED', payload);
|
|
324
|
+
|
|
325
|
+
// PEAT-B2: Record trigger decision even when cooldown blocks manual pain
|
|
326
|
+
const painTriageFlag = loadPdConfigForPlugin(workspaceDir);
|
|
327
|
+
if (painTriageFlag.effective) {
|
|
328
|
+
SystemLogger.log(workspaceDir, 'TRIGGER_DECISION', JSON.stringify({
|
|
329
|
+
outcome: 'cooldown_skipped',
|
|
330
|
+
sourceKind: 'owner_reported',
|
|
331
|
+
reason: `Manual pain within cooldown: ${gate.detail}`,
|
|
332
|
+
nextAction: 'wait_for_cooldown_or_manual_retrigger',
|
|
333
|
+
isOwnerManual: true,
|
|
334
|
+
sessionId,
|
|
335
|
+
score: 100,
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
310
338
|
return;
|
|
311
339
|
}
|
|
312
340
|
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { handlePainCommand } from '../../src/commands/pain.js';
|
|
2
|
+
import { handlePainCommand, handlePainReportCommand } from '../../src/commands/pain.js';
|
|
3
3
|
import * as sessionTracker from '../../src/core/session-tracker.js';
|
|
4
4
|
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
5
5
|
|
|
6
6
|
vi.mock('../../src/core/session-tracker.js');
|
|
7
7
|
vi.mock('../../src/core/workspace-context.js');
|
|
8
|
+
vi.mock('../../src/core/pd-config-loader.js', () => ({
|
|
9
|
+
loadPdConfigForPlugin: vi.fn().mockReturnValue({ ok: true, effective: {}, source: 'defaults', warnings: [], errors: [] }),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('@principles/core/runtime-v2', () => ({
|
|
12
|
+
PainToPrincipleService: vi.fn(),
|
|
13
|
+
PrincipleTreeLedgerAdapter: vi.fn(function(this: any) { this.stateDir = ''; }),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { PainToPrincipleService } from '@principles/core/runtime-v2';
|
|
8
17
|
|
|
9
18
|
describe('Pain Command', () => {
|
|
10
19
|
const workspaceDir = '/mock/workspace';
|
|
@@ -106,3 +115,173 @@ describe('Pain Command', () => {
|
|
|
106
115
|
expect(result.text).toContain('approved samples');
|
|
107
116
|
});
|
|
108
117
|
});
|
|
118
|
+
|
|
119
|
+
describe('Pain Report Command (/pd-pain)', () => {
|
|
120
|
+
const workspaceDir = '/mock/workspace';
|
|
121
|
+
const sessionId = 's1';
|
|
122
|
+
|
|
123
|
+
const mockEvolutionReducer = { emitSync: vi.fn() };
|
|
124
|
+
const mockWctx = {
|
|
125
|
+
workspaceDir,
|
|
126
|
+
stateDir: '/mock/workspace/.state',
|
|
127
|
+
evolutionReducer: mockEvolutionReducer,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
vi.clearAllMocks();
|
|
132
|
+
vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue(mockWctx as any);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function runPainReport(args: string, lang = 'en') {
|
|
136
|
+
return handlePainReportCommand({
|
|
137
|
+
args,
|
|
138
|
+
config: { workspaceDir, language: lang },
|
|
139
|
+
sessionId,
|
|
140
|
+
} as any);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('rejects empty args', async () => {
|
|
144
|
+
const result = await runPainReport('');
|
|
145
|
+
expect(result.text).toContain('Please provide a pain reason');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('rejects missing session ID', async () => {
|
|
149
|
+
const result = await handlePainReportCommand({
|
|
150
|
+
args: 'something broke',
|
|
151
|
+
config: { workspaceDir, language: 'en' },
|
|
152
|
+
} as any);
|
|
153
|
+
expect(result.text).toContain('Session ID not available');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('reports success when recordPain returns succeeded', async () => {
|
|
157
|
+
const mockRecordPain = vi.fn().mockResolvedValue({
|
|
158
|
+
status: 'succeeded',
|
|
159
|
+
painId: 'manual_123_abc',
|
|
160
|
+
taskId: 'diagnosis_manual_123_abc',
|
|
161
|
+
candidateIds: [],
|
|
162
|
+
ledgerEntryIds: [],
|
|
163
|
+
observabilityWarnings: [],
|
|
164
|
+
latencyMs: 100,
|
|
165
|
+
});
|
|
166
|
+
vi.mocked(PainToPrincipleService).mockImplementation(function(this: any) { this.recordPain = mockRecordPain; } as any);
|
|
167
|
+
|
|
168
|
+
const result = await runPainReport('something broke');
|
|
169
|
+
expect(result.text).toContain('Pain recorded');
|
|
170
|
+
expect(result.text).toContain('manual_');
|
|
171
|
+
expect(result.text).not.toContain('not accepted');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('reports retried as pain recorded with retry info, NOT as "not accepted"', async () => {
|
|
175
|
+
const mockRecordPain = vi.fn().mockResolvedValue({
|
|
176
|
+
status: 'retried',
|
|
177
|
+
painId: 'manual_456_def',
|
|
178
|
+
taskId: 'diagnosis_manual_456_def',
|
|
179
|
+
failureCategory: 'output_invalid',
|
|
180
|
+
message: 'Diagnostician output failed validation',
|
|
181
|
+
candidateIds: [],
|
|
182
|
+
ledgerEntryIds: [],
|
|
183
|
+
observabilityWarnings: [],
|
|
184
|
+
latencyMs: 200,
|
|
185
|
+
});
|
|
186
|
+
vi.mocked(PainToPrincipleService).mockImplementation(function(this: any) { this.recordPain = mockRecordPain; } as any);
|
|
187
|
+
|
|
188
|
+
const result = await runPainReport('something broke');
|
|
189
|
+
expect(result.text).toContain('Pain recorded');
|
|
190
|
+
expect(result.text).toContain('retry');
|
|
191
|
+
expect(result.text).toContain('diagnosis_manual_456_def');
|
|
192
|
+
expect(result.text).toContain('output_invalid');
|
|
193
|
+
expect(result.text).toContain('/pd-status');
|
|
194
|
+
// Must NOT say "not accepted" or "failed"
|
|
195
|
+
expect(result.text).not.toContain('not accepted');
|
|
196
|
+
expect(result.text).not.toContain('未成功');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('reports retried in Chinese correctly', async () => {
|
|
200
|
+
const mockRecordPain = vi.fn().mockResolvedValue({
|
|
201
|
+
status: 'retried',
|
|
202
|
+
painId: 'manual_789_xyz',
|
|
203
|
+
taskId: 'diagnosis_manual_789_xyz',
|
|
204
|
+
failureCategory: 'output_invalid',
|
|
205
|
+
candidateIds: [],
|
|
206
|
+
ledgerEntryIds: [],
|
|
207
|
+
observabilityWarnings: [],
|
|
208
|
+
latencyMs: 200,
|
|
209
|
+
});
|
|
210
|
+
vi.mocked(PainToPrincipleService).mockImplementation(function(this: any) { this.recordPain = mockRecordPain; } as any);
|
|
211
|
+
|
|
212
|
+
const result = await runPainReport('something broke', 'zh');
|
|
213
|
+
expect(result.text).toContain('Pain 已记录');
|
|
214
|
+
expect(result.text).toContain('重试');
|
|
215
|
+
expect(result.text).not.toContain('未成功');
|
|
216
|
+
expect(result.text).not.toContain('not accepted');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('reports retried without failureCategory or message', async () => {
|
|
220
|
+
const mockRecordPain = vi.fn().mockResolvedValue({
|
|
221
|
+
status: 'retried',
|
|
222
|
+
painId: 'manual_000_nocat',
|
|
223
|
+
taskId: 'diagnosis_manual_000_nocat',
|
|
224
|
+
candidateIds: [],
|
|
225
|
+
ledgerEntryIds: [],
|
|
226
|
+
observabilityWarnings: [],
|
|
227
|
+
latencyMs: 150,
|
|
228
|
+
});
|
|
229
|
+
vi.mocked(PainToPrincipleService).mockImplementation(function(this: any) { this.recordPain = mockRecordPain; } as any);
|
|
230
|
+
|
|
231
|
+
const result = await runPainReport('something broke');
|
|
232
|
+
expect(result.text).toContain('Pain recorded');
|
|
233
|
+
expect(result.text).toContain('retry');
|
|
234
|
+
expect(result.text).toContain('diagnosis_manual_000_nocat');
|
|
235
|
+
// No error category or detail lines when absent
|
|
236
|
+
expect(result.text).not.toContain('Error category');
|
|
237
|
+
expect(result.text).not.toContain('Detail');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('reports failed as "not accepted" with reason', async () => {
|
|
241
|
+
const mockRecordPain = vi.fn().mockResolvedValue({
|
|
242
|
+
status: 'failed',
|
|
243
|
+
painId: 'manual_fail_1',
|
|
244
|
+
taskId: 'diagnosis_manual_fail_1',
|
|
245
|
+
failureCategory: 'runtime_unavailable',
|
|
246
|
+
message: 'No runner available',
|
|
247
|
+
candidateIds: [],
|
|
248
|
+
ledgerEntryIds: [],
|
|
249
|
+
observabilityWarnings: [],
|
|
250
|
+
latencyMs: 50,
|
|
251
|
+
});
|
|
252
|
+
vi.mocked(PainToPrincipleService).mockImplementation(function(this: any) { this.recordPain = mockRecordPain; } as any);
|
|
253
|
+
|
|
254
|
+
const result = await runPainReport('something broke');
|
|
255
|
+
expect(result.text).toContain('not accepted');
|
|
256
|
+
expect(result.text).toContain('failed');
|
|
257
|
+
expect(result.text).toContain('runtime_unavailable');
|
|
258
|
+
expect(result.text).toContain('No runner available');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('reports degraded as "not accepted"', async () => {
|
|
262
|
+
const mockRecordPain = vi.fn().mockResolvedValue({
|
|
263
|
+
status: 'degraded',
|
|
264
|
+
painId: 'manual_deg_1',
|
|
265
|
+
taskId: 'diagnosis_manual_deg_1',
|
|
266
|
+
candidateIds: [],
|
|
267
|
+
ledgerEntryIds: [],
|
|
268
|
+
observabilityWarnings: [],
|
|
269
|
+
latencyMs: 30,
|
|
270
|
+
});
|
|
271
|
+
vi.mocked(PainToPrincipleService).mockImplementation(function(this: any) { this.recordPain = mockRecordPain; } as any);
|
|
272
|
+
|
|
273
|
+
const result = await runPainReport('something broke');
|
|
274
|
+
expect(result.text).toContain('not accepted');
|
|
275
|
+
expect(result.text).toContain('degraded');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('reports error on exception', async () => {
|
|
279
|
+
vi.mocked(PainToPrincipleService).mockImplementation(function(this: any) {
|
|
280
|
+
throw new Error('DB connection failed');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const result = await runPainReport('something broke');
|
|
284
|
+
expect(result.text).toContain('Failed to record pain');
|
|
285
|
+
expect(result.text).toContain('DB connection failed');
|
|
286
|
+
});
|
|
287
|
+
});
|