neoagent 3.2.1-beta.9 → 3.3.1-beta.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/LICENSE +67 -80
- package/README.md +7 -1
- package/docs/licensing.md +44 -0
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_app_shell.dart +186 -40
- package/flutter_app/lib/main_chat.dart +542 -80
- package/flutter_app/lib/main_controller.dart +66 -5
- package/flutter_app/lib/main_install.dart +9 -7
- package/flutter_app/lib/main_models.dart +171 -22
- package/flutter_app/lib/main_operations.dart +0 -49
- package/flutter_app/lib/main_settings.dart +338 -0
- package/flutter_app/lib/src/backend_client.dart +19 -0
- package/flutter_app/lib/src/local_runtime_paths.dart +60 -0
- package/lib/manager.js +10 -2
- package/package.json +1 -1
- package/runtime/paths.js +70 -0
- package/server/db/database.js +2 -2
- package/server/http/routes.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +84940 -83737
- package/server/routes/behavior.js +80 -0
- package/server/routes/settings.js +4 -0
- package/server/services/agents/manager.js +1 -1
- package/server/services/ai/history.js +1 -1
- package/server/services/ai/loop/agent_engine_core.js +110 -17
- package/server/services/ai/loop/blank_recovery.js +5 -4
- package/server/services/ai/loop/completion_judge.js +226 -33
- package/server/services/ai/loop/conversation_loop.js +92 -34
- package/server/services/ai/loop/messaging_delivery.js +47 -0
- package/server/services/ai/loop/progress_classification.js +2 -0
- package/server/services/ai/loopPolicy.js +24 -2
- package/server/services/ai/messagingFallback.js +17 -17
- package/server/services/ai/model_failure_cache.js +7 -0
- package/server/services/ai/systemPrompt.js +31 -122
- package/server/services/ai/taskAnalysis.js +86 -12
- package/server/services/ai/toolEvidence.js +68 -224
- package/server/services/ai/tools.js +60 -19
- package/server/services/behavior/config.js +251 -0
- package/server/services/behavior/defaults.js +68 -0
- package/server/services/behavior/delivery.js +176 -0
- package/server/services/behavior/index.js +43 -0
- package/server/services/behavior/model_client.js +35 -0
- package/server/services/behavior/modules/agent_identity.js +37 -0
- package/server/services/behavior/modules/channel_style.js +28 -0
- package/server/services/behavior/modules/index.js +29 -0
- package/server/services/behavior/modules/norms.js +101 -0
- package/server/services/behavior/modules/persona.js +172 -0
- package/server/services/behavior/modules/persona_prompt.js +238 -0
- package/server/services/behavior/modules/social_memory.js +86 -0
- package/server/services/behavior/modules/social_observability.js +94 -0
- package/server/services/behavior/modules/social_signals.js +41 -0
- package/server/services/behavior/modules/theory_of_mind.js +118 -0
- package/server/services/behavior/modules/turn_taking.js +238 -0
- package/server/services/behavior/pipeline.js +341 -0
- package/server/services/behavior/registry.js +78 -0
- package/server/services/behavior/signals.js +107 -0
- package/server/services/behavior/state.js +99 -0
- package/server/services/behavior/system_prompt.js +75 -0
- package/server/services/memory/manager.js +14 -33
- package/server/services/messaging/access_policy.js +269 -74
- package/server/services/messaging/automation.js +158 -27
- package/server/services/messaging/discord.js +11 -1
- package/server/services/messaging/formatting_guides.js +5 -4
- package/server/services/messaging/http_platforms.js +73 -28
- package/server/services/messaging/inbound_queue.js +74 -13
- package/server/services/messaging/inbound_store.js +33 -0
- package/server/services/messaging/manager.js +57 -5
- package/server/services/messaging/telegram.js +10 -1
- package/server/services/messaging/whatsapp.js +11 -0
- package/server/services/social_reach/channels/social_video.js +1 -1
- package/server/services/social_video/captions.js +2 -2
- package/server/services/social_video/service.js +194 -29
- package/server/services/voice/message.js +1 -1
- package/server/services/voice/runtime.js +2 -2
- package/server/utils/logger.js +19 -0
- package/server/services/ai/terminal_reply.js +0 -57
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { buildChannelScopeId, truncate } = require('../signals');
|
|
4
|
+
const { isModuleEnabled } = require('../config');
|
|
5
|
+
|
|
6
|
+
function participantLabel(msg) {
|
|
7
|
+
return msg.senderName || msg.senderDisplayName || msg.senderUsername || msg.sender || 'participant';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function observeInbound(ctx) {
|
|
11
|
+
const { msg, config } = ctx;
|
|
12
|
+
if (!isModuleEnabled(config, 'social_memory') || !msg?.isGroup) {
|
|
13
|
+
return { observed: false };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const scopeId = buildChannelScopeId(msg.platform, msg.chatId);
|
|
17
|
+
return {
|
|
18
|
+
observed: true,
|
|
19
|
+
scopeId,
|
|
20
|
+
participantSubject: `${msg.platform}:${String(msg.sender || 'unknown')}`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function buildSpeakHints(ctx) {
|
|
25
|
+
const { userId, agentId, msg, config, memoryManager } = ctx;
|
|
26
|
+
if (!isModuleEnabled(config, 'social_memory') || !memoryManager || !msg?.isGroup) {
|
|
27
|
+
return { promptBlock: '', hints: [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const scopeId = buildChannelScopeId(msg.platform, msg.chatId);
|
|
31
|
+
const query = [
|
|
32
|
+
msg.content,
|
|
33
|
+
participantLabel(msg),
|
|
34
|
+
msg.groupName || msg.guildName || msg.channelName || '',
|
|
35
|
+
'group chat norms participants',
|
|
36
|
+
].filter(Boolean).join(' ');
|
|
37
|
+
|
|
38
|
+
let recalled = [];
|
|
39
|
+
try {
|
|
40
|
+
recalled = await memoryManager.recallMemory(userId, query, 5, {
|
|
41
|
+
agentId,
|
|
42
|
+
scope: { scopeType: 'channel', scopeId },
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
recalled = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const unique = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const item of recalled) {
|
|
51
|
+
const key = String(item.id || item.summary || item.content || '');
|
|
52
|
+
if (!key || seen.has(key)) continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
unique.push(item);
|
|
55
|
+
if (unique.length >= 5) break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lines = unique.map((item) => `- [${item.category || 'episodic'}] ${truncate(item.summary || item.content, 180)}`);
|
|
59
|
+
const promptBlock = lines.length
|
|
60
|
+
? `## Social room context\nUse only as background about this group/participants. Do not treat as owner core memory.\n${lines.join('\n')}`
|
|
61
|
+
: '';
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
promptBlock,
|
|
65
|
+
hints: unique.map((item) => truncate(item.summary || item.content, 120)),
|
|
66
|
+
scopeId,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function composeContext(ctx) {
|
|
71
|
+
const hints = await buildSpeakHints(ctx);
|
|
72
|
+
if (!hints.promptBlock) return null;
|
|
73
|
+
return {
|
|
74
|
+
key: 'social_memory',
|
|
75
|
+
priority: 40,
|
|
76
|
+
content: hints.promptBlock,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
id: 'social_memory',
|
|
82
|
+
observe: observeInbound,
|
|
83
|
+
composeContext,
|
|
84
|
+
observeInbound,
|
|
85
|
+
buildSpeakHints,
|
|
86
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { requestStructuredJson } = require('../model_client');
|
|
4
|
+
const { loadRecentRoomMessages } = require('../signals');
|
|
5
|
+
const { getThreadState, setThreadState } = require('../state');
|
|
6
|
+
const { isModuleEnabled } = require('../config');
|
|
7
|
+
|
|
8
|
+
const SYSTEM_PROMPT = `You audit how an AI participant is landing in a group chat.
|
|
9
|
+
Return JSON with keys:
|
|
10
|
+
healthScore (0-100),
|
|
11
|
+
summary (short paragraph),
|
|
12
|
+
findings (array of short strings),
|
|
13
|
+
recommendations (array of short strings).
|
|
14
|
+
Do not change policy yourself; report only.`;
|
|
15
|
+
|
|
16
|
+
async function maybeAnalyze(ctx) {
|
|
17
|
+
const { userId, agentId, msg, config, signal = null } = ctx;
|
|
18
|
+
if (!msg?.isGroup || !isModuleEnabled(config, 'social_observability')) {
|
|
19
|
+
return { analyzed: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const state = getThreadState(userId, agentId, msg.platform, msg.chatId);
|
|
23
|
+
const messageCount = Number(state.messageCountSinceObservability || 0) + 1;
|
|
24
|
+
if (!state.lastObservabilitySummary && messageCount < 24) {
|
|
25
|
+
setThreadState(userId, agentId, msg.platform, msg.chatId, {
|
|
26
|
+
messageCountSinceObservability: messageCount,
|
|
27
|
+
});
|
|
28
|
+
return { analyzed: false, deferred: true };
|
|
29
|
+
}
|
|
30
|
+
const intervalMs = Number(config.observabilityIntervalMinutes || 360) * 60 * 1000;
|
|
31
|
+
if (state.lastObservabilityAt) {
|
|
32
|
+
const age = Date.now() - Date.parse(state.lastObservabilityAt);
|
|
33
|
+
if (Number.isFinite(age) && age < intervalMs) {
|
|
34
|
+
return { analyzed: false, deferred: true, summary: state.lastObservabilitySummary || null };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const roomMessages = loadRecentRoomMessages({
|
|
39
|
+
userId,
|
|
40
|
+
agentId,
|
|
41
|
+
platform: msg.platform,
|
|
42
|
+
chatId: msg.chatId,
|
|
43
|
+
limit: 30,
|
|
44
|
+
});
|
|
45
|
+
if (roomMessages.length < 6) {
|
|
46
|
+
return { analyzed: false, reason: 'insufficient_history' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await requestStructuredJson({
|
|
51
|
+
agentEngine: ctx.agentEngine,
|
|
52
|
+
userId,
|
|
53
|
+
agentId,
|
|
54
|
+
purpose: 'fast',
|
|
55
|
+
system: SYSTEM_PROMPT,
|
|
56
|
+
prompt: JSON.stringify({
|
|
57
|
+
platform: msg.platform,
|
|
58
|
+
chatId: msg.chatId,
|
|
59
|
+
recentSilenceCount: state.recentSilenceCount || 0,
|
|
60
|
+
recentMessages: roomMessages,
|
|
61
|
+
}),
|
|
62
|
+
signal,
|
|
63
|
+
maxTokens: 320,
|
|
64
|
+
});
|
|
65
|
+
const summary = {
|
|
66
|
+
healthScore: Number(result.parsed?.healthScore || 0),
|
|
67
|
+
summary: String(result.parsed?.summary || '').trim(),
|
|
68
|
+
findings: Array.isArray(result.parsed?.findings) ? result.parsed.findings.slice(0, 8) : [],
|
|
69
|
+
recommendations: Array.isArray(result.parsed?.recommendations) ? result.parsed.recommendations.slice(0, 8) : [],
|
|
70
|
+
at: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
setThreadState(userId, agentId, msg.platform, msg.chatId, {
|
|
73
|
+
lastObservabilityAt: summary.at,
|
|
74
|
+
lastObservabilitySummary: summary,
|
|
75
|
+
messageCountSinceObservability: 0,
|
|
76
|
+
});
|
|
77
|
+
return { analyzed: true, summary };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (signal?.aborted) throw error;
|
|
80
|
+
return { analyzed: false, error: error?.message || String(error) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getLatestSummary(userId, agentId, platform, chatId) {
|
|
85
|
+
const state = getThreadState(userId, agentId, platform, chatId);
|
|
86
|
+
return state.lastObservabilitySummary || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
id: 'social_observability',
|
|
91
|
+
afterTurn: maybeAnalyze,
|
|
92
|
+
maybeAnalyze,
|
|
93
|
+
getLatestSummary,
|
|
94
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { setThreadState, getThreadState } = require('../state');
|
|
4
|
+
const { isModuleEnabled } = require('../config');
|
|
5
|
+
|
|
6
|
+
function recordSignal(ctx) {
|
|
7
|
+
const { userId, agentId, msg, config, signalType = 'message', details = {} } = ctx;
|
|
8
|
+
if (!isModuleEnabled(config, 'social_signals')) {
|
|
9
|
+
return { recorded: false };
|
|
10
|
+
}
|
|
11
|
+
const state = getThreadState(userId, agentId, msg.platform, msg.chatId);
|
|
12
|
+
const signals = Array.isArray(state.recentSignals) ? state.recentSignals.slice(-19) : [];
|
|
13
|
+
signals.push({
|
|
14
|
+
type: signalType,
|
|
15
|
+
at: new Date().toISOString(),
|
|
16
|
+
sender: msg.sender || null,
|
|
17
|
+
details,
|
|
18
|
+
});
|
|
19
|
+
setThreadState(userId, agentId, msg.platform, msg.chatId, {
|
|
20
|
+
recentSignals: signals,
|
|
21
|
+
});
|
|
22
|
+
return { recorded: true, count: signals.length };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function observe(ctx) {
|
|
26
|
+
return recordSignal({
|
|
27
|
+
...ctx,
|
|
28
|
+
signalType: ctx.msg?.eventType || 'inbound_message',
|
|
29
|
+
details: {
|
|
30
|
+
hasMedia: Boolean(ctx.msg?.localMediaPath || ctx.msg?.mediaType),
|
|
31
|
+
wasMentioned: ctx.msg?.wasMentioned === true,
|
|
32
|
+
repliedToAgent: ctx.msg?.repliedToAgent === true,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
id: 'social_signals',
|
|
39
|
+
observe,
|
|
40
|
+
recordSignal,
|
|
41
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { requestStructuredJson } = require('../model_client');
|
|
4
|
+
const { isModuleEnabled } = require('../config');
|
|
5
|
+
const { truncate } = require('../signals');
|
|
6
|
+
const { INTERACTION_VOICE_RULES } = require('./persona');
|
|
7
|
+
|
|
8
|
+
const BASE_SYSTEM_PROMPT = `You are the final reviewer for an AI draft in a multi-party chat.
|
|
9
|
+
Return JSON with keys:
|
|
10
|
+
action ("send"|"revise"|"suppress"),
|
|
11
|
+
revisedContent (string, required if action is revise, else empty),
|
|
12
|
+
risk ("low"|"medium"|"high"),
|
|
13
|
+
reasonCodes (array of short strings),
|
|
14
|
+
rationale (one short sentence).
|
|
15
|
+
Prefer minimal edits. Suppress only if the draft is likely harmful, invasive, clearly socially damaging, redundant after the conversation moved on, or no longer worth adding.`;
|
|
16
|
+
|
|
17
|
+
async function refineDraft(ctx) {
|
|
18
|
+
const {
|
|
19
|
+
userId,
|
|
20
|
+
agentId,
|
|
21
|
+
msg,
|
|
22
|
+
config,
|
|
23
|
+
draft,
|
|
24
|
+
signal = null,
|
|
25
|
+
runId = null,
|
|
26
|
+
} = ctx;
|
|
27
|
+
|
|
28
|
+
if (!msg?.isGroup || !isModuleEnabled(config, 'theory_of_mind')) {
|
|
29
|
+
return {
|
|
30
|
+
action: 'send',
|
|
31
|
+
content: draft,
|
|
32
|
+
risk: 'low',
|
|
33
|
+
reasonCodes: ['tom_disabled_or_direct'],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const content = String(draft || '').trim();
|
|
38
|
+
if (!content || content.toUpperCase() === '[NO RESPONSE]') {
|
|
39
|
+
return {
|
|
40
|
+
action: 'send',
|
|
41
|
+
content,
|
|
42
|
+
risk: 'low',
|
|
43
|
+
reasonCodes: ['empty_or_silent'],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const runModelId = runId
|
|
49
|
+
? ctx.agentEngine?.getRunMeta?.(runId)?.modelSelectionId || null
|
|
50
|
+
: null;
|
|
51
|
+
const system = isModuleEnabled(config, 'persona')
|
|
52
|
+
? `${BASE_SYSTEM_PROMPT}\n\n${INTERACTION_VOICE_RULES}`
|
|
53
|
+
: BASE_SYSTEM_PROMPT;
|
|
54
|
+
const result = await requestStructuredJson({
|
|
55
|
+
agentEngine: ctx.agentEngine,
|
|
56
|
+
userId,
|
|
57
|
+
agentId,
|
|
58
|
+
modelId: config.decisionModelId || runModelId,
|
|
59
|
+
purpose: runModelId ? 'general' : config.decisionModelPurpose,
|
|
60
|
+
system,
|
|
61
|
+
prompt: JSON.stringify({
|
|
62
|
+
room: {
|
|
63
|
+
platform: msg.platform,
|
|
64
|
+
chatId: msg.chatId,
|
|
65
|
+
isGroup: true,
|
|
66
|
+
sender: msg.senderName || msg.sender,
|
|
67
|
+
inbound: truncate(msg.content, 500),
|
|
68
|
+
},
|
|
69
|
+
draft: truncate(content, 2800),
|
|
70
|
+
}),
|
|
71
|
+
signal,
|
|
72
|
+
maxTokens: 900,
|
|
73
|
+
});
|
|
74
|
+
const parsed = result.parsed || {};
|
|
75
|
+
const action = ['send', 'revise', 'suppress'].includes(String(parsed.action || ''))
|
|
76
|
+
? String(parsed.action)
|
|
77
|
+
: 'send';
|
|
78
|
+
if (action === 'suppress') {
|
|
79
|
+
return {
|
|
80
|
+
action,
|
|
81
|
+
content: '[NO RESPONSE]',
|
|
82
|
+
risk: parsed.risk || 'high',
|
|
83
|
+
reasonCodes: parsed.reasonCodes || ['tom_suppress'],
|
|
84
|
+
rationale: parsed.rationale || '',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (action === 'revise' && String(parsed.revisedContent || '').trim()) {
|
|
88
|
+
return {
|
|
89
|
+
action,
|
|
90
|
+
content: String(parsed.revisedContent).trim(),
|
|
91
|
+
risk: parsed.risk || 'medium',
|
|
92
|
+
reasonCodes: parsed.reasonCodes || ['tom_revise'],
|
|
93
|
+
rationale: parsed.rationale || '',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
action: 'send',
|
|
98
|
+
content,
|
|
99
|
+
risk: parsed.risk || 'low',
|
|
100
|
+
reasonCodes: parsed.reasonCodes || ['tom_send'],
|
|
101
|
+
rationale: parsed.rationale || '',
|
|
102
|
+
};
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (signal?.aborted) throw error;
|
|
105
|
+
return {
|
|
106
|
+
action: 'send',
|
|
107
|
+
content,
|
|
108
|
+
risk: 'low',
|
|
109
|
+
reasonCodes: ['tom_error_passthrough'],
|
|
110
|
+
failureCode: 'model_unavailable',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
id: 'theory_of_mind',
|
|
117
|
+
refineDraft,
|
|
118
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { requestStructuredJson } = require('../model_client');
|
|
4
|
+
const { buildDecisionPacket, loadRecentRoomMessages } = require('../signals');
|
|
5
|
+
const { getThreadState, setThreadState } = require('../state');
|
|
6
|
+
const { isModuleEnabled } = require('../config');
|
|
7
|
+
|
|
8
|
+
const SYSTEM_PROMPT = `You are NeoAgent's group turn-taking gate.
|
|
9
|
+
Decide whether the agent should speak now or stay silent in a multi-party chat.
|
|
10
|
+
Default posture in groups: prefer holding back. Speak only when the agent would add clear value, is addressed, can usefully answer an open need, should correct a harmful misunderstanding, or media/context clearly calls for a response.
|
|
11
|
+
Judge the meaning and flow of the provided room context. Do not use phrase matching or keyword rules.
|
|
12
|
+
Return JSON only with keys:
|
|
13
|
+
decision ("speak" or "stay_silent"),
|
|
14
|
+
needScore (0-1 number measuring how worthwhile an agent contribution is now),
|
|
15
|
+
confidence (0-1 number),
|
|
16
|
+
reasonCodes (array of short snake_case strings),
|
|
17
|
+
urgency ("low"|"medium"|"high"),
|
|
18
|
+
rationale (one short sentence).`;
|
|
19
|
+
|
|
20
|
+
function normalizeDecision(raw, fallback) {
|
|
21
|
+
const decision = String(raw?.decision || fallback.decision || 'stay_silent').trim().toLowerCase();
|
|
22
|
+
const score = (value, fallbackValue) => {
|
|
23
|
+
const number = Number(value);
|
|
24
|
+
const normalized = Number.isFinite(number) ? number : Number(fallbackValue);
|
|
25
|
+
return Math.max(0, Math.min(1, Number.isFinite(normalized) ? normalized : 0.5));
|
|
26
|
+
};
|
|
27
|
+
const normalized = {
|
|
28
|
+
decision: decision === 'speak' ? 'speak' : 'stay_silent',
|
|
29
|
+
needScore: score(raw?.needScore, fallback.needScore),
|
|
30
|
+
confidence: score(raw?.confidence, fallback.confidence),
|
|
31
|
+
reasonCodes: Array.isArray(raw?.reasonCodes)
|
|
32
|
+
? raw.reasonCodes.map((item) => String(item || '').trim()).filter(Boolean).slice(0, 8)
|
|
33
|
+
: (fallback.reasonCodes || []),
|
|
34
|
+
urgency: ['low', 'medium', 'high'].includes(String(raw?.urgency || ''))
|
|
35
|
+
? String(raw.urgency)
|
|
36
|
+
: (fallback.urgency || 'low'),
|
|
37
|
+
rationale: String(raw?.rationale || fallback.rationale || '').trim().slice(0, 240),
|
|
38
|
+
tokenPath: fallback.tokenPath || 'gate_only',
|
|
39
|
+
model: raw?.model || fallback.model || null,
|
|
40
|
+
};
|
|
41
|
+
const usage = Number(raw?.usage ?? fallback.usage);
|
|
42
|
+
if (Number.isFinite(usage)) normalized.usage = usage;
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function localFallbackDecision(packet, config) {
|
|
47
|
+
if (!packet.chat.isGroup) {
|
|
48
|
+
return normalizeDecision({
|
|
49
|
+
decision: 'speak',
|
|
50
|
+
needScore: 1,
|
|
51
|
+
confidence: 1,
|
|
52
|
+
reasonCodes: ['direct_chat'],
|
|
53
|
+
urgency: 'medium',
|
|
54
|
+
rationale: 'Direct chats always engage.',
|
|
55
|
+
}, { tokenPath: 'gate_skip' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (packet.event.wasMentioned || packet.event.repliedToAgent) {
|
|
59
|
+
return normalizeDecision({
|
|
60
|
+
decision: 'speak',
|
|
61
|
+
needScore: 1,
|
|
62
|
+
confidence: 0.85,
|
|
63
|
+
reasonCodes: [packet.event.repliedToAgent ? 'reply_to_agent' : 'addressed'],
|
|
64
|
+
urgency: 'medium',
|
|
65
|
+
rationale: 'Fallback engaged because platform metadata directly addresses the agent.',
|
|
66
|
+
}, { tokenPath: 'gate_fallback' });
|
|
67
|
+
}
|
|
68
|
+
return normalizeDecision({
|
|
69
|
+
decision: 'stay_silent',
|
|
70
|
+
needScore: 0,
|
|
71
|
+
confidence: 0.7,
|
|
72
|
+
reasonCodes: ['prefer_hold_back', 'model_unavailable'],
|
|
73
|
+
urgency: 'low',
|
|
74
|
+
rationale: 'Fallback gate holds back in groups when address is unclear.',
|
|
75
|
+
}, { tokenPath: 'gate_fallback' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function shouldEngage(ctx) {
|
|
79
|
+
const startedAt = Date.now();
|
|
80
|
+
const {
|
|
81
|
+
userId,
|
|
82
|
+
agentId,
|
|
83
|
+
msg,
|
|
84
|
+
config,
|
|
85
|
+
signal = null,
|
|
86
|
+
memoryHints = [],
|
|
87
|
+
agentEngine,
|
|
88
|
+
turnEpoch,
|
|
89
|
+
} = ctx;
|
|
90
|
+
|
|
91
|
+
if (!msg?.isGroup || !isModuleEnabled(config, 'turn_taking') || config.enabled === false) {
|
|
92
|
+
return {
|
|
93
|
+
decision: 'speak',
|
|
94
|
+
needScore: 1,
|
|
95
|
+
confidence: 1,
|
|
96
|
+
reasonCodes: msg?.isGroup ? ['turn_taking_disabled'] : ['direct_chat'],
|
|
97
|
+
urgency: 'medium',
|
|
98
|
+
rationale: msg?.isGroup ? 'Turn-taking disabled; engaging.' : 'Direct chat always engages.',
|
|
99
|
+
tokenPath: 'gate_skip',
|
|
100
|
+
latencyMs: Date.now() - startedAt,
|
|
101
|
+
turnEpoch,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (msg.wasMentioned || msg.repliedToAgent) {
|
|
106
|
+
return {
|
|
107
|
+
decision: 'speak',
|
|
108
|
+
needScore: 1,
|
|
109
|
+
confidence: 1,
|
|
110
|
+
reasonCodes: [msg.repliedToAgent ? 'reply_to_agent' : 'addressed'],
|
|
111
|
+
urgency: 'medium',
|
|
112
|
+
rationale: 'Platform metadata directly addresses the agent.',
|
|
113
|
+
tokenPath: 'gate_skip',
|
|
114
|
+
latencyMs: Date.now() - startedAt,
|
|
115
|
+
turnEpoch,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mode = config.participationMode || 'automatic';
|
|
120
|
+
if (mode === 'always') {
|
|
121
|
+
return {
|
|
122
|
+
decision: 'speak',
|
|
123
|
+
needScore: 1,
|
|
124
|
+
confidence: 1,
|
|
125
|
+
reasonCodes: ['participation_always'],
|
|
126
|
+
urgency: 'medium',
|
|
127
|
+
rationale: 'Room participation is configured to always engage.',
|
|
128
|
+
tokenPath: 'gate_skip',
|
|
129
|
+
latencyMs: Date.now() - startedAt,
|
|
130
|
+
turnEpoch,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (mode === 'mention_only') {
|
|
134
|
+
return {
|
|
135
|
+
decision: 'stay_silent',
|
|
136
|
+
needScore: 0,
|
|
137
|
+
confidence: 1,
|
|
138
|
+
reasonCodes: ['mention_only'],
|
|
139
|
+
urgency: 'low',
|
|
140
|
+
rationale: 'Room participation requires a structural mention or reply.',
|
|
141
|
+
tokenPath: 'gate_skip',
|
|
142
|
+
latencyMs: Date.now() - startedAt,
|
|
143
|
+
turnEpoch,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const threadState = getThreadState(userId, agentId, msg.platform, msg.chatId);
|
|
148
|
+
const roomMessages = loadRecentRoomMessages({
|
|
149
|
+
userId,
|
|
150
|
+
agentId,
|
|
151
|
+
platform: msg.platform,
|
|
152
|
+
chatId: msg.chatId,
|
|
153
|
+
limit: config.decisionContextMessageLimit,
|
|
154
|
+
});
|
|
155
|
+
const packet = buildDecisionPacket({
|
|
156
|
+
msg,
|
|
157
|
+
config,
|
|
158
|
+
threadState,
|
|
159
|
+
roomMessages,
|
|
160
|
+
localMemoryHints: memoryHints,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
let decision;
|
|
164
|
+
try {
|
|
165
|
+
const result = await requestStructuredJson({
|
|
166
|
+
agentEngine,
|
|
167
|
+
userId,
|
|
168
|
+
agentId,
|
|
169
|
+
modelId: config.decisionModelId,
|
|
170
|
+
purpose: config.decisionModelPurpose,
|
|
171
|
+
system: SYSTEM_PROMPT,
|
|
172
|
+
prompt: JSON.stringify(packet),
|
|
173
|
+
signal,
|
|
174
|
+
maxTokens: 220,
|
|
175
|
+
fallback: {
|
|
176
|
+
decision: 'stay_silent',
|
|
177
|
+
needScore: 0,
|
|
178
|
+
confidence: 0.55,
|
|
179
|
+
reasonCodes: ['parse_fallback'],
|
|
180
|
+
urgency: 'low',
|
|
181
|
+
rationale: 'The decision could not be parsed.',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
decision = normalizeDecision(result.parsed || {}, {
|
|
185
|
+
decision: 'stay_silent',
|
|
186
|
+
needScore: 0,
|
|
187
|
+
confidence: 0.55,
|
|
188
|
+
reasonCodes: ['parse_fallback'],
|
|
189
|
+
urgency: 'low',
|
|
190
|
+
rationale: 'Could not parse gate response; holding back.',
|
|
191
|
+
tokenPath: 'gate_only',
|
|
192
|
+
model: result.modelSelectionId || result.model,
|
|
193
|
+
});
|
|
194
|
+
decision.model = result.modelSelectionId || result.model;
|
|
195
|
+
decision.usage = Number(result.usage || 0);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (signal?.aborted) throw error;
|
|
198
|
+
decision = localFallbackDecision(packet, config);
|
|
199
|
+
decision.failureCode = 'model_unavailable';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Confidence measures certainty. Need score measures whether speaking is worthwhile.
|
|
203
|
+
if (
|
|
204
|
+
decision.decision === 'speak'
|
|
205
|
+
&& Number(decision.needScore || 0) < Number(config.minimumNeedScore ?? 0.72)
|
|
206
|
+
&& !packet.event.wasMentioned
|
|
207
|
+
&& !packet.event.repliedToAgent
|
|
208
|
+
) {
|
|
209
|
+
decision = normalizeDecision({
|
|
210
|
+
...decision,
|
|
211
|
+
decision: 'stay_silent',
|
|
212
|
+
reasonCodes: [...(decision.reasonCodes || []), 'below_need_threshold'],
|
|
213
|
+
rationale: decision.rationale || 'The contribution value is below the room threshold.',
|
|
214
|
+
}, { tokenPath: decision.tokenPath || 'gate_only', model: decision.model });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
setThreadState(userId, agentId, msg.platform, msg.chatId, {
|
|
218
|
+
lastDecision: decision.decision,
|
|
219
|
+
lastDecisionAt: new Date().toISOString(),
|
|
220
|
+
recentSilenceCount: decision.decision === 'stay_silent'
|
|
221
|
+
? Number(threadState.recentSilenceCount || 0) + 1
|
|
222
|
+
: 0,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
...decision,
|
|
227
|
+
latencyMs: Date.now() - startedAt,
|
|
228
|
+
turnEpoch,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
id: 'turn_taking',
|
|
234
|
+
decide: shouldEngage,
|
|
235
|
+
shouldEngage,
|
|
236
|
+
normalizeDecision,
|
|
237
|
+
localFallbackDecision,
|
|
238
|
+
};
|