principles-disciple 1.8.0 → 1.8.1
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/dist/core/config.d.ts +2 -0
- package/dist/core/session-tracker.d.ts +3 -1
- package/dist/core/session-tracker.js +12 -3
- package/dist/hooks/llm.d.ts +1 -0
- package/dist/hooks/llm.js +17 -70
- package/dist/hooks/progressive-trust-gate.d.ts +1 -0
- package/dist/hooks/progressive-trust-gate.js +45 -0
- package/dist/hooks/prompt.d.ts +2 -0
- package/dist/hooks/prompt.js +27 -6
- package/dist/hooks/subagent.js +2 -2
- package/dist/http/principles-console-route.js +114 -0
- package/dist/service/empathy-observer-manager.d.ts +46 -10
- package/dist/service/empathy-observer-manager.js +249 -64
- package/dist/service/evolution-worker.js +1 -0
- package/dist/service/health-query-service.d.ts +170 -0
- package/dist/service/health-query-service.js +662 -0
- package/dist/service/nocturnal-runtime.d.ts +2 -2
- package/dist/service/nocturnal-runtime.js +75 -4
- package/dist/service/nocturnal-service.js +2 -2
- package/dist/service/subagent-workflow/empathy-observer-workflow-manager.d.ts +48 -0
- package/dist/service/subagent-workflow/empathy-observer-workflow-manager.js +480 -0
- package/dist/service/subagent-workflow/index.d.ts +4 -0
- package/dist/service/subagent-workflow/index.js +3 -0
- package/dist/service/subagent-workflow/runtime-direct-driver.d.ts +77 -0
- package/dist/service/subagent-workflow/runtime-direct-driver.js +75 -0
- package/dist/service/subagent-workflow/types.d.ts +259 -0
- package/dist/service/subagent-workflow/types.js +11 -0
- package/dist/service/subagent-workflow/workflow-store.d.ts +26 -0
- package/dist/service/subagent-workflow/workflow-store.js +165 -0
- package/dist/tools/deep-reflect.js +2 -2
- package/openclaw.plugin.json +6 -1
- package/package.json +3 -3
|
@@ -22,6 +22,74 @@ import * as path from 'path';
|
|
|
22
22
|
import { listSessions } from '../core/session-tracker.js';
|
|
23
23
|
import { withLockAsync } from '../utils/file-lock.js';
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
|
+
// Session Key Parsing (replicated from openclaw/src/sessions/session-key-utils.ts)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Parse an agent-scoped session key. Returns null for non-agent keys. */
|
|
28
|
+
function parseAgentSessionKey(sessionKey) {
|
|
29
|
+
const raw = (sessionKey ?? '').trim().toLowerCase();
|
|
30
|
+
if (!raw)
|
|
31
|
+
return null;
|
|
32
|
+
const parts = raw.split(':').filter(Boolean);
|
|
33
|
+
if (parts.length < 3 || parts[0] !== 'agent')
|
|
34
|
+
return null;
|
|
35
|
+
const agentId = parts[1]?.trim();
|
|
36
|
+
const rest = parts.slice(2).join(':');
|
|
37
|
+
if (!agentId || !rest)
|
|
38
|
+
return null;
|
|
39
|
+
return { agentId, rest };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns true if the session was created by a system process (cron, boot, probe, subagent, acp).
|
|
43
|
+
* Uses OpenClaw's native session key patterns to avoid false positives.
|
|
44
|
+
*
|
|
45
|
+
* System patterns:
|
|
46
|
+
* - boot- prefix: boot sessions (e.g. boot-2026-04-02_10-43-45)
|
|
47
|
+
* - probe- prefix: probe sessions (e.g. probe-glm-4.9-xxx)
|
|
48
|
+
* - cron:<...> rest: cron run sessions (agent:<id>:cron:...)
|
|
49
|
+
* - subagent: prefix: subagent sessions
|
|
50
|
+
* - acp: prefix: acp sessions
|
|
51
|
+
*
|
|
52
|
+
* Excluded (NOT system sessions):
|
|
53
|
+
* - User sessions like agent:main:feishu:user:xxx — third component is channel type, NOT cron/subagent/acp
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Returns true if the session was created by a system process (cron, boot, probe, subagent, acp).
|
|
57
|
+
* Uses OpenClaw's native session key patterns to avoid false positives.
|
|
58
|
+
*
|
|
59
|
+
* Detection priority (most reliable first):
|
|
60
|
+
* 1. trigger field: Most reliable - explicitly set by OpenClaw ("cron", "heartbeat", "subagent")
|
|
61
|
+
* 2. sessionKey patterns: Secondary confirmation via structured key (agent:main:cron:...)
|
|
62
|
+
* 3. sessionId prefix: Fallback for boot-, probe- prefixed IDs
|
|
63
|
+
*
|
|
64
|
+
* System patterns:
|
|
65
|
+
* - trigger === 'cron' | 'heartbeat' | 'subagent'
|
|
66
|
+
* - sessionKey contains: cron:, subagent:, acp:
|
|
67
|
+
* - sessionId starts with: boot-, probe-
|
|
68
|
+
*/
|
|
69
|
+
function isSystemSession(state) {
|
|
70
|
+
const { sessionId, sessionKey, trigger } = state;
|
|
71
|
+
// Primary: trigger field is explicitly set by OpenClaw - most reliable
|
|
72
|
+
if (trigger === 'cron' || trigger === 'heartbeat' || trigger === 'subagent') {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
// Secondary: sessionKey pattern matching
|
|
76
|
+
if (sessionKey) {
|
|
77
|
+
const raw = sessionKey.toLowerCase();
|
|
78
|
+
if (raw.includes('cron:'))
|
|
79
|
+
return true;
|
|
80
|
+
if (raw.includes('subagent:'))
|
|
81
|
+
return true;
|
|
82
|
+
if (raw.includes('acp:'))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
// Fallback: sessionId prefix patterns (boot-, probe-)
|
|
86
|
+
if (sessionId?.startsWith('boot-'))
|
|
87
|
+
return true;
|
|
88
|
+
if (sessionId?.startsWith('probe-'))
|
|
89
|
+
return true;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
25
93
|
// Constants
|
|
26
94
|
// ---------------------------------------------------------------------------
|
|
27
95
|
/** File name for nocturnal runtime bookkeeping */
|
|
@@ -130,14 +198,17 @@ export function checkWorkspaceIdle(workspaceDir, options = {}, trajectoryLastAct
|
|
|
130
198
|
// Separate active vs abandoned sessions
|
|
131
199
|
const abandonedSessions = [];
|
|
132
200
|
let mostRecentActivityAt = 0;
|
|
133
|
-
let
|
|
201
|
+
let userActiveSessions = 0;
|
|
134
202
|
for (const session of sessions) {
|
|
203
|
+
// Skip system sessions (cron, boot, probe, subagent, acp) from idle determination
|
|
204
|
+
if (isSystemSession(session))
|
|
205
|
+
continue;
|
|
135
206
|
const inactiveFor = now - session.lastActivityAt;
|
|
136
207
|
if (inactiveFor > abandonedThresholdMs) {
|
|
137
208
|
abandonedSessions.push(session.sessionId);
|
|
138
209
|
}
|
|
139
210
|
else {
|
|
140
|
-
|
|
211
|
+
userActiveSessions++;
|
|
141
212
|
if (session.lastActivityAt > mostRecentActivityAt) {
|
|
142
213
|
mostRecentActivityAt = session.lastActivityAt;
|
|
143
214
|
}
|
|
@@ -171,7 +242,7 @@ export function checkWorkspaceIdle(workspaceDir, options = {}, trajectoryLastAct
|
|
|
171
242
|
isIdle,
|
|
172
243
|
mostRecentActivityAt,
|
|
173
244
|
idleForMs,
|
|
174
|
-
|
|
245
|
+
userActiveSessions,
|
|
175
246
|
abandonedSessionIds: abandonedSessions,
|
|
176
247
|
trajectoryGuardrailConfirmsIdle,
|
|
177
248
|
reason,
|
|
@@ -339,7 +410,7 @@ export function checkPreflight(workspaceDir, stateDir, principleId, trajectoryLa
|
|
|
339
410
|
if (cooldown.quotaExhausted) {
|
|
340
411
|
blockers.push(`Quota exhausted (${DEFAULT_MAX_RUNS_PER_WINDOW} runs per ${DEFAULT_QUOTA_WINDOW_MS / 3600000}h window)`);
|
|
341
412
|
}
|
|
342
|
-
if (idle.abandonedSessionIds.length > 0 && idle.
|
|
413
|
+
if (idle.abandonedSessionIds.length > 0 && idle.userActiveSessions === 0) {
|
|
343
414
|
// Only block if ALL sessions are abandoned (meaning workspace truly has no activity)
|
|
344
415
|
// If some sessions are active, we trust the session-based idle check
|
|
345
416
|
}
|
|
@@ -217,7 +217,7 @@ export function executeNocturnalReflection(workspaceDir, stateDir, options = {})
|
|
|
217
217
|
diagnostics,
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
|
-
diagnostics.idle = { isIdle: true, mostRecentActivityAt: 0, idleForMs: 0,
|
|
220
|
+
diagnostics.idle = { isIdle: true, mostRecentActivityAt: 0, idleForMs: 0, userActiveSessions: 0, abandonedSessionIds: [], trajectoryGuardrailConfirmsIdle: true, reason: 'preflight passed' };
|
|
221
221
|
// -------------------------------------------------------------------------
|
|
222
222
|
// Step 4: Record run start (begin cooldown window)
|
|
223
223
|
// -------------------------------------------------------------------------
|
|
@@ -597,7 +597,7 @@ async function executeNocturnalReflectionWithAdapter(workspaceDir, stateDir, opt
|
|
|
597
597
|
diagnostics,
|
|
598
598
|
};
|
|
599
599
|
}
|
|
600
|
-
diagnostics.idle = { isIdle: true, mostRecentActivityAt: 0, idleForMs: 0,
|
|
600
|
+
diagnostics.idle = { isIdle: true, mostRecentActivityAt: 0, idleForMs: 0, userActiveSessions: 0, abandonedSessionIds: [], trajectoryGuardrailConfirmsIdle: true, reason: 'preflight passed' };
|
|
601
601
|
// Step 3: Record run start
|
|
602
602
|
void recordRunStart(stateDir, selectedPrincipleId).catch((err) => {
|
|
603
603
|
console.warn(`[nocturnal-service] Failed to record run start: ${String(err)}`);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PluginLogger } from '../../openclaw-sdk.js';
|
|
2
|
+
import type { WorkflowManager, WorkflowHandle, SubagentWorkflowSpec, WorkflowDebugSummary, EmpathyObserverPayload, EmpathyResult } from './types.js';
|
|
3
|
+
import { RuntimeDirectDriver } from './runtime-direct-driver.js';
|
|
4
|
+
export interface EmpathyObserverWorkflowOptions {
|
|
5
|
+
workspaceDir: string;
|
|
6
|
+
logger: PluginLogger;
|
|
7
|
+
subagent: RuntimeDirectDriver['subagent'];
|
|
8
|
+
}
|
|
9
|
+
export declare class EmpathyObserverWorkflowManager implements WorkflowManager {
|
|
10
|
+
private readonly store;
|
|
11
|
+
private readonly driver;
|
|
12
|
+
private readonly logger;
|
|
13
|
+
private readonly workspaceDir;
|
|
14
|
+
private activeWorkflows;
|
|
15
|
+
private completedWorkflows;
|
|
16
|
+
private workflowSpecs;
|
|
17
|
+
constructor(opts: EmpathyObserverWorkflowOptions);
|
|
18
|
+
startWorkflow<TResult>(spec: SubagentWorkflowSpec<TResult>, options: {
|
|
19
|
+
parentSessionId: string;
|
|
20
|
+
workspaceDir?: string;
|
|
21
|
+
taskInput: unknown;
|
|
22
|
+
metadata?: Record<string, unknown>;
|
|
23
|
+
}): Promise<WorkflowHandle>;
|
|
24
|
+
private buildRunParams;
|
|
25
|
+
static buildEmpathyPrompt(userMessage: string): string;
|
|
26
|
+
private scheduleWaitPoll;
|
|
27
|
+
notifyWaitResult(workflowId: string, status: 'ok' | 'error' | 'timeout', error?: string): Promise<void>;
|
|
28
|
+
notifyLifecycleEvent(workflowId: string, event: 'subagent_spawned' | 'subagent_ended', data?: {
|
|
29
|
+
outcome?: 'ok' | 'error' | 'timeout' | 'killed' | 'reset' | 'deleted';
|
|
30
|
+
error?: string;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
finalizeOnce(workflowId: string): Promise<void>;
|
|
33
|
+
sweepExpiredWorkflows(maxAgeMs?: number): Promise<number>;
|
|
34
|
+
getWorkflowDebugSummary(workflowId: string, eventLimit?: number): Promise<WorkflowDebugSummary | null>;
|
|
35
|
+
private generateWorkflowId;
|
|
36
|
+
private buildChildSessionKey;
|
|
37
|
+
private extractAssistantText;
|
|
38
|
+
parseEmpathyPayload(rawText: string): EmpathyObserverPayload | null;
|
|
39
|
+
private isCompleted;
|
|
40
|
+
private markCompleted;
|
|
41
|
+
dispose(): void;
|
|
42
|
+
}
|
|
43
|
+
export declare function createEmpathyObserverWorkflowManager(opts: EmpathyObserverWorkflowOptions): EmpathyObserverWorkflowManager;
|
|
44
|
+
/**
|
|
45
|
+
* EmpathyObserver workflow specification.
|
|
46
|
+
* This spec drives EmpathyObserverWorkflowManager for the empathy observer workflow.
|
|
47
|
+
*/
|
|
48
|
+
export declare const empathyObserverWorkflowSpec: SubagentWorkflowSpec<EmpathyResult>;
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { RuntimeDirectDriver } from './runtime-direct-driver.js';
|
|
2
|
+
import { WorkflowStore } from './workflow-store.js';
|
|
3
|
+
import { isSubagentRuntimeAvailable } from '../../utils/subagent-probe.js';
|
|
4
|
+
import { WorkspaceContext } from '../../core/workspace-context.js';
|
|
5
|
+
import { trackFriction } from '../../core/session-tracker.js';
|
|
6
|
+
const WORKFLOW_SESSION_PREFIX = 'agent:main:subagent:workflow-';
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
export class EmpathyObserverWorkflowManager {
|
|
10
|
+
store;
|
|
11
|
+
driver;
|
|
12
|
+
logger;
|
|
13
|
+
workspaceDir;
|
|
14
|
+
activeWorkflows = new Map();
|
|
15
|
+
completedWorkflows = new Map();
|
|
16
|
+
workflowSpecs = new Map();
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.workspaceDir = opts.workspaceDir;
|
|
19
|
+
this.logger = opts.logger;
|
|
20
|
+
this.store = new WorkflowStore({ workspaceDir: opts.workspaceDir });
|
|
21
|
+
this.driver = new RuntimeDirectDriver({ subagent: opts.subagent, logger: opts.logger });
|
|
22
|
+
}
|
|
23
|
+
async startWorkflow(spec, options) {
|
|
24
|
+
const workflowId = this.generateWorkflowId();
|
|
25
|
+
const childSessionKey = this.buildChildSessionKey(options.parentSessionId);
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const metadata = {
|
|
28
|
+
parentSessionId: options.parentSessionId,
|
|
29
|
+
workspaceDir: options.workspaceDir,
|
|
30
|
+
taskInput: options.taskInput,
|
|
31
|
+
startedAt: now,
|
|
32
|
+
workflowType: spec.workflowType,
|
|
33
|
+
...options.metadata,
|
|
34
|
+
};
|
|
35
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] Starting workflow: workflowId=${workflowId}, type=${spec.workflowType}`);
|
|
36
|
+
// Surface degrade: skip boot sessions (they run outside gateway request context)
|
|
37
|
+
if (options.parentSessionId.startsWith('boot-')) {
|
|
38
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] Skipping workflow: boot session (gateway request context unavailable)`);
|
|
39
|
+
throw new Error(`EmpathyObserverWorkflowManager: cannot start workflow for boot session`);
|
|
40
|
+
}
|
|
41
|
+
// Surface degrade: check subagent runtime availability before calling run()
|
|
42
|
+
if (!isSubagentRuntimeAvailable(this.driver.getSubagent())) {
|
|
43
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] Skipping workflow: subagent runtime unavailable`);
|
|
44
|
+
throw new Error(`EmpathyObserverWorkflowManager: subagent runtime unavailable`);
|
|
45
|
+
}
|
|
46
|
+
if (spec.transport !== 'runtime_direct') {
|
|
47
|
+
throw new Error(`EmpathyObserverWorkflowManager only supports runtime_direct transport`);
|
|
48
|
+
}
|
|
49
|
+
const runParams = this.buildRunParams(spec, options, childSessionKey);
|
|
50
|
+
const runResult = await this.driver.run(runParams);
|
|
51
|
+
this.store.createWorkflow({
|
|
52
|
+
workflow_id: workflowId,
|
|
53
|
+
workflow_type: spec.workflowType,
|
|
54
|
+
transport: spec.transport,
|
|
55
|
+
parent_session_id: options.parentSessionId,
|
|
56
|
+
child_session_key: childSessionKey,
|
|
57
|
+
run_id: runResult.runId,
|
|
58
|
+
state: 'active',
|
|
59
|
+
created_at: now,
|
|
60
|
+
updated_at: now,
|
|
61
|
+
metadata_json: JSON.stringify(metadata),
|
|
62
|
+
});
|
|
63
|
+
this.store.recordEvent(workflowId, 'spawned', null, 'active', 'subagent spawned', { runId: runResult.runId });
|
|
64
|
+
this.workflowSpecs.set(workflowId, spec);
|
|
65
|
+
this.scheduleWaitPoll(workflowId, spec.timeoutMs ?? DEFAULT_TIMEOUT_MS, runResult.runId);
|
|
66
|
+
return {
|
|
67
|
+
workflowId,
|
|
68
|
+
childSessionKey,
|
|
69
|
+
runId: runResult.runId,
|
|
70
|
+
state: 'active',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
buildRunParams(spec, options, childSessionKey) {
|
|
74
|
+
const message = spec.buildPrompt(options.taskInput, {
|
|
75
|
+
parentSessionId: options.parentSessionId,
|
|
76
|
+
workspaceDir: options.workspaceDir,
|
|
77
|
+
taskInput: options.taskInput,
|
|
78
|
+
startedAt: Date.now(),
|
|
79
|
+
workflowType: spec.workflowType,
|
|
80
|
+
...(options.metadata ?? {}),
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
sessionKey: childSessionKey,
|
|
84
|
+
message,
|
|
85
|
+
lane: 'subagent',
|
|
86
|
+
deliver: false,
|
|
87
|
+
idempotencyKey: `${options.parentSessionId}:${Date.now()}`,
|
|
88
|
+
expectsCompletionMessage: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
static buildEmpathyPrompt(userMessage) {
|
|
92
|
+
return [
|
|
93
|
+
'You are an empathy observer.',
|
|
94
|
+
'Analyze ONLY the user message and return strict JSON (no markdown):',
|
|
95
|
+
'{"damageDetected": boolean, "severity": "mild|moderate|severe", "confidence": number, "reason": string}',
|
|
96
|
+
`User message: ${JSON.stringify(userMessage.trim())}`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
scheduleWaitPoll(workflowId, timeoutMs, runId) {
|
|
100
|
+
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
101
|
+
const timeout = setTimeout(async () => {
|
|
102
|
+
try {
|
|
103
|
+
const result = await this.driver.wait({ runId, timeoutMs: effectiveTimeoutMs });
|
|
104
|
+
await this.notifyWaitResult(workflowId, result.status, result.error);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
this.logger.error(`[PD:EmpathyObserverWorkflow] Wait poll failed: ${String(error)}`);
|
|
108
|
+
await this.notifyWaitResult(workflowId, 'error', String(error));
|
|
109
|
+
}
|
|
110
|
+
}, 100);
|
|
111
|
+
this.activeWorkflows.set(workflowId, timeout);
|
|
112
|
+
}
|
|
113
|
+
async notifyWaitResult(workflowId, status, error) {
|
|
114
|
+
const workflow = this.store.getWorkflow(workflowId);
|
|
115
|
+
if (!workflow) {
|
|
116
|
+
this.logger.warn(`[PD:EmpathyObserverWorkflow] notifyWaitResult: workflow not found: ${workflowId}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (workflow.state === 'completed' || workflow.state === 'terminal_error' || workflow.state === 'expired') {
|
|
120
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] notifyWaitResult: ignoring terminal workflow: ${workflowId}, state=${workflow.state}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] notifyWaitResult: workflowId=${workflowId}, status=${status}`);
|
|
124
|
+
const previousState = workflow.state;
|
|
125
|
+
this.store.updateWorkflowState(workflowId, 'wait_result');
|
|
126
|
+
this.store.recordEvent(workflowId, 'wait_result', previousState, 'wait_result', `wait completed: ${status}`, { error });
|
|
127
|
+
const spec = this.workflowSpecs.get(workflowId);
|
|
128
|
+
const shouldFinalize = spec ? spec.shouldFinalizeOnWaitStatus(status) : status === 'ok';
|
|
129
|
+
if (shouldFinalize) {
|
|
130
|
+
await this.finalizeOnce(workflowId);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
this.store.updateWorkflowState(workflowId, 'terminal_error');
|
|
134
|
+
this.store.recordEvent(workflowId, 'finalize_skipped', 'wait_result', 'terminal_error', `wait status: ${status}`, { error });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async notifyLifecycleEvent(workflowId, event, data) {
|
|
138
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] notifyLifecycleEvent: workflowId=${workflowId}, event=${event}`);
|
|
139
|
+
if (event === 'subagent_ended' && data?.outcome) {
|
|
140
|
+
await this.notifyWaitResult(workflowId, data.outcome === 'ok' ? 'ok' : data.outcome === 'error' ? 'error' : 'timeout', data.error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async finalizeOnce(workflowId) {
|
|
144
|
+
const workflow = this.store.getWorkflow(workflowId);
|
|
145
|
+
if (!workflow) {
|
|
146
|
+
this.logger.warn(`[PD:EmpathyObserverWorkflow] finalizeOnce: workflow not found: ${workflowId}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const spec = this.workflowSpecs.get(workflowId);
|
|
150
|
+
if (!spec) {
|
|
151
|
+
throw new Error(`Workflow spec not registered for ${workflowId}`);
|
|
152
|
+
}
|
|
153
|
+
if (this.isCompleted(workflowId)) {
|
|
154
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] finalizeOnce: already completed: ${workflowId}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] Finalizing workflow: ${workflowId}`);
|
|
158
|
+
this.store.updateWorkflowState(workflowId, 'finalizing');
|
|
159
|
+
try {
|
|
160
|
+
const result = await this.driver.getResult({ sessionKey: workflow.child_session_key, limit: 20 });
|
|
161
|
+
const metadata = JSON.parse(workflow.metadata_json);
|
|
162
|
+
const parsed = await spec.parseResult({
|
|
163
|
+
messages: result.messages,
|
|
164
|
+
assistantTexts: result.assistantTexts,
|
|
165
|
+
metadata,
|
|
166
|
+
waitStatus: 'ok',
|
|
167
|
+
});
|
|
168
|
+
if (!parsed) {
|
|
169
|
+
this.store.updateWorkflowState(workflowId, 'terminal_error');
|
|
170
|
+
this.store.recordEvent(workflowId, 'parse_failed', 'finalizing', 'terminal_error', 'spec.parseResult returned null', {});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await spec.persistResult({
|
|
174
|
+
result: parsed,
|
|
175
|
+
metadata,
|
|
176
|
+
workspaceDir: this.workspaceDir,
|
|
177
|
+
});
|
|
178
|
+
this.store.recordEvent(workflowId, 'persisted', 'finalizing', 'finalizing', 'result persisted', {});
|
|
179
|
+
if (spec.shouldDeleteSessionAfterFinalize && workflow.run_id) {
|
|
180
|
+
try {
|
|
181
|
+
await this.driver.cleanup({ sessionKey: workflow.child_session_key });
|
|
182
|
+
this.store.updateCleanupState(workflowId, 'completed');
|
|
183
|
+
}
|
|
184
|
+
catch (cleanupError) {
|
|
185
|
+
this.logger.error(`[PD:EmpathyObserverWorkflow] cleanup failed after persistence: ${String(cleanupError)}`);
|
|
186
|
+
this.store.updateCleanupState(workflowId, 'failed');
|
|
187
|
+
this.store.updateWorkflowState(workflowId, 'cleanup_pending');
|
|
188
|
+
this.store.recordEvent(workflowId, 'cleanup_failed', 'finalizing', 'cleanup_pending', String(cleanupError), {});
|
|
189
|
+
this.markCompleted(workflowId);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.store.updateWorkflowState(workflowId, 'completed');
|
|
194
|
+
this.store.recordEvent(workflowId, 'finalized', 'finalizing', 'completed', 'success', {});
|
|
195
|
+
this.markCompleted(workflowId);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
this.logger.error(`[PD:EmpathyObserverWorkflow] finalizeOnce failed: ${String(error)}`);
|
|
199
|
+
this.store.updateWorkflowState(workflowId, 'terminal_error');
|
|
200
|
+
this.store.recordEvent(workflowId, 'finalize_error', 'finalizing', 'terminal_error', String(error), {});
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async sweepExpiredWorkflows(maxAgeMs = DEFAULT_TTL_MS) {
|
|
205
|
+
const expired = this.store.getExpiredWorkflows(maxAgeMs);
|
|
206
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] sweepExpiredWorkflows: found ${expired.length} expired`);
|
|
207
|
+
for (const workflow of expired) {
|
|
208
|
+
try {
|
|
209
|
+
this.logger.info(`[PD:EmpathyObserverWorkflow] Sweeping expired workflow: ${workflow.workflow_id}`);
|
|
210
|
+
await this.driver.cleanup({ sessionKey: workflow.child_session_key });
|
|
211
|
+
this.store.updateCleanupState(workflow.workflow_id, 'completed');
|
|
212
|
+
this.store.updateWorkflowState(workflow.workflow_id, 'expired');
|
|
213
|
+
this.store.recordEvent(workflow.workflow_id, 'swept', workflow.state, 'expired', 'TTL expired', {});
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
this.logger.error(`[PD:EmpathyObserverWorkflow] Sweep cleanup failed for ${workflow.workflow_id}: ${String(error)}`);
|
|
217
|
+
this.store.updateCleanupState(workflow.workflow_id, 'failed');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return expired.length;
|
|
221
|
+
}
|
|
222
|
+
async getWorkflowDebugSummary(workflowId, eventLimit = 10) {
|
|
223
|
+
const workflow = this.store.getWorkflow(workflowId);
|
|
224
|
+
if (!workflow)
|
|
225
|
+
return null;
|
|
226
|
+
const metadata = JSON.parse(workflow.metadata_json);
|
|
227
|
+
const recentEvents = this.store
|
|
228
|
+
.getEvents(workflowId)
|
|
229
|
+
.slice(-eventLimit)
|
|
230
|
+
.map((event) => ({
|
|
231
|
+
eventType: event.event_type,
|
|
232
|
+
fromState: event.from_state,
|
|
233
|
+
toState: event.to_state,
|
|
234
|
+
reason: event.reason,
|
|
235
|
+
createdAt: event.created_at,
|
|
236
|
+
payload: JSON.parse(event.payload_json || '{}'),
|
|
237
|
+
}));
|
|
238
|
+
return {
|
|
239
|
+
workflowId: workflow.workflow_id,
|
|
240
|
+
workflowType: workflow.workflow_type,
|
|
241
|
+
transport: workflow.transport,
|
|
242
|
+
parentSessionId: workflow.parent_session_id,
|
|
243
|
+
childSessionKey: workflow.child_session_key,
|
|
244
|
+
runId: workflow.run_id,
|
|
245
|
+
state: workflow.state,
|
|
246
|
+
cleanupState: workflow.cleanup_state,
|
|
247
|
+
lastObservedAt: workflow.last_observed_at ?? null,
|
|
248
|
+
metadata,
|
|
249
|
+
recentEvents,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
generateWorkflowId() {
|
|
253
|
+
return `wf_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
|
254
|
+
}
|
|
255
|
+
buildChildSessionKey(parentSessionId) {
|
|
256
|
+
const safeParentSessionId = parentSessionId
|
|
257
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
258
|
+
.substring(0, 64);
|
|
259
|
+
const timestamp = Date.now();
|
|
260
|
+
return `${WORKFLOW_SESSION_PREFIX}${safeParentSessionId}-${timestamp}`;
|
|
261
|
+
}
|
|
262
|
+
extractAssistantText(messages, assistantTexts) {
|
|
263
|
+
if (assistantTexts && assistantTexts.length > 0) {
|
|
264
|
+
return assistantTexts[assistantTexts.length - 1] || '';
|
|
265
|
+
}
|
|
266
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
267
|
+
const msg = messages[i];
|
|
268
|
+
if (msg?.role !== 'assistant')
|
|
269
|
+
continue;
|
|
270
|
+
if (typeof msg.content === 'string')
|
|
271
|
+
return msg.content;
|
|
272
|
+
if (Array.isArray(msg.content)) {
|
|
273
|
+
const txt = msg.content
|
|
274
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
275
|
+
.map((part) => part.text)
|
|
276
|
+
.join('\n');
|
|
277
|
+
if (txt)
|
|
278
|
+
return txt;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
parseEmpathyPayload(rawText) {
|
|
284
|
+
if (!rawText?.trim())
|
|
285
|
+
return null;
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(rawText.trim());
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
const match = rawText.match(/\{[\s\S]*\}/);
|
|
291
|
+
if (!match) {
|
|
292
|
+
this.logger.warn('[PD:EmpathyObserverWorkflow] Observer payload is not valid JSON');
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(match[0]);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
this.logger.warn('[PD:EmpathyObserverWorkflow] Failed to parse observer JSON payload');
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
isCompleted(workflowId) {
|
|
305
|
+
const timestamp = this.completedWorkflows.get(workflowId);
|
|
306
|
+
if (!timestamp)
|
|
307
|
+
return false;
|
|
308
|
+
if (Date.now() - timestamp > 5 * 60 * 1000) {
|
|
309
|
+
this.completedWorkflows.delete(workflowId);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
markCompleted(workflowId) {
|
|
315
|
+
this.completedWorkflows.set(workflowId, Date.now());
|
|
316
|
+
this.workflowSpecs.delete(workflowId);
|
|
317
|
+
const timeout = this.activeWorkflows.get(workflowId);
|
|
318
|
+
if (timeout) {
|
|
319
|
+
clearTimeout(timeout);
|
|
320
|
+
this.activeWorkflows.delete(workflowId);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
dispose() {
|
|
324
|
+
for (const timeout of this.activeWorkflows.values()) {
|
|
325
|
+
clearTimeout(timeout);
|
|
326
|
+
}
|
|
327
|
+
this.activeWorkflows.clear();
|
|
328
|
+
this.store.dispose();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export function createEmpathyObserverWorkflowManager(opts) {
|
|
332
|
+
return new EmpathyObserverWorkflowManager(opts);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Extract raw assistant text from messages or assistantTexts array.
|
|
336
|
+
*/
|
|
337
|
+
function extractAssistantTextForSpec(messages, assistantTexts) {
|
|
338
|
+
if (assistantTexts && assistantTexts.length > 0) {
|
|
339
|
+
return assistantTexts[assistantTexts.length - 1] || '';
|
|
340
|
+
}
|
|
341
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
342
|
+
const msg = messages[i];
|
|
343
|
+
if (msg?.role !== 'assistant')
|
|
344
|
+
continue;
|
|
345
|
+
if (typeof msg.content === 'string')
|
|
346
|
+
return msg.content;
|
|
347
|
+
if (Array.isArray(msg.content)) {
|
|
348
|
+
const txt = msg.content
|
|
349
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
350
|
+
.map((part) => part.text)
|
|
351
|
+
.join('\n');
|
|
352
|
+
if (txt)
|
|
353
|
+
return txt;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return '';
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Parse empathy observer JSON payload from raw text.
|
|
360
|
+
*/
|
|
361
|
+
function parseEmpathyPayloadForSpec(rawText) {
|
|
362
|
+
if (!rawText?.trim())
|
|
363
|
+
return null;
|
|
364
|
+
try {
|
|
365
|
+
return JSON.parse(rawText.trim());
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
const match = rawText.match(/\{[\s\S]*\}/);
|
|
369
|
+
if (!match)
|
|
370
|
+
return null;
|
|
371
|
+
try {
|
|
372
|
+
return JSON.parse(match[0]);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Normalize severity to valid enum.
|
|
381
|
+
*/
|
|
382
|
+
function normalizeSeverityForSpec(severity) {
|
|
383
|
+
if (severity === 'severe')
|
|
384
|
+
return 'severe';
|
|
385
|
+
if (severity === 'moderate')
|
|
386
|
+
return 'moderate';
|
|
387
|
+
return 'mild';
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Normalize confidence to [0, 1] range.
|
|
391
|
+
*/
|
|
392
|
+
function normalizeConfidenceForSpec(value) {
|
|
393
|
+
if (!Number.isFinite(value))
|
|
394
|
+
return 1;
|
|
395
|
+
return Math.max(0, Math.min(1, Number(value)));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Calculate pain score from severity using config.
|
|
399
|
+
*/
|
|
400
|
+
function scoreFromSeverityForSpec(severity, wctx) {
|
|
401
|
+
if (severity === 'severe')
|
|
402
|
+
return Number(wctx.config.get('empathy_engine.penalties.severe') ?? 40);
|
|
403
|
+
if (severity === 'moderate')
|
|
404
|
+
return Number(wctx.config.get('empathy_engine.penalties.moderate') ?? 25);
|
|
405
|
+
return Number(wctx.config.get('empathy_engine.penalties.mild') ?? 10);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* EmpathyObserver workflow specification.
|
|
409
|
+
* This spec drives EmpathyObserverWorkflowManager for the empathy observer workflow.
|
|
410
|
+
*/
|
|
411
|
+
export const empathyObserverWorkflowSpec = {
|
|
412
|
+
workflowType: 'empathy-observer',
|
|
413
|
+
transport: 'runtime_direct',
|
|
414
|
+
timeoutMs: 30_000,
|
|
415
|
+
ttlMs: 300_000,
|
|
416
|
+
shouldDeleteSessionAfterFinalize: true,
|
|
417
|
+
buildPrompt(taskInput, _metadata) {
|
|
418
|
+
const userMessage = String(taskInput).trim();
|
|
419
|
+
return [
|
|
420
|
+
'You are an empathy observer.',
|
|
421
|
+
'Analyze ONLY the user message and return strict JSON (no markdown):',
|
|
422
|
+
'{"damageDetected": boolean, "severity": "mild|moderate|severe", "confidence": number, "reason": string}',
|
|
423
|
+
`User message: ${JSON.stringify(userMessage)}`,
|
|
424
|
+
].join('\n');
|
|
425
|
+
},
|
|
426
|
+
async parseResult(ctx) {
|
|
427
|
+
const rawText = extractAssistantTextForSpec(ctx.messages, ctx.assistantTexts);
|
|
428
|
+
const payload = parseEmpathyPayloadForSpec(rawText);
|
|
429
|
+
if (!payload)
|
|
430
|
+
return null;
|
|
431
|
+
return {
|
|
432
|
+
damageDetected: payload.damageDetected ?? false,
|
|
433
|
+
severity: normalizeSeverityForSpec(payload.severity),
|
|
434
|
+
confidence: normalizeConfidenceForSpec(payload.confidence),
|
|
435
|
+
reason: payload.reason ?? '',
|
|
436
|
+
painScore: 0,
|
|
437
|
+
};
|
|
438
|
+
},
|
|
439
|
+
async persistResult(ctx) {
|
|
440
|
+
const { result, metadata, workspaceDir } = ctx;
|
|
441
|
+
if (!result.damageDetected)
|
|
442
|
+
return;
|
|
443
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
|
|
444
|
+
const painScore = scoreFromSeverityForSpec(result.severity, wctx);
|
|
445
|
+
trackFriction(metadata.parentSessionId, painScore, `observer_empathy_${result.severity}`, workspaceDir, { source: 'user_empathy' });
|
|
446
|
+
const eventId = `emp_obs_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
447
|
+
wctx.eventLog.recordPainSignal(metadata.parentSessionId, {
|
|
448
|
+
score: painScore,
|
|
449
|
+
source: 'user_empathy',
|
|
450
|
+
reason: result.reason || 'Empathy observer detected likely user frustration.',
|
|
451
|
+
isRisky: false,
|
|
452
|
+
origin: 'system_infer',
|
|
453
|
+
severity: result.severity,
|
|
454
|
+
confidence: result.confidence,
|
|
455
|
+
detection_mode: 'structured',
|
|
456
|
+
deduped: false,
|
|
457
|
+
trigger_text_excerpt: '',
|
|
458
|
+
raw_score: painScore,
|
|
459
|
+
calibrated_score: painScore,
|
|
460
|
+
eventId,
|
|
461
|
+
});
|
|
462
|
+
try {
|
|
463
|
+
wctx.trajectory?.recordPainEvent?.({
|
|
464
|
+
sessionId: metadata.parentSessionId,
|
|
465
|
+
source: 'user_empathy',
|
|
466
|
+
score: painScore,
|
|
467
|
+
reason: result.reason || 'Empathy observer detected likely user frustration.',
|
|
468
|
+
severity: result.severity,
|
|
469
|
+
origin: 'system_infer',
|
|
470
|
+
confidence: result.confidence,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
console.warn(`[PD:EmpathyObserverWorkflow] Failed to persist trajectory: ${String(error)}`);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
shouldFinalizeOnWaitStatus(status) {
|
|
478
|
+
return status === 'ok';
|
|
479
|
+
},
|
|
480
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { RuntimeDirectDriver, type TransportDriver, type RunParams, type RunResult, type WaitParams, type WaitResult, type GetResultParams, type GetResultResult, type CleanupParams, } from './runtime-direct-driver.js';
|
|
2
|
+
export { WorkflowStore, type WorkflowStoreOptions } from './workflow-store.js';
|
|
3
|
+
export { EmpathyObserverWorkflowManager, createEmpathyObserverWorkflowManager, empathyObserverWorkflowSpec, type EmpathyObserverWorkflowOptions, } from './empathy-observer-workflow-manager.js';
|
|
4
|
+
export type { WorkflowState, WorkflowTransport, WorkflowMetadata, WorkflowResultContext, WorkflowPersistContext, WorkflowHandle, SubagentWorkflowSpec, EmpathyObserverWorkflowSpec, EmpathyObserverPayload, EmpathyResult, WorkflowRow, WorkflowEventRow, WorkflowDebugSummary, } from './types.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { RuntimeDirectDriver, } from './runtime-direct-driver.js';
|
|
2
|
+
export { WorkflowStore } from './workflow-store.js';
|
|
3
|
+
export { EmpathyObserverWorkflowManager, createEmpathyObserverWorkflowManager, empathyObserverWorkflowSpec, } from './empathy-observer-workflow-manager.js';
|