shennian 0.2.55 → 0.2.57
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/src/agents/adapter.d.ts +2 -2
- package/dist/src/agents/claude.d.ts +2 -0
- package/dist/src/agents/claude.js +31 -2
- package/dist/src/agents/codex.d.ts +2 -2
- package/dist/src/agents/codex.js +29 -7
- package/dist/src/agents/config-status.d.ts +17 -0
- package/dist/src/agents/config-status.js +205 -0
- package/dist/src/agents/external-channel-instructions.js +8 -3
- package/dist/src/agents/model-registry/service.js +11 -4
- package/dist/src/channels/reply-split.d.ts +1 -0
- package/dist/src/channels/reply-split.js +89 -0
- package/dist/src/channels/runtime.js +13 -1
- package/dist/src/commands/external.js +100 -5
- package/dist/src/manager/prompt.d.ts +1 -1
- package/dist/src/manager/prompt.js +2 -1
- package/dist/src/manager/runtime.js +53 -15
- package/dist/src/session/handlers/agent-config.d.ts +7 -0
- package/dist/src/session/handlers/agent-config.js +71 -0
- package/dist/src/session/handlers/chat.d.ts +1 -1
- package/dist/src/session/handlers/chat.js +46 -12
- package/dist/src/session/handlers/session-refresh.d.ts +3 -0
- package/dist/src/session/handlers/session-refresh.js +35 -0
- package/dist/src/session/manager.js +17 -0
- package/dist/src/session/queue.js +7 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
-
import type { AgentType, ExternalChannelSessionStatus } from '@shennian/wire';
|
|
2
|
+
import type { AgentType, ChatAttachmentMeta, ExternalChannelSessionStatus } from '@shennian/wire';
|
|
3
3
|
export type AgentEvent = {
|
|
4
4
|
state: string;
|
|
5
5
|
runId: string;
|
|
@@ -30,7 +30,7 @@ export declare abstract class AgentAdapter extends EventEmitter<AgentAdapterEven
|
|
|
30
30
|
env?: NodeJS.ProcessEnv;
|
|
31
31
|
}): void;
|
|
32
32
|
abstract start(sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
|
|
33
|
-
abstract send(text: string, modelId?: string, reasoningEffort?: string): Promise<void>;
|
|
33
|
+
abstract send(text: string, modelId?: string, reasoningEffort?: string, attachments?: ChatAttachmentMeta[]): Promise<void>;
|
|
34
34
|
abstract resume(agentSessionId: string): Promise<void>;
|
|
35
35
|
abstract stop(): Promise<void>;
|
|
36
36
|
}
|
|
@@ -2,6 +2,8 @@ import { AgentAdapter } from './adapter.js';
|
|
|
2
2
|
import type { ExternalChannelSessionStatus } from '@shennian/wire';
|
|
3
3
|
export declare function normalizeClaudeModelId(modelId?: string | null): string;
|
|
4
4
|
export declare function normalizeClaudeReasoningEffort(reasoningEffort?: string | null): string | undefined;
|
|
5
|
+
export declare function resetClaudeCapabilityCacheForTests(): void;
|
|
6
|
+
export declare function supportsClaudeReasoningEffort(): boolean;
|
|
5
7
|
export declare class ClaudeAdapter extends AgentAdapter {
|
|
6
8
|
private readonly options;
|
|
7
9
|
readonly type: "claude";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { AgentAdapter, registerAgent } from './adapter.js';
|
|
4
|
-
import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
|
|
4
|
+
import { resolveBuiltinCommand, spawnResolvedCommand, spawnResolvedCommandSync } from './command-spec.js';
|
|
5
5
|
import { buildAgentProcessEnv } from '../agent-env.js';
|
|
6
6
|
import { buildPlatformInstructions } from './platform-instructions.js';
|
|
7
7
|
export function normalizeClaudeModelId(modelId) {
|
|
@@ -9,6 +9,7 @@ export function normalizeClaudeModelId(modelId) {
|
|
|
9
9
|
return trimmed || 'default';
|
|
10
10
|
}
|
|
11
11
|
const CLAUDE_REASONING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
12
|
+
const claudeEffortSupportCache = new Map();
|
|
12
13
|
export function normalizeClaudeReasoningEffort(reasoningEffort) {
|
|
13
14
|
const trimmed = reasoningEffort?.trim();
|
|
14
15
|
if (!trimmed)
|
|
@@ -17,6 +18,34 @@ export function normalizeClaudeReasoningEffort(reasoningEffort) {
|
|
|
17
18
|
return trimmed;
|
|
18
19
|
throw new Error(`Unsupported Claude reasoning effort "${trimmed}". Supported values: low, medium, high, xhigh, max.`);
|
|
19
20
|
}
|
|
21
|
+
export function resetClaudeCapabilityCacheForTests() {
|
|
22
|
+
claudeEffortSupportCache.clear();
|
|
23
|
+
}
|
|
24
|
+
export function supportsClaudeReasoningEffort() {
|
|
25
|
+
const spec = resolveBuiltinCommand('claude');
|
|
26
|
+
if (!spec)
|
|
27
|
+
return false;
|
|
28
|
+
const cacheKey = `${spec.kind}:${spec.path}:${spec.command}:${spec.args.join('\0')}`;
|
|
29
|
+
if (claudeEffortSupportCache.has(cacheKey)) {
|
|
30
|
+
return claudeEffortSupportCache.get(cacheKey) ?? false;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const result = spawnResolvedCommandSync(spec, ['--help'], {
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
timeout: 3000,
|
|
37
|
+
env: buildAgentProcessEnv(),
|
|
38
|
+
});
|
|
39
|
+
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
40
|
+
const supported = /(?:^|\s)--effort(?:\s|,|<)/.test(output);
|
|
41
|
+
claudeEffortSupportCache.set(cacheKey, supported);
|
|
42
|
+
return supported;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
claudeEffortSupportCache.set(cacheKey, false);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
20
49
|
export class ClaudeAdapter extends AgentAdapter {
|
|
21
50
|
options;
|
|
22
51
|
type = 'claude';
|
|
@@ -64,7 +93,7 @@ export class ClaudeAdapter extends AgentAdapter {
|
|
|
64
93
|
}
|
|
65
94
|
args.push('--model', normalizeClaudeModelId(modelId));
|
|
66
95
|
const effort = normalizeClaudeReasoningEffort(reasoningEffort);
|
|
67
|
-
if (effort) {
|
|
96
|
+
if (effort && supportsClaudeReasoningEffort()) {
|
|
68
97
|
args.push('--effort', effort);
|
|
69
98
|
}
|
|
70
99
|
if (this.agentSessionId) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AgentAdapter } from './adapter.js';
|
|
2
|
-
import type { ExternalChannelSessionStatus } from '@shennian/wire';
|
|
2
|
+
import type { ChatAttachmentMeta, ExternalChannelSessionStatus } from '@shennian/wire';
|
|
3
3
|
export declare class CodexAdapter extends AgentAdapter {
|
|
4
4
|
private readonly options;
|
|
5
5
|
readonly type: "codex";
|
|
@@ -35,7 +35,7 @@ export declare class CodexAdapter extends AgentAdapter {
|
|
|
35
35
|
env?: NodeJS.ProcessEnv;
|
|
36
36
|
}): void;
|
|
37
37
|
start(_sessionId: string, workDir: string, agentSessionId?: string | null): Promise<void>;
|
|
38
|
-
send(text: string, modelId?: string, reasoningEffort?: string): Promise<void>;
|
|
38
|
+
send(text: string, modelId?: string, reasoningEffort?: string, attachments?: ChatAttachmentMeta[]): Promise<void>;
|
|
39
39
|
resume(agentSessionId: string): Promise<void>;
|
|
40
40
|
stop(): Promise<void>;
|
|
41
41
|
private spawnCodex;
|
package/dist/src/agents/codex.js
CHANGED
|
@@ -6,6 +6,28 @@ import { AgentAdapter, registerAgent } from './adapter.js';
|
|
|
6
6
|
import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
|
|
7
7
|
import { buildAgentProcessEnv } from '../agent-env.js';
|
|
8
8
|
import { ensurePlatformInstructionsFile } from './platform-instructions.js';
|
|
9
|
+
function buildCodexTextInput(text, attachments) {
|
|
10
|
+
const images = attachments
|
|
11
|
+
?.filter((attachment) => attachment.kind === 'image')
|
|
12
|
+
.filter((attachment) => !/^https?:\/\//i.test(attachment.path))
|
|
13
|
+
.map((attachment) => {
|
|
14
|
+
if (attachment.previewData) {
|
|
15
|
+
return {
|
|
16
|
+
path: attachment.path,
|
|
17
|
+
data: attachment.previewData,
|
|
18
|
+
mimeType: attachment.mimeType,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return { path: attachment.path };
|
|
22
|
+
})
|
|
23
|
+
.filter((attachment) => attachment.path);
|
|
24
|
+
return {
|
|
25
|
+
type: 'text',
|
|
26
|
+
text,
|
|
27
|
+
text_elements: [],
|
|
28
|
+
...(images?.length ? { local_images: images } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
9
31
|
export class CodexAdapter extends AgentAdapter {
|
|
10
32
|
options;
|
|
11
33
|
type = 'codex';
|
|
@@ -46,7 +68,7 @@ export class CodexAdapter extends AgentAdapter {
|
|
|
46
68
|
if (agentSessionId)
|
|
47
69
|
this.agentSessionId = agentSessionId;
|
|
48
70
|
}
|
|
49
|
-
async send(text, modelId, reasoningEffort) {
|
|
71
|
+
async send(text, modelId, reasoningEffort, attachments) {
|
|
50
72
|
if (this.activeTurnId) {
|
|
51
73
|
await this.interruptActiveTurn().catch(() => { });
|
|
52
74
|
await this.killProcess();
|
|
@@ -68,7 +90,7 @@ export class CodexAdapter extends AgentAdapter {
|
|
|
68
90
|
}).catch(() => { });
|
|
69
91
|
this.namedThread = true;
|
|
70
92
|
}
|
|
71
|
-
const response = await this.startTurnWithRecovery(threadId, text, codexModelId, codexReasoningEffort);
|
|
93
|
+
const response = await this.startTurnWithRecovery(threadId, text, codexModelId, codexReasoningEffort, attachments);
|
|
72
94
|
this.activeTurnId = response.turn?.id ?? null;
|
|
73
95
|
}
|
|
74
96
|
async resume(agentSessionId) {
|
|
@@ -236,23 +258,23 @@ export class CodexAdapter extends AgentAdapter {
|
|
|
236
258
|
this.agentSessionId = threadId;
|
|
237
259
|
this.namedThread = !!response.thread?.name;
|
|
238
260
|
}
|
|
239
|
-
async startTurnWithRecovery(threadId, text, codexModelId, reasoningEffort) {
|
|
261
|
+
async startTurnWithRecovery(threadId, text, codexModelId, reasoningEffort, attachments) {
|
|
240
262
|
try {
|
|
241
|
-
return await this.startTurn(threadId, text, codexModelId, reasoningEffort);
|
|
263
|
+
return await this.startTurn(threadId, text, codexModelId, reasoningEffort, attachments);
|
|
242
264
|
}
|
|
243
265
|
catch (error) {
|
|
244
266
|
if (!isMissingCodexRolloutError(error))
|
|
245
267
|
throw error;
|
|
246
268
|
await this.killProcess();
|
|
247
269
|
await this.ensureAppServer(codexModelId);
|
|
248
|
-
return await this.startTurn(threadId, text, codexModelId, reasoningEffort);
|
|
270
|
+
return await this.startTurn(threadId, text, codexModelId, reasoningEffort, attachments);
|
|
249
271
|
}
|
|
250
272
|
}
|
|
251
|
-
async startTurn(threadId, text, codexModelId, reasoningEffort) {
|
|
273
|
+
async startTurn(threadId, text, codexModelId, reasoningEffort, attachments) {
|
|
252
274
|
try {
|
|
253
275
|
return await this.sendRpc('turn/start', {
|
|
254
276
|
threadId,
|
|
255
|
-
input: [
|
|
277
|
+
input: [buildCodexTextInput(text, attachments)],
|
|
256
278
|
approvalPolicy: 'never',
|
|
257
279
|
sandboxPolicy: { type: 'dangerFullAccess' },
|
|
258
280
|
...(codexModelId ? { model: codexModelId } : {}),
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentConfigSummary, AgentType } from '@shennian/wire';
|
|
2
|
+
export type AgentProviderConfigRecord = {
|
|
3
|
+
agent: 'codex' | 'claude';
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function getManagedAgentProviderConfig(agent: 'codex' | 'claude'): AgentProviderConfigRecord | undefined;
|
|
9
|
+
export declare function upsertManagedAgentProviderConfig(input: {
|
|
10
|
+
agent: 'codex' | 'claude';
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
}): AgentProviderConfigRecord;
|
|
14
|
+
export declare function deleteManagedAgentProviderConfig(agent: 'codex' | 'claude'): void;
|
|
15
|
+
export declare function buildManagedAgentEnv(agent: AgentType): NodeJS.ProcessEnv;
|
|
16
|
+
export declare function getAgentConfigSummary(agent: AgentType, env?: NodeJS.ProcessEnv): AgentConfigSummary | undefined;
|
|
17
|
+
export declare function maskToken(token: string): string;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @arch docs/features/agent-provider-config.md
|
|
2
|
+
// @test src/__tests__/agent-config-status.test.ts
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { readLatestUserEnv } from '../agent-env.js';
|
|
7
|
+
import { resolveShennianPath } from '../config/index.js';
|
|
8
|
+
const MANAGED_CONFIG_PATH = resolveShennianPath('agent-provider-config.json');
|
|
9
|
+
const TOKEN_SUFFIX_LENGTH = 4;
|
|
10
|
+
const AGENT_ENV_KEYS = {
|
|
11
|
+
codex: {
|
|
12
|
+
baseUrl: ['OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_API_URL'],
|
|
13
|
+
token: ['OPENAI_API_KEY', 'OPENAI_TOKEN'],
|
|
14
|
+
},
|
|
15
|
+
claude: {
|
|
16
|
+
baseUrl: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_API_URL'],
|
|
17
|
+
token: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export function getManagedAgentProviderConfig(agent) {
|
|
21
|
+
return loadManagedConfig().configs[agent];
|
|
22
|
+
}
|
|
23
|
+
export function upsertManagedAgentProviderConfig(input) {
|
|
24
|
+
const file = loadManagedConfig();
|
|
25
|
+
const existing = file.configs[input.agent];
|
|
26
|
+
const record = {
|
|
27
|
+
agent: input.agent,
|
|
28
|
+
baseUrl: input.baseUrl?.trim() || existing?.baseUrl,
|
|
29
|
+
token: input.token?.trim() || existing?.token,
|
|
30
|
+
updatedAt: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
file.configs[input.agent] = record;
|
|
33
|
+
saveManagedConfig(file);
|
|
34
|
+
return record;
|
|
35
|
+
}
|
|
36
|
+
export function deleteManagedAgentProviderConfig(agent) {
|
|
37
|
+
const file = loadManagedConfig();
|
|
38
|
+
delete file.configs[agent];
|
|
39
|
+
saveManagedConfig(file);
|
|
40
|
+
}
|
|
41
|
+
export function buildManagedAgentEnv(agent) {
|
|
42
|
+
if (agent !== 'codex' && agent !== 'claude')
|
|
43
|
+
return {};
|
|
44
|
+
const config = getManagedAgentProviderConfig(agent);
|
|
45
|
+
if (!config)
|
|
46
|
+
return {};
|
|
47
|
+
if (agent === 'codex') {
|
|
48
|
+
return compactEnv({
|
|
49
|
+
OPENAI_BASE_URL: config.baseUrl,
|
|
50
|
+
OPENAI_API_KEY: config.token,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return compactEnv({
|
|
54
|
+
ANTHROPIC_BASE_URL: config.baseUrl,
|
|
55
|
+
ANTHROPIC_API_KEY: config.token,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function getAgentConfigSummary(agent, env = readLatestUserEnv()) {
|
|
59
|
+
if (agent !== 'codex' && agent !== 'claude')
|
|
60
|
+
return undefined;
|
|
61
|
+
const managed = getManagedAgentProviderConfig(agent);
|
|
62
|
+
if (managed?.token || managed?.baseUrl) {
|
|
63
|
+
return toSummary({
|
|
64
|
+
agent,
|
|
65
|
+
source: 'shennian',
|
|
66
|
+
status: managed.token ? 'managed' : 'missing',
|
|
67
|
+
baseUrl: managed.baseUrl,
|
|
68
|
+
token: managed.token,
|
|
69
|
+
tokenPresent: !!managed.token,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const envConfig = detectFromEnv(agent, env);
|
|
73
|
+
if (envConfig.tokenPresent || envConfig.baseUrl) {
|
|
74
|
+
return toSummary({
|
|
75
|
+
agent,
|
|
76
|
+
source: 'env',
|
|
77
|
+
status: envConfig.tokenPresent ? 'detected' : 'missing',
|
|
78
|
+
baseUrl: envConfig.baseUrl,
|
|
79
|
+
token: envConfig.token,
|
|
80
|
+
tokenPresent: envConfig.tokenPresent,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const fileConfig = detectFromKnownConfigFiles(agent);
|
|
84
|
+
if (fileConfig.tokenPresent || fileConfig.baseUrl) {
|
|
85
|
+
return toSummary({
|
|
86
|
+
agent,
|
|
87
|
+
source: 'config-file',
|
|
88
|
+
status: fileConfig.tokenPresent ? 'detected' : 'missing',
|
|
89
|
+
baseUrl: fileConfig.baseUrl,
|
|
90
|
+
token: fileConfig.token,
|
|
91
|
+
tokenPresent: fileConfig.tokenPresent,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return toSummary({
|
|
95
|
+
agent,
|
|
96
|
+
source: 'unknown',
|
|
97
|
+
status: 'missing',
|
|
98
|
+
tokenPresent: false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function loadManagedConfig() {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(fs.readFileSync(MANAGED_CONFIG_PATH, 'utf-8'));
|
|
104
|
+
return { configs: parsed.configs ?? {} };
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { configs: {} };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function saveManagedConfig(file) {
|
|
111
|
+
fs.mkdirSync(path.dirname(MANAGED_CONFIG_PATH), { recursive: true });
|
|
112
|
+
fs.writeFileSync(MANAGED_CONFIG_PATH, JSON.stringify(file, null, 2), { mode: 0o600 });
|
|
113
|
+
try {
|
|
114
|
+
fs.chmodSync(MANAGED_CONFIG_PATH, 0o600);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Best effort on filesystems without POSIX modes.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function compactEnv(env) {
|
|
121
|
+
const compacted = {};
|
|
122
|
+
for (const [key, value] of Object.entries(env)) {
|
|
123
|
+
if (value)
|
|
124
|
+
compacted[key] = value;
|
|
125
|
+
}
|
|
126
|
+
return compacted;
|
|
127
|
+
}
|
|
128
|
+
function detectFromEnv(agent, env) {
|
|
129
|
+
const keys = AGENT_ENV_KEYS[agent];
|
|
130
|
+
const baseUrl = firstEnv(env, keys.baseUrl);
|
|
131
|
+
const token = firstEnv(env, keys.token);
|
|
132
|
+
return { baseUrl, token, tokenPresent: !!token };
|
|
133
|
+
}
|
|
134
|
+
function firstEnv(env, keys) {
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
const value = env[key]?.trim();
|
|
137
|
+
if (value)
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
function detectFromKnownConfigFiles(agent) {
|
|
143
|
+
const home = os.homedir();
|
|
144
|
+
const candidates = agent === 'codex'
|
|
145
|
+
? [path.join(home, '.codex', 'config.toml'), path.join(home, '.codex', 'auth.json')]
|
|
146
|
+
: [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude.json')];
|
|
147
|
+
for (const file of candidates) {
|
|
148
|
+
const text = readSmallTextFile(file);
|
|
149
|
+
if (!text)
|
|
150
|
+
continue;
|
|
151
|
+
const token = extractTokenLikeValue(text, agent);
|
|
152
|
+
const baseUrl = extractBaseUrl(text);
|
|
153
|
+
if (token || baseUrl)
|
|
154
|
+
return { token, baseUrl, tokenPresent: !!token };
|
|
155
|
+
}
|
|
156
|
+
return { tokenPresent: false };
|
|
157
|
+
}
|
|
158
|
+
function readSmallTextFile(file) {
|
|
159
|
+
try {
|
|
160
|
+
const stat = fs.statSync(file);
|
|
161
|
+
if (!stat.isFile() || stat.size > 1024 * 1024)
|
|
162
|
+
return null;
|
|
163
|
+
return fs.readFileSync(file, 'utf-8');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function extractBaseUrl(text) {
|
|
170
|
+
const match = text.match(/(?:base_url|baseURL|api_url|apiUrl|ANTHROPIC_BASE_URL|OPENAI_BASE_URL)["'\s:=]+([^"'\s,}]+)/i);
|
|
171
|
+
return match?.[1]?.trim();
|
|
172
|
+
}
|
|
173
|
+
function extractTokenLikeValue(text, agent) {
|
|
174
|
+
const prefix = agent === 'claude' ? 'sk-ant-' : 'sk-';
|
|
175
|
+
const direct = text.match(new RegExp(`${prefix.replace(/-/g, '\\-')}[A-Za-z0-9_\\-]{8,}`));
|
|
176
|
+
if (direct?.[0])
|
|
177
|
+
return direct[0];
|
|
178
|
+
const keyMatch = text.match(/(?:api[_-]?key|auth[_-]?token|token)["'\s:=]+([A-Za-z0-9_\-.]{12,})/i);
|
|
179
|
+
return keyMatch?.[1]?.trim();
|
|
180
|
+
}
|
|
181
|
+
function toSummary(input) {
|
|
182
|
+
return {
|
|
183
|
+
status: input.status,
|
|
184
|
+
source: input.source,
|
|
185
|
+
tokenPresent: input.tokenPresent,
|
|
186
|
+
...(input.baseUrl ? { baseUrlHost: formatBaseUrlHost(input.baseUrl) } : {}),
|
|
187
|
+
...(input.token ? { tokenHint: maskToken(input.token) } : {}),
|
|
188
|
+
updatedAt: new Date().toISOString(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
export function maskToken(token) {
|
|
192
|
+
const trimmed = token.trim();
|
|
193
|
+
if (!trimmed)
|
|
194
|
+
return '';
|
|
195
|
+
const suffix = trimmed.slice(-TOKEN_SUFFIX_LENGTH);
|
|
196
|
+
return `••••${suffix}`;
|
|
197
|
+
}
|
|
198
|
+
function formatBaseUrlHost(baseUrl) {
|
|
199
|
+
try {
|
|
200
|
+
return new URL(baseUrl).host || baseUrl;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return baseUrl.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') || baseUrl;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -6,23 +6,28 @@ export function buildExternalChannelInstructions(channel, workDir, sessionId) {
|
|
|
6
6
|
const channelName = channel.name?.trim() || '外部消息通道';
|
|
7
7
|
const customPrompt = channel.systemPrompt?.trim();
|
|
8
8
|
const sessionHint = sessionId?.trim()
|
|
9
|
-
? `当前对话 ID:${sessionId}
|
|
9
|
+
? `当前对话 ID:${sessionId}。如果命令提示缺少外部通道上下文,在发送命令里补充 --session-id ${sessionId}。`
|
|
10
10
|
: '';
|
|
11
11
|
const workdirHint = workDir?.trim()
|
|
12
12
|
? `如果使用 shell_command,请设置 workdir 为 ${workDir}。不要用裸 /bin/zsh -lc 或 command_execution 运行此命令,因为那种环境可能拿不到当前对话的外部通道身份。`
|
|
13
13
|
: '如果使用 shell_command,请设置 workdir 为当前对话的项目目录。不要用裸 /bin/zsh -lc 或 command_execution 运行此命令,因为那种环境可能拿不到当前对话的外部通道身份。';
|
|
14
14
|
const sections = [
|
|
15
15
|
`当前对话已接入外部消息通道:${channelName}。`,
|
|
16
|
-
|
|
16
|
+
`外部企业微信群消息会以如下格式进入对话:\n外部企业微信群消息\n<时间> <用户昵称>: <内容>`,
|
|
17
17
|
channel.canReply === false
|
|
18
18
|
? '当前通道只允许接收消息,不要尝试向外部通道发送回复。'
|
|
19
19
|
: [
|
|
20
20
|
'当用户明确要求你向外部消息通道发送内容,或你需要回复一条外部消息时,调用:',
|
|
21
21
|
'shennian external send --text "<要发送的消息>"',
|
|
22
|
+
'发送图片:shennian external send-image --path "<图片绝对路径>" --caption "<可选说明>"',
|
|
23
|
+
'发送视频:shennian external send-video --path "<视频绝对路径>" --caption "<可选说明>"',
|
|
24
|
+
'发送文件:shennian external send-file --path "<文件绝对路径>" --caption "<可选说明>"',
|
|
25
|
+
'如果需要转发外部消息里的图片/视频/文件链接,先把链接下载到本地临时文件,再用对应的 --path 命令发送;不要直接发送短时效链接,也不要只口头说明已发送。',
|
|
22
26
|
sessionHint,
|
|
23
27
|
workdirHint,
|
|
24
28
|
'只发送用户可见的最终内容,不要发送内部推理、工具日志或实现细节。',
|
|
25
|
-
'
|
|
29
|
+
'对外消息必须像真人聊天:短回复一条发完;内容较多时按自然段拆成 2-4 条连续消息,每条只讲一个完整主题。',
|
|
30
|
+
'避免把超过 300-500 字的内容塞进单条消息;不要使用 Markdown、编号列表、项目符号或字面 \\n。',
|
|
26
31
|
].join('\n'),
|
|
27
32
|
'如果外部消息和当前任务无关,可以忽略或简短说明无需处理;如果处理需要时间,先用一句话确认,再继续完成任务。',
|
|
28
33
|
customPrompt ? `本通道附加约束:${customPrompt}` : '',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @arch docs/architecture/cli/model-discovery.md
|
|
2
2
|
// @test src/__tests__/model-switching.test.ts
|
|
3
|
+
import { getAgentConfigSummary } from '../config-status.js';
|
|
3
4
|
import { isCacheFresh, readCache, writeCache } from './cache.js';
|
|
4
5
|
import { discoverModelsForAgent } from './discovery.js';
|
|
5
6
|
function uniqueModels(models) {
|
|
@@ -15,10 +16,14 @@ function uniqueModels(models) {
|
|
|
15
16
|
}
|
|
16
17
|
export function getCachedAgentInfos(detectedAgents) {
|
|
17
18
|
const cache = readCache();
|
|
18
|
-
return detectedAgents.map((agent) =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
return detectedAgents.map((agent) => {
|
|
20
|
+
const config = getAgentConfigSummary(agent.type);
|
|
21
|
+
return {
|
|
22
|
+
type: agent.type,
|
|
23
|
+
models: cache.agents[agent.type]?.models ?? agent.models ?? [],
|
|
24
|
+
...(config ? { config } : {}),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
22
27
|
}
|
|
23
28
|
export async function refreshAgentInfos(detectedAgents, context) {
|
|
24
29
|
const cache = readCache();
|
|
@@ -59,9 +64,11 @@ export async function resolveAgentInfos(detectedAgents, context) {
|
|
|
59
64
|
}
|
|
60
65
|
}
|
|
61
66
|
}
|
|
67
|
+
const config = getAgentConfigSummary(agent.type);
|
|
62
68
|
infos.push({
|
|
63
69
|
type: agent.type,
|
|
64
70
|
models,
|
|
71
|
+
...(config ? { config } : {}),
|
|
65
72
|
});
|
|
66
73
|
}
|
|
67
74
|
if (changed) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function splitExternalReplyText(text: string): string[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @arch docs/features/manager-agent.md
|
|
2
|
+
// @test src/__tests__/external-reply-split.test.ts
|
|
3
|
+
const MAX_REPLY_CHARS = 500;
|
|
4
|
+
const TARGET_REPLY_CHARS = 360;
|
|
5
|
+
const MAX_REPLY_PARTS = 6;
|
|
6
|
+
function visualLength(text) {
|
|
7
|
+
return Array.from(text).length;
|
|
8
|
+
}
|
|
9
|
+
function sliceVisual(text, maxLength) {
|
|
10
|
+
return Array.from(text).slice(0, maxLength).join('');
|
|
11
|
+
}
|
|
12
|
+
function normalizeReplyText(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/\r\n/g, '\n')
|
|
15
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
16
|
+
.replace(/\n[ \t]+/g, '\n')
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
function splitLongParagraph(paragraph) {
|
|
20
|
+
const pieces = [];
|
|
21
|
+
const sentences = paragraph
|
|
22
|
+
.split(/(?<=[。!?!?;;])\s*/)
|
|
23
|
+
.map((part) => part.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
const units = sentences.length > 1 ? sentences : Array.from(paragraph);
|
|
26
|
+
let current = '';
|
|
27
|
+
for (const unit of units) {
|
|
28
|
+
const next = current ? `${current}${sentences.length > 1 ? '' : ''}${unit}` : unit;
|
|
29
|
+
if (current && visualLength(next) > TARGET_REPLY_CHARS) {
|
|
30
|
+
pieces.push(current);
|
|
31
|
+
current = unit;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
current = next;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (current)
|
|
38
|
+
pieces.push(current);
|
|
39
|
+
return pieces.flatMap((piece) => {
|
|
40
|
+
if (visualLength(piece) <= MAX_REPLY_CHARS)
|
|
41
|
+
return [piece];
|
|
42
|
+
const chars = Array.from(piece);
|
|
43
|
+
const chunks = [];
|
|
44
|
+
for (let i = 0; i < chars.length; i += TARGET_REPLY_CHARS) {
|
|
45
|
+
chunks.push(chars.slice(i, i + TARGET_REPLY_CHARS).join('').trim());
|
|
46
|
+
}
|
|
47
|
+
return chunks.filter(Boolean);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function capReplyParts(chunks) {
|
|
51
|
+
if (chunks.length <= MAX_REPLY_PARTS)
|
|
52
|
+
return chunks;
|
|
53
|
+
const head = chunks.slice(0, MAX_REPLY_PARTS - 1);
|
|
54
|
+
const tail = chunks.slice(MAX_REPLY_PARTS - 1).join('\n\n');
|
|
55
|
+
const tailText = visualLength(tail) > MAX_REPLY_CHARS
|
|
56
|
+
? `${sliceVisual(tail, MAX_REPLY_CHARS - 1)}…`
|
|
57
|
+
: tail;
|
|
58
|
+
return [...head, tailText].filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
export function splitExternalReplyText(text) {
|
|
61
|
+
const normalized = normalizeReplyText(text);
|
|
62
|
+
if (!normalized)
|
|
63
|
+
return [];
|
|
64
|
+
if (visualLength(normalized) <= MAX_REPLY_CHARS && !normalized.includes('\n\n'))
|
|
65
|
+
return [normalized];
|
|
66
|
+
const paragraphs = normalized
|
|
67
|
+
.split(/\n{2,}/)
|
|
68
|
+
.map((part) => part.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim())
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
if (paragraphs.length > 1 && visualLength(normalized) > TARGET_REPLY_CHARS) {
|
|
71
|
+
return capReplyParts(paragraphs.flatMap(splitLongParagraph));
|
|
72
|
+
}
|
|
73
|
+
const sourceParts = paragraphs.length > 1 ? paragraphs : [normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()];
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let current = '';
|
|
76
|
+
for (const part of sourceParts.flatMap(splitLongParagraph)) {
|
|
77
|
+
const next = current ? `${current}\n\n${part}` : part;
|
|
78
|
+
if (current && visualLength(next) > MAX_REPLY_CHARS) {
|
|
79
|
+
chunks.push(current);
|
|
80
|
+
current = part;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
current = next;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (current)
|
|
87
|
+
chunks.push(current);
|
|
88
|
+
return capReplyParts(chunks);
|
|
89
|
+
}
|
|
@@ -4,6 +4,7 @@ import { ChannelConfigRegistry } from './registry.js';
|
|
|
4
4
|
import { ChannelSecretRegistry } from './secret-registry.js';
|
|
5
5
|
import { WeComChannelAdapter } from './wecom.js';
|
|
6
6
|
import { ExternalWebSocketChannelAdapter } from './websocket.js';
|
|
7
|
+
import { splitExternalReplyText } from './reply-split.js';
|
|
7
8
|
export class ChannelRuntime {
|
|
8
9
|
onExternalMessage;
|
|
9
10
|
createReplyTarget;
|
|
@@ -52,7 +53,18 @@ export class ChannelRuntime {
|
|
|
52
53
|
if (!adapter)
|
|
53
54
|
return { ok: false, error: `Unsupported channel type: ${config.type}` };
|
|
54
55
|
try {
|
|
55
|
-
|
|
56
|
+
const parts = splitExternalReplyText(input.text);
|
|
57
|
+
if (!parts.length)
|
|
58
|
+
return { ok: false, error: 'Reply text is required' };
|
|
59
|
+
for (const [index, text] of parts.entries()) {
|
|
60
|
+
await adapter.send(config, {
|
|
61
|
+
...input,
|
|
62
|
+
text,
|
|
63
|
+
idempotencyKey: parts.length > 1 && input.idempotencyKey
|
|
64
|
+
? `${input.idempotencyKey}:${index + 1}`
|
|
65
|
+
: input.idempotencyKey,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
56
68
|
return { ok: true };
|
|
57
69
|
}
|
|
58
70
|
catch (err) {
|
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
// @arch docs/features/wecom-managed-channel.md
|
|
2
2
|
// @test src/__tests__/external-command.test.ts
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
4
5
|
import chalk from 'chalk';
|
|
5
6
|
import { resolveShennianPath } from '../config/index.js';
|
|
7
|
+
const MIME_BY_EXT = {
|
|
8
|
+
'.jpg': 'image/jpeg',
|
|
9
|
+
'.jpeg': 'image/jpeg',
|
|
10
|
+
'.png': 'image/png',
|
|
11
|
+
'.gif': 'image/gif',
|
|
12
|
+
'.webp': 'image/webp',
|
|
13
|
+
'.mp4': 'video/mp4',
|
|
14
|
+
'.mov': 'video/quicktime',
|
|
15
|
+
'.pdf': 'application/pdf',
|
|
16
|
+
'.txt': 'text/plain',
|
|
17
|
+
'.md': 'text/markdown',
|
|
18
|
+
'.csv': 'text/csv',
|
|
19
|
+
'.doc': 'application/msword',
|
|
20
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
21
|
+
'.xls': 'application/vnd.ms-excel',
|
|
22
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
23
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
24
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
25
|
+
'.zip': 'application/zip',
|
|
26
|
+
};
|
|
27
|
+
const MAX_EXTERNAL_ATTACHMENT_BYTES = Number(process.env.SHENNIAN_EXTERNAL_ATTACHMENT_MAX_BYTES || 50 * 1024 * 1024);
|
|
6
28
|
function loadManagerIpcFromRuntimeFile() {
|
|
7
29
|
try {
|
|
8
30
|
const parsed = JSON.parse(fs.readFileSync(resolveShennianPath('runtime', 'manager-ipc.json'), 'utf-8'));
|
|
@@ -28,8 +50,35 @@ function requireExternalContext(explicitSessionId) {
|
|
|
28
50
|
}
|
|
29
51
|
return { url, token, sessionId };
|
|
30
52
|
}
|
|
31
|
-
|
|
32
|
-
const
|
|
53
|
+
function inferMimeType(filePath, kind) {
|
|
54
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
55
|
+
if (MIME_BY_EXT[ext])
|
|
56
|
+
return MIME_BY_EXT[ext];
|
|
57
|
+
if (kind === 'image')
|
|
58
|
+
return 'image/jpeg';
|
|
59
|
+
if (kind === 'video')
|
|
60
|
+
return 'video/mp4';
|
|
61
|
+
return 'application/octet-stream';
|
|
62
|
+
}
|
|
63
|
+
function readAttachment(filePath, kind) {
|
|
64
|
+
const absolutePath = path.resolve(filePath);
|
|
65
|
+
const stat = fs.statSync(absolutePath);
|
|
66
|
+
if (!stat.isFile())
|
|
67
|
+
throw new Error(`Attachment is not a file: ${absolutePath}`);
|
|
68
|
+
if (stat.size > MAX_EXTERNAL_ATTACHMENT_BYTES) {
|
|
69
|
+
throw new Error(`Attachment is too large: ${stat.size} bytes. Max: ${MAX_EXTERNAL_ATTACHMENT_BYTES} bytes.`);
|
|
70
|
+
}
|
|
71
|
+
const buffer = fs.readFileSync(absolutePath);
|
|
72
|
+
return {
|
|
73
|
+
kind,
|
|
74
|
+
name: path.basename(absolutePath),
|
|
75
|
+
mimeType: inferMimeType(absolutePath, kind),
|
|
76
|
+
size: buffer.byteLength,
|
|
77
|
+
dataBase64: buffer.toString('base64'),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async function sendExternal(input) {
|
|
81
|
+
const ctx = requireExternalContext(input.sessionId);
|
|
33
82
|
const response = await fetch(`${ctx.url}/external/reply`, {
|
|
34
83
|
method: 'POST',
|
|
35
84
|
headers: {
|
|
@@ -39,8 +88,9 @@ async function sendExternal(text, idempotencyKey, sessionId) {
|
|
|
39
88
|
},
|
|
40
89
|
body: JSON.stringify({
|
|
41
90
|
managerSessionId: ctx.sessionId,
|
|
42
|
-
text,
|
|
43
|
-
|
|
91
|
+
text: input.text,
|
|
92
|
+
attachment: input.attachment,
|
|
93
|
+
idempotencyKey: input.idempotencyKey,
|
|
44
94
|
}),
|
|
45
95
|
});
|
|
46
96
|
const data = await response.json().catch(() => ({ ok: false, error: response.statusText }));
|
|
@@ -58,6 +108,51 @@ export function registerExternalCommand(program) {
|
|
|
58
108
|
.option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
|
|
59
109
|
.option('--idempotency-key <key>', 'Idempotency key')
|
|
60
110
|
.action(async (opts) => {
|
|
61
|
-
await sendExternal(opts.text, opts.idempotencyKey, opts.sessionId);
|
|
111
|
+
await sendExternal({ text: opts.text, idempotencyKey: opts.idempotencyKey, sessionId: opts.sessionId });
|
|
112
|
+
});
|
|
113
|
+
external
|
|
114
|
+
.command('send-image')
|
|
115
|
+
.description('Send an image file to the external channel bound to this conversation')
|
|
116
|
+
.requiredOption('--path <path>', 'Image file path')
|
|
117
|
+
.option('--caption <text>', 'Optional text to send before the image')
|
|
118
|
+
.option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
|
|
119
|
+
.option('--idempotency-key <key>', 'Idempotency key')
|
|
120
|
+
.action(async (opts) => {
|
|
121
|
+
await sendExternal({
|
|
122
|
+
text: opts.caption,
|
|
123
|
+
attachment: readAttachment(opts.path, 'image'),
|
|
124
|
+
idempotencyKey: opts.idempotencyKey,
|
|
125
|
+
sessionId: opts.sessionId,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
external
|
|
129
|
+
.command('send-video')
|
|
130
|
+
.description('Send a video file to the external channel bound to this conversation')
|
|
131
|
+
.requiredOption('--path <path>', 'Video file path')
|
|
132
|
+
.option('--caption <text>', 'Optional text to send before the video')
|
|
133
|
+
.option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
|
|
134
|
+
.option('--idempotency-key <key>', 'Idempotency key')
|
|
135
|
+
.action(async (opts) => {
|
|
136
|
+
await sendExternal({
|
|
137
|
+
text: opts.caption,
|
|
138
|
+
attachment: readAttachment(opts.path, 'video'),
|
|
139
|
+
idempotencyKey: opts.idempotencyKey,
|
|
140
|
+
sessionId: opts.sessionId,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
external
|
|
144
|
+
.command('send-file')
|
|
145
|
+
.description('Send a file to the external channel bound to this conversation')
|
|
146
|
+
.requiredOption('--path <path>', 'File path')
|
|
147
|
+
.option('--caption <text>', 'Optional text to send before the file')
|
|
148
|
+
.option('--session-id <id>', 'Shennian conversation/session id; defaults to injected current-session env')
|
|
149
|
+
.option('--idempotency-key <key>', 'Idempotency key')
|
|
150
|
+
.action(async (opts) => {
|
|
151
|
+
await sendExternal({
|
|
152
|
+
text: opts.caption,
|
|
153
|
+
attachment: readAttachment(opts.path, 'file'),
|
|
154
|
+
idempotencyKey: opts.idempotencyKey,
|
|
155
|
+
sessionId: opts.sessionId,
|
|
156
|
+
});
|
|
62
157
|
});
|
|
63
158
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const MANAGER_SYSTEM_PROMPT = "\u4F60\u662F\u9879\u76EE\u7ECF\u7406\uFF0C\u662F\u5F53\u524D\u9879\u76EE\u7684\u7BA1\u7406\u8005\u3002\n\n\u4F60\u7684\u804C\u8D23\uFF1A\n- \u7406\u89E3\u7528\u6237\u76EE\u6807\u3002\n- \u62C6\u89E3\u4EFB\u52A1\u3002\n- \u521B\u5EFA\u3001\u6307\u6D3E\u3001\u89C2\u5BDF\u548C\u505C\u6B62\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u4E0B\u7684 worker Agent session\u3002\n- \u6C47\u603B worker \u7ED3\u679C\u3002\n- \u5224\u65AD\u662F\u5426\u9700\u8981\u7EE7\u7EED\u7B49\u5F85\u3001\u8C03\u6574\u5B89\u6392\u3001\u8BE2\u95EE\u7528\u6237\u6216\u9A8C\u6536\u3002\n- \u5728\u9879\u76EE .shennian/ \u76EE\u5F55\u4E0B\u7EF4\u62A4\u5FC5\u8981\u7684\u8BA1\u5212\u3001\u8BB0\u5F55\u548C\u9879\u76EE\u8BB0\u5FC6\u3002\n\n\u4F60\u7684\u8FB9\u754C\uFF1A\n- \u4E0D\u8981\u628A\u81EA\u5DF1\u5F53\u4F5C\u4E3B\u8981\u6267\u884C\u8005\u3002\n- \u4E0D\u8981\u76F4\u63A5\u7F16\u8F91\u4E1A\u52A1\u4EE3\u7801\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u4EB2\u81EA\u6267\u884C\u3002\n- \u53EF\u4EE5\u8BFB\u53D6\u6587\u4EF6\u3001\u641C\u7D22\u9879\u76EE\u548C\u68C0\u67E5\u4E0A\u4E0B\u6587\uFF0C\u4EE5\u4FBF\u505A\u5224\u65AD\u3002\n- \u9700\u8981\u4FEE\u6539\u4EE3\u7801\u3001\u8FD0\u884C\u6D4B\u8BD5\u3001\u8C03\u7814\u65B9\u6848\u65F6\uFF0C\u4F18\u5148\u521B\u5EFA\u6216\u6307\u6D3E worker\u3002\n- \u521B\u5EFA worker \u540E\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u5F53\u573A\u7EE7\u7EED\u8C03\u5EA6\uFF0C\u5426\u5219\u56DE\u590D\u7528\u6237\u5DF2\u5B89\u6392\u5E76\u7ED3\u675F\u5F53\u524D turn\uFF1B\u4E0D\u8981\u4E3B\u52A8\u8F6E\u8BE2 worker \u72B6\u6001\uFF0C\u795E\u5FF5\u4F1A\u5728 worker \u7EC8\u6001\u6216\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u91CD\u65B0\u5524\u9192\u4F60\u3002\n- sessions read \u8FD4\u56DE\u7684\u662F\u7ED9\u7BA1\u7406\u8005\u770B\u7684\u7B80\u6D01\u8FDB\u5C55\u3001\u5DE5\u5177\u6458\u8981\u548C\u6700\u7EC8\u7ED3\u679C\uFF0C\u4E0D\u662F\u539F\u59CB\u6D41\u5F0F token\uFF1B\u4E0D\u8981\u8981\u6C42\u8BFB\u53D6\u6216\u8F6C\u8FF0\u5B8C\u6574\u6D41\u5F0F\u65E5\u5FD7\u3002\n- \u53EA\u80FD\u7BA1\u7406\u4E0E\u4F60\u5904\u4E8E\u540C\u4E00\u53F0\u673A\u5668\u3001\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u7684\u4F1A\u8BDD\uFF1B\u4E0D\u8981\u8DE8\u673A\u5668\u6216\u8DE8\u9879\u76EE\u8C03\u5EA6\u3002\n- \u4E0D\u8981\u65E0\u9650\u5FAA\u73AF\uFF1B\u6CA1\u6709\u660E\u786E\u4E0B\u4E00\u6B65\u65F6\u8BE2\u95EE\u7528\u6237\u6216\u7ED3\u675F\u5F53\u524D turn \u7B49\u5F85\u7CFB\u7EDF\u4E8B\u4EF6\u3002\n- \u4E0D\u8981\u81EA\u5DF1\u8BBE\u7F6E\u5B9A\u65F6\u5524\u9192\uFF1B\u795E\u5FF5\u4F1A\u5728\u7528\u6237\u6D88\u606F\u3001worker \u7EC8\u6001\u6216 worker \u957F\u8FD0\u884C\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u5524\u9192\u4F60\u3002\n- \u5916\u90E8\u6D88\u606F\u901A\u9053\u4E8B\u4EF6\u4F1A\u50CF\u666E\u901A\u7528\u6237\u6D88\u606F\u4E00\u6837\u9001\u8FBE\uFF0C\u683C\u5F0F\u7C7B\u4F3C\u201C\u5916\u90E8\u6D88\u606F / \u53D1\u9001\u4EBA\u201D\u540E\u8DDF\u6D88\u606F\u5185\u5BB9\uFF0C\u53EF\u80FD\u662F\u5408\u5E76\u6D88\u606F\uFF0C\u4E5F\u53EF\u80FD\u5305\u542B\u56FE\u7247\u3001\u89C6\u9891\u6216\u6587\u4EF6 URL\u3002\n- \u5BF9\u5916\u4F60\u662F\u5F53\u524D\u9879\u76EE\u7684\u9879\u76EE\u7ECF\u7406\uFF0C\u4E0D\u8981\u81EA\u79F0\u795E\u5FF5\u3001Manager Agent \u6216 worker\uFF0C\u4E5F\u4E0D\u8981\u89E3\u91CA\u5185\u90E8\u8C03\u5EA6\u673A\u5236\uFF1B\u53EA\u5728\u9700\u8981\u65F6\u7528\u201C\u6211\u8FD9\u8FB9/\u6211\u4EEC\u8FD9\u8FB9\u201D\u6C9F\u901A\u3002\n- \u5BF9\u5916\u56DE\u590D\u5FC5\u987B\u50CF\u771F\u4EBA\u804A\u5929\uFF1A\
|
|
1
|
+
export declare const MANAGER_SYSTEM_PROMPT = "\u4F60\u662F\u9879\u76EE\u7ECF\u7406\uFF0C\u662F\u5F53\u524D\u9879\u76EE\u7684\u7BA1\u7406\u8005\u3002\n\n\u4F60\u7684\u804C\u8D23\uFF1A\n- \u7406\u89E3\u7528\u6237\u76EE\u6807\u3002\n- \u62C6\u89E3\u4EFB\u52A1\u3002\n- \u521B\u5EFA\u3001\u6307\u6D3E\u3001\u89C2\u5BDF\u548C\u505C\u6B62\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u4E0B\u7684 worker Agent session\u3002\n- \u6C47\u603B worker \u7ED3\u679C\u3002\n- \u5224\u65AD\u662F\u5426\u9700\u8981\u7EE7\u7EED\u7B49\u5F85\u3001\u8C03\u6574\u5B89\u6392\u3001\u8BE2\u95EE\u7528\u6237\u6216\u9A8C\u6536\u3002\n- \u5728\u9879\u76EE .shennian/ \u76EE\u5F55\u4E0B\u7EF4\u62A4\u5FC5\u8981\u7684\u8BA1\u5212\u3001\u8BB0\u5F55\u548C\u9879\u76EE\u8BB0\u5FC6\u3002\n\n\u4F60\u7684\u8FB9\u754C\uFF1A\n- \u4E0D\u8981\u628A\u81EA\u5DF1\u5F53\u4F5C\u4E3B\u8981\u6267\u884C\u8005\u3002\n- \u4E0D\u8981\u76F4\u63A5\u7F16\u8F91\u4E1A\u52A1\u4EE3\u7801\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u4EB2\u81EA\u6267\u884C\u3002\n- \u53EF\u4EE5\u8BFB\u53D6\u6587\u4EF6\u3001\u641C\u7D22\u9879\u76EE\u548C\u68C0\u67E5\u4E0A\u4E0B\u6587\uFF0C\u4EE5\u4FBF\u505A\u5224\u65AD\u3002\n- \u9700\u8981\u4FEE\u6539\u4EE3\u7801\u3001\u8FD0\u884C\u6D4B\u8BD5\u3001\u8C03\u7814\u65B9\u6848\u65F6\uFF0C\u4F18\u5148\u521B\u5EFA\u6216\u6307\u6D3E worker\u3002\n- \u521B\u5EFA worker \u540E\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\u4F60\u5F53\u573A\u7EE7\u7EED\u8C03\u5EA6\uFF0C\u5426\u5219\u56DE\u590D\u7528\u6237\u5DF2\u5B89\u6392\u5E76\u7ED3\u675F\u5F53\u524D turn\uFF1B\u4E0D\u8981\u4E3B\u52A8\u8F6E\u8BE2 worker \u72B6\u6001\uFF0C\u795E\u5FF5\u4F1A\u5728 worker \u7EC8\u6001\u6216\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u91CD\u65B0\u5524\u9192\u4F60\u3002\n- sessions read \u8FD4\u56DE\u7684\u662F\u7ED9\u7BA1\u7406\u8005\u770B\u7684\u7B80\u6D01\u8FDB\u5C55\u3001\u5DE5\u5177\u6458\u8981\u548C\u6700\u7EC8\u7ED3\u679C\uFF0C\u4E0D\u662F\u539F\u59CB\u6D41\u5F0F token\uFF1B\u4E0D\u8981\u8981\u6C42\u8BFB\u53D6\u6216\u8F6C\u8FF0\u5B8C\u6574\u6D41\u5F0F\u65E5\u5FD7\u3002\n- \u53EA\u80FD\u7BA1\u7406\u4E0E\u4F60\u5904\u4E8E\u540C\u4E00\u53F0\u673A\u5668\u3001\u540C\u4E00\u9879\u76EE\u76EE\u5F55\u7684\u4F1A\u8BDD\uFF1B\u4E0D\u8981\u8DE8\u673A\u5668\u6216\u8DE8\u9879\u76EE\u8C03\u5EA6\u3002\n- \u4E0D\u8981\u65E0\u9650\u5FAA\u73AF\uFF1B\u6CA1\u6709\u660E\u786E\u4E0B\u4E00\u6B65\u65F6\u8BE2\u95EE\u7528\u6237\u6216\u7ED3\u675F\u5F53\u524D turn \u7B49\u5F85\u7CFB\u7EDF\u4E8B\u4EF6\u3002\n- \u4E0D\u8981\u81EA\u5DF1\u8BBE\u7F6E\u5B9A\u65F6\u5524\u9192\uFF1B\u795E\u5FF5\u4F1A\u5728\u7528\u6237\u6D88\u606F\u3001worker \u7EC8\u6001\u6216 worker \u957F\u8FD0\u884C\u5065\u5EB7\u6458\u8981\u5230\u6765\u65F6\u5524\u9192\u4F60\u3002\n- \u5916\u90E8\u6D88\u606F\u901A\u9053\u4E8B\u4EF6\u4F1A\u50CF\u666E\u901A\u7528\u6237\u6D88\u606F\u4E00\u6837\u9001\u8FBE\uFF0C\u683C\u5F0F\u7C7B\u4F3C\u201C\u5916\u90E8\u6D88\u606F / \u53D1\u9001\u4EBA\u201D\u540E\u8DDF\u6D88\u606F\u5185\u5BB9\uFF0C\u53EF\u80FD\u662F\u5408\u5E76\u6D88\u606F\uFF0C\u4E5F\u53EF\u80FD\u5305\u542B\u56FE\u7247\u3001\u89C6\u9891\u6216\u6587\u4EF6 URL\u3002\n- \u5BF9\u5916\u4F60\u662F\u5F53\u524D\u9879\u76EE\u7684\u9879\u76EE\u7ECF\u7406\uFF0C\u4E0D\u8981\u81EA\u79F0\u795E\u5FF5\u3001Manager Agent \u6216 worker\uFF0C\u4E5F\u4E0D\u8981\u89E3\u91CA\u5185\u90E8\u8C03\u5EA6\u673A\u5236\uFF1B\u53EA\u5728\u9700\u8981\u65F6\u7528\u201C\u6211\u8FD9\u8FB9/\u6211\u4EEC\u8FD9\u8FB9\u201D\u6C9F\u901A\u3002\n- \u5BF9\u5916\u56DE\u590D\u5FC5\u987B\u50CF\u771F\u4EBA\u804A\u5929\uFF1A\u77ED\u56DE\u590D\u4E00\u6761\u53D1\u5B8C\uFF1B\u5185\u5BB9\u8F83\u591A\u65F6\u6309\u81EA\u7136\u6BB5\u62C6\u6210 2-4 \u6761\u8FDE\u7EED\u6D88\u606F\uFF0C\u6BCF\u6761\u53EA\u8BB2\u4E00\u4E2A\u5B8C\u6574\u4E3B\u9898\u3002\n- \u907F\u514D\u628A\u8D85\u8FC7 300-500 \u5B57\u7684\u5185\u5BB9\u585E\u8FDB\u5355\u6761\u6D88\u606F\uFF1B\u4E0D\u8981\u4F7F\u7528 Markdown\u3001\u7F16\u53F7\u5217\u8868\u3001\u9879\u76EE\u7B26\u53F7\u6216\u5B57\u9762 \\n\u3002\n- \u5916\u90E8\u6D88\u606F\u4E0E\u5F53\u524D\u9879\u76EE\u65E0\u5173\u65F6\u53EF\u4EE5\u5FFD\u7565\uFF1B\u9700\u8981\u8F83\u957F\u5904\u7406\u65F6\uFF0C\u5148\u7B80\u77ED\u56DE\u590D\u201C\u6536\u5230\uFF0C\u6211\u5148\u5904\u7406/\u5B89\u6392\u4E00\u4E0B\u201D\uFF0C\u518D\u521B\u5EFA\u6216\u6307\u6D3E worker\u3002\n- \u5411\u5916\u90E8\u7FA4\u53D1\u6D88\u606F\u4E00\u5F8B\u8C03\u7528 shennian manager external send --text \"<\u6D88\u606F\u5185\u5BB9>\"\n- \u4E0D\u8981\u628A\u6240\u6709\u7EC6\u8282\u585E\u8FDB\u5BF9\u8BDD\u4E0A\u4E0B\u6587\uFF1B\u9700\u8981\u957F\u671F\u4FDD\u5B58\u7684\u4FE1\u606F\u5199\u5230\u9879\u76EE .shennian/ \u4E0B\u3002\n\n\u9700\u8981\u7BA1\u7406 worker \u6216\u5916\u90E8\u901A\u9053\u65F6\uFF0C\u4F7F\u7528\u672C\u5730\u547D\u4EE4\uFF1A\n- shennian manager sessions list --json\n- shennian manager sessions start --agent codex --workdir <path> --message <text>\n- shennian manager sessions send --session-id <id> --message <text>\n- shennian manager sessions send --session-id <id> --message <text> --direct\n- shennian manager sessions queue list --session-id <id> --json\n- shennian manager sessions queue edit --session-id <id> --message-id <queueMessageId> --message <text>\n- shennian manager sessions queue delete --session-id <id> --message-id <queueMessageId>\n- shennian manager sessions stop --session-id <id>\n- shennian manager sessions read --session-id <id> --limit 200 --json\n- shennian manager memory path\n- shennian manager external send --text <text>\n\n\u9ED8\u8BA4\u7528 sessions send \u6392\u961F\u53D1\u9001 worker \u6D88\u606F\uFF1Aworker \u6B63\u5FD9\u65F6\u6D88\u606F\u4F1A\u5728\u672C\u673A daemon \u961F\u5217\u91CC\u7B49\u5F85\uFF0Cworker \u7A7A\u95F2\u65F6\u81EA\u52A8\u6267\u884C\u3002\u961F\u5217\u91CC\u7684\u672A\u6267\u884C\u6D88\u606F\u53EF\u4EE5 list/edit/delete\uFF1B\u5DF2\u7ECF\u5F00\u59CB\u6267\u884C\u7684\u6D88\u606F\u4E0D\u80FD\u7F16\u8F91\u6216\u5220\u9664\uFF0C\u53EA\u80FD stop \u540E\u91CD\u65B0\u53D1\u9001\u3002\u53EA\u6709\u660E\u786E\u9700\u8981\u6253\u65AD\u987A\u5E8F\u65F6\u624D\u4F7F\u7528 --direct\u3002\n\n\u8FD9\u4E9B\u547D\u4EE4\u5DF2\u7ECF\u7531\u795E\u5FF5\u6CE8\u5165\u5F53\u524D Manager \u8EAB\u4EFD\u548C\u540C\u9879\u76EE\u6743\u9650\u8FB9\u754C\u3002\u4E0D\u8981\u5C1D\u8BD5\u4F2A\u9020 Manager session id\u3002";
|
|
2
2
|
export declare function buildManagerPrompt(userText: string): string;
|
|
@@ -22,7 +22,8 @@ export const MANAGER_SYSTEM_PROMPT = `你是项目经理,是当前项目的管
|
|
|
22
22
|
- 不要自己设置定时唤醒;神念会在用户消息、worker 终态或 worker 长运行健康摘要到来时唤醒你。
|
|
23
23
|
- 外部消息通道事件会像普通用户消息一样送达,格式类似“外部消息 / 发送人”后跟消息内容,可能是合并消息,也可能包含图片、视频或文件 URL。
|
|
24
24
|
- 对外你是当前项目的项目经理,不要自称神念、Manager Agent 或 worker,也不要解释内部调度机制;只在需要时用“我这边/我们这边”沟通。
|
|
25
|
-
-
|
|
25
|
+
- 对外回复必须像真人聊天:短回复一条发完;内容较多时按自然段拆成 2-4 条连续消息,每条只讲一个完整主题。
|
|
26
|
+
- 避免把超过 300-500 字的内容塞进单条消息;不要使用 Markdown、编号列表、项目符号或字面 \\n。
|
|
26
27
|
- 外部消息与当前项目无关时可以忽略;需要较长处理时,先简短回复“收到,我先处理/安排一下”,再创建或指派 worker。
|
|
27
28
|
- 向外部群发消息一律调用 shennian manager external send --text "<消息内容>"
|
|
28
29
|
- 不要把所有细节塞进对话上下文;需要长期保存的信息写到项目 .shennian/ 下。
|
|
@@ -9,6 +9,7 @@ import { extractPayloadText, isToolPayload } from '@shennian/wire';
|
|
|
9
9
|
import { ManagerRegistry } from './registry.js';
|
|
10
10
|
import { readMessages } from '../session/store.js';
|
|
11
11
|
import { ChannelRuntime } from '../channels/runtime.js';
|
|
12
|
+
import { splitExternalReplyText } from '../channels/reply-split.js';
|
|
12
13
|
import { resolveShennianPath } from '../config/index.js';
|
|
13
14
|
let singleton = null;
|
|
14
15
|
export function setManagerRuntimeService(service) {
|
|
@@ -41,6 +42,21 @@ function toolSummary(payload) {
|
|
|
41
42
|
return '[tool]';
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
function parseExternalReplyAttachment(value) {
|
|
46
|
+
if (!value || typeof value !== 'object')
|
|
47
|
+
return undefined;
|
|
48
|
+
const record = value;
|
|
49
|
+
const kind = String(record.kind || '');
|
|
50
|
+
const name = String(record.name || '');
|
|
51
|
+
const mimeType = String(record.mimeType || '');
|
|
52
|
+
const dataBase64 = String(record.dataBase64 || '');
|
|
53
|
+
const size = Number(record.size || 0);
|
|
54
|
+
if (kind !== 'image' && kind !== 'video' && kind !== 'file')
|
|
55
|
+
return undefined;
|
|
56
|
+
if (!name || !mimeType || !dataBase64 || !Number.isFinite(size) || size <= 0)
|
|
57
|
+
return undefined;
|
|
58
|
+
return { kind, name, mimeType, dataBase64, size };
|
|
59
|
+
}
|
|
44
60
|
function compactWorkerTranscript(rawMessages, limit) {
|
|
45
61
|
const chronological = [...rawMessages].sort((a, b) => a.ts - b.ts);
|
|
46
62
|
const compacted = [];
|
|
@@ -476,11 +492,13 @@ export class ManagerRuntimeService {
|
|
|
476
492
|
? this.registry.getReplyTarget(body.replyTarget)
|
|
477
493
|
: this.registry.getLatestReplyTargetForManager(managerSessionId);
|
|
478
494
|
const text = String(body.text || '');
|
|
495
|
+
const attachment = parseExternalReplyAttachment(body.attachment);
|
|
479
496
|
const idempotencyKey = String(body.idempotencyKey || randomUUID());
|
|
480
497
|
try {
|
|
481
498
|
const relayResult = await this.sendManagedWeComReply({
|
|
482
499
|
managerSessionId,
|
|
483
500
|
text,
|
|
501
|
+
attachment,
|
|
484
502
|
idempotencyKey,
|
|
485
503
|
});
|
|
486
504
|
if (relayResult.ok) {
|
|
@@ -570,25 +588,45 @@ export class ManagerRuntimeService {
|
|
|
570
588
|
});
|
|
571
589
|
}
|
|
572
590
|
async sendManagedWeComReply(input) {
|
|
573
|
-
|
|
574
|
-
|
|
591
|
+
const parts = splitExternalReplyText(input.text);
|
|
592
|
+
if (!parts.length && !input.attachment)
|
|
593
|
+
return { ok: false, error: 'text or attachment is required' };
|
|
575
594
|
const client = this.opts.getRuntime().client;
|
|
576
595
|
if (!client || typeof client.sendReq !== 'function') {
|
|
577
596
|
return { ok: false, error: 'Relay is not connected' };
|
|
578
597
|
}
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
598
|
+
const payloads = [];
|
|
599
|
+
for (const [index, text] of parts.entries()) {
|
|
600
|
+
const frame = await client.sendReq({
|
|
601
|
+
type: 'req',
|
|
602
|
+
id: `external-send-${randomUUID()}`,
|
|
603
|
+
method: 'external.send',
|
|
604
|
+
params: {
|
|
605
|
+
managerSessionId: input.managerSessionId,
|
|
606
|
+
text,
|
|
607
|
+
idempotencyKey: parts.length > 1 ? `${input.idempotencyKey}:${index + 1}` : input.idempotencyKey,
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
if (!frame.ok)
|
|
611
|
+
return { ok: false, error: frame.error || 'External send failed' };
|
|
612
|
+
payloads.push(frame.payload);
|
|
613
|
+
}
|
|
614
|
+
if (input.attachment) {
|
|
615
|
+
const frame = await client.sendReq({
|
|
616
|
+
type: 'req',
|
|
617
|
+
id: `external-send-${randomUUID()}`,
|
|
618
|
+
method: 'external.send',
|
|
619
|
+
params: {
|
|
620
|
+
managerSessionId: input.managerSessionId,
|
|
621
|
+
attachment: input.attachment,
|
|
622
|
+
idempotencyKey: parts.length ? `${input.idempotencyKey}:attachment` : input.idempotencyKey,
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
if (!frame.ok)
|
|
626
|
+
return { ok: false, error: frame.error || 'External send failed' };
|
|
627
|
+
payloads.push(frame.payload);
|
|
628
|
+
}
|
|
629
|
+
return { ok: true, payload: payloads.length === 1 ? payloads[0] : payloads };
|
|
592
630
|
}
|
|
593
631
|
wakeManagerForWorker(managerSessionId, worker, state, message) {
|
|
594
632
|
const manager = this.registry.getManager(managerSessionId);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AgentType, ReqFrame } from '@shennian/wire';
|
|
2
|
+
import type { SessionManagerRuntime } from '../types.js';
|
|
3
|
+
export declare function handleAgentConfigGet(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
4
|
+
export declare function handleAgentConfigUpsert(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
5
|
+
export declare function handleAgentConfigClear(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
6
|
+
export declare function handleAgentConfigTest(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
7
|
+
export declare function getManagedEnvForAgent(agentType: AgentType): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @arch docs/features/agent-provider-config.md
|
|
2
|
+
// @test src/__tests__/agent-config-status.test.ts
|
|
3
|
+
import { buildManagedAgentEnv, deleteManagedAgentProviderConfig, getAgentConfigSummary, upsertManagedAgentProviderConfig, } from '../../agents/config-status.js';
|
|
4
|
+
import { handleAgentsRefresh } from './agents.js';
|
|
5
|
+
function normalizeAgent(value) {
|
|
6
|
+
if (value === 'codex' || value === 'claude')
|
|
7
|
+
return value;
|
|
8
|
+
throw new Error('Only Codex and Claude Code provider config are supported');
|
|
9
|
+
}
|
|
10
|
+
export async function handleAgentConfigGet(runtime, req) {
|
|
11
|
+
const agent = normalizeAgent(req.params.agent);
|
|
12
|
+
runtime.client.sendRes({
|
|
13
|
+
type: 'res',
|
|
14
|
+
id: req.id,
|
|
15
|
+
ok: true,
|
|
16
|
+
payload: { agent, config: getAgentConfigSummary(agent) },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function handleAgentConfigUpsert(runtime, req) {
|
|
20
|
+
const agent = normalizeAgent(req.params.agent);
|
|
21
|
+
const baseUrl = typeof req.params.baseUrl === 'string' ? req.params.baseUrl.trim() : undefined;
|
|
22
|
+
const token = typeof req.params.token === 'string' ? req.params.token.trim() : undefined;
|
|
23
|
+
if (!baseUrl && !token)
|
|
24
|
+
throw new Error('Base URL or token is required');
|
|
25
|
+
upsertManagedAgentProviderConfig({ agent, baseUrl, token });
|
|
26
|
+
runtime.client.sendRes({
|
|
27
|
+
type: 'res',
|
|
28
|
+
id: req.id,
|
|
29
|
+
ok: true,
|
|
30
|
+
payload: { agent, config: getAgentConfigSummary(agent) },
|
|
31
|
+
});
|
|
32
|
+
await broadcastAgents(runtime);
|
|
33
|
+
}
|
|
34
|
+
export async function handleAgentConfigClear(runtime, req) {
|
|
35
|
+
const agent = normalizeAgent(req.params.agent);
|
|
36
|
+
deleteManagedAgentProviderConfig(agent);
|
|
37
|
+
runtime.client.sendRes({
|
|
38
|
+
type: 'res',
|
|
39
|
+
id: req.id,
|
|
40
|
+
ok: true,
|
|
41
|
+
payload: { agent, config: getAgentConfigSummary(agent) },
|
|
42
|
+
});
|
|
43
|
+
await broadcastAgents(runtime);
|
|
44
|
+
}
|
|
45
|
+
export async function handleAgentConfigTest(runtime, req) {
|
|
46
|
+
const agent = normalizeAgent(req.params.agent);
|
|
47
|
+
const summary = getAgentConfigSummary(agent);
|
|
48
|
+
runtime.client.sendRes({
|
|
49
|
+
type: 'res',
|
|
50
|
+
id: req.id,
|
|
51
|
+
ok: !!summary?.tokenPresent,
|
|
52
|
+
payload: { agent, config: summary },
|
|
53
|
+
...(!summary?.tokenPresent ? { error: 'Token is missing on this machine' } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function broadcastAgents(runtime) {
|
|
57
|
+
const req = {
|
|
58
|
+
type: 'req',
|
|
59
|
+
id: `agent-config-refresh-${Date.now()}`,
|
|
60
|
+
method: 'agents.refresh',
|
|
61
|
+
params: {},
|
|
62
|
+
};
|
|
63
|
+
await handleAgentsRefresh(runtime, req);
|
|
64
|
+
}
|
|
65
|
+
export function getManagedEnvForAgent(agentType) {
|
|
66
|
+
// Re-export point kept here for session handlers to avoid reaching into storage internals.
|
|
67
|
+
// The implementation lives in agents/config-status.ts.
|
|
68
|
+
return agentType === 'codex' || agentType === 'claude'
|
|
69
|
+
? buildManagedAgentEnv(agentType)
|
|
70
|
+
: {};
|
|
71
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReqFrame } from '@shennian/wire';
|
|
2
2
|
import type { SessionManagerRuntime } from '../types.js';
|
|
3
3
|
export declare function handleChatSend(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
4
4
|
export declare function handleChatAbort(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
@@ -2,11 +2,32 @@
|
|
|
2
2
|
// @test src/__tests__/session-manager.test.ts
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { createAgent } from '../../agents/adapter.js';
|
|
5
|
+
import { buildUserMessagePayload } from '@shennian/wire';
|
|
5
6
|
import { reportLog } from '../../log-reporter.js';
|
|
6
7
|
import { lookupClaudeTranscriptCwd } from '../../native-fusion/parsers.js';
|
|
7
8
|
import { appendMessage, recordSession } from '../store.js';
|
|
8
9
|
import { mergeProjectedSessions } from '../projection.js';
|
|
9
10
|
import { getManagerRuntimeService } from '../../manager/runtime.js';
|
|
11
|
+
import { buildManagedAgentEnv } from '../../agents/config-status.js';
|
|
12
|
+
function normalizeChatAttachments(value) {
|
|
13
|
+
if (!Array.isArray(value))
|
|
14
|
+
return undefined;
|
|
15
|
+
const attachments = value
|
|
16
|
+
.map((item) => {
|
|
17
|
+
if (!item || typeof item !== 'object')
|
|
18
|
+
return null;
|
|
19
|
+
const entry = item;
|
|
20
|
+
const path = typeof entry.path === 'string' ? entry.path : '';
|
|
21
|
+
const name = typeof entry.name === 'string' ? entry.name : '';
|
|
22
|
+
const mimeType = typeof entry.mimeType === 'string' ? entry.mimeType : '';
|
|
23
|
+
if (!path || !name || !mimeType)
|
|
24
|
+
return null;
|
|
25
|
+
const previewData = typeof entry.previewData === 'string' && entry.previewData.trim() ? entry.previewData.trim() : undefined;
|
|
26
|
+
return { path, name, mimeType, kind: mimeType.startsWith('image/') ? 'image' : 'file', ...(previewData ? { previewData } : {}) };
|
|
27
|
+
})
|
|
28
|
+
.filter((item) => item != null);
|
|
29
|
+
return attachments.length ? attachments : undefined;
|
|
30
|
+
}
|
|
10
31
|
function extractSummary(text) {
|
|
11
32
|
const newline = text.indexOf('\n');
|
|
12
33
|
const end = newline > 0 ? Math.min(newline, 80) : Math.min(text.length, 80);
|
|
@@ -77,6 +98,9 @@ function normalizeExternalChannel(value) {
|
|
|
77
98
|
function externalChannelEnabled(channel) {
|
|
78
99
|
return Boolean(channel?.configured ?? channel?.connected);
|
|
79
100
|
}
|
|
101
|
+
function managedProviderEnv(agentType) {
|
|
102
|
+
return buildManagedAgentEnv(agentType);
|
|
103
|
+
}
|
|
80
104
|
function externalChannelEnv(sessionId, channel) {
|
|
81
105
|
if (!externalChannelEnabled(channel))
|
|
82
106
|
return {};
|
|
@@ -88,11 +112,14 @@ function externalChannelEnv(sessionId, channel) {
|
|
|
88
112
|
SHENNIAN_MANAGER_SESSION_ID: sessionId,
|
|
89
113
|
};
|
|
90
114
|
}
|
|
91
|
-
function configureAdapterForSession(adapter, sessionId, channel) {
|
|
115
|
+
function configureAdapterForSession(adapter, sessionId, agentType, channel) {
|
|
92
116
|
adapter.configure?.({
|
|
93
117
|
sessionId,
|
|
94
118
|
externalChannel: channel ?? null,
|
|
95
|
-
env:
|
|
119
|
+
env: {
|
|
120
|
+
...managedProviderEnv(agentType),
|
|
121
|
+
...externalChannelEnv(sessionId, channel),
|
|
122
|
+
},
|
|
96
123
|
});
|
|
97
124
|
}
|
|
98
125
|
function getSessionExternalChannel(runtime, sessionId, agentType) {
|
|
@@ -284,7 +311,7 @@ async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDi
|
|
|
284
311
|
const adapter = createAgent(agentType);
|
|
285
312
|
if (!adapter)
|
|
286
313
|
throw new Error(`Unsupported agent: ${agentType}`);
|
|
287
|
-
configureAdapterForSession(adapter, sessionId, externalChannel);
|
|
314
|
+
configureAdapterForSession(adapter, sessionId, agentType, externalChannel);
|
|
288
315
|
await adapter.start(sessionId, resolvedWorkDir, incomingAgentSid);
|
|
289
316
|
const session = {
|
|
290
317
|
adapter,
|
|
@@ -296,7 +323,10 @@ async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDi
|
|
|
296
323
|
nextEventSeq: 0,
|
|
297
324
|
pendingTextEvent: null,
|
|
298
325
|
externalChannel: externalChannel ?? null,
|
|
299
|
-
externalChannelEnv:
|
|
326
|
+
externalChannelEnv: {
|
|
327
|
+
...managedProviderEnv(agentType),
|
|
328
|
+
...externalChannelEnv(sessionId, externalChannel),
|
|
329
|
+
},
|
|
300
330
|
};
|
|
301
331
|
runtime.sessions.set(sessionId, session);
|
|
302
332
|
bindAdapterEvents(runtime, sessionId, agentType, adapter);
|
|
@@ -326,12 +356,13 @@ export async function handleChatSend(runtime, req) {
|
|
|
326
356
|
return;
|
|
327
357
|
}
|
|
328
358
|
rememberProcessedReqId(runtime, req.id);
|
|
329
|
-
const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, reasoningEffort, clientMessageId, sessionListProjection, waitForDispatch } = req.params;
|
|
359
|
+
const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, reasoningEffort, clientMessageId, sessionListProjection, waitForDispatch, responseId } = req.params;
|
|
360
|
+
const replyId = responseId || req.id;
|
|
330
361
|
mergeProjectedSessions(sessionListProjection);
|
|
331
362
|
const incomingExternalChannel = normalizeExternalChannel(req.params.externalChannel);
|
|
332
363
|
if (!sessionId || !text) {
|
|
333
364
|
runtime.processedReqIds.delete(req.id);
|
|
334
|
-
runtime.client.sendRes({ type: 'res', id:
|
|
365
|
+
runtime.client.sendRes({ type: 'res', id: replyId, ok: false, error: 'sessionId and text are required' });
|
|
335
366
|
return;
|
|
336
367
|
}
|
|
337
368
|
const requestedAgentType = agentType;
|
|
@@ -397,7 +428,7 @@ export async function handleChatSend(runtime, req) {
|
|
|
397
428
|
runtime.processedReqIds.delete(req.id);
|
|
398
429
|
runtime.client.sendRes({
|
|
399
430
|
type: 'res',
|
|
400
|
-
id:
|
|
431
|
+
id: replyId,
|
|
401
432
|
ok: false,
|
|
402
433
|
error: message,
|
|
403
434
|
});
|
|
@@ -409,7 +440,7 @@ export async function handleChatSend(runtime, req) {
|
|
|
409
440
|
sessionId,
|
|
410
441
|
role: 'user',
|
|
411
442
|
ts: Date.now(),
|
|
412
|
-
payload: text,
|
|
443
|
+
payload: buildUserMessagePayload(text, normalizeChatAttachments(req.params.attachments)),
|
|
413
444
|
};
|
|
414
445
|
reportLog({
|
|
415
446
|
level: 'info',
|
|
@@ -466,7 +497,7 @@ export async function handleChatSend(runtime, req) {
|
|
|
466
497
|
runtime.processedReqIds.delete(req.id);
|
|
467
498
|
runtime.client.sendRes({
|
|
468
499
|
type: 'res',
|
|
469
|
-
id:
|
|
500
|
+
id: replyId,
|
|
470
501
|
ok: false,
|
|
471
502
|
error: message,
|
|
472
503
|
});
|
|
@@ -474,7 +505,10 @@ export async function handleChatSend(runtime, req) {
|
|
|
474
505
|
};
|
|
475
506
|
if (waitForDispatch) {
|
|
476
507
|
try {
|
|
477
|
-
|
|
508
|
+
const attachments = normalizeChatAttachments(req.params.attachments);
|
|
509
|
+
if (attachments?.length)
|
|
510
|
+
await session.adapter.send(text, modelId, resolvedReasoningEffort, attachments);
|
|
511
|
+
else if (resolvedReasoningEffort)
|
|
478
512
|
await session.adapter.send(text, modelId, resolvedReasoningEffort);
|
|
479
513
|
else
|
|
480
514
|
await session.adapter.send(text, modelId);
|
|
@@ -490,7 +524,7 @@ export async function handleChatSend(runtime, req) {
|
|
|
490
524
|
return;
|
|
491
525
|
}
|
|
492
526
|
markAccepted();
|
|
493
|
-
runtime.client.sendRes({ type: 'res', id:
|
|
527
|
+
runtime.client.sendRes({ type: 'res', id: replyId, ok: true });
|
|
494
528
|
reportLog({
|
|
495
529
|
level: 'info',
|
|
496
530
|
sessionId,
|
|
@@ -500,7 +534,7 @@ export async function handleChatSend(runtime, req) {
|
|
|
500
534
|
return;
|
|
501
535
|
}
|
|
502
536
|
markAccepted();
|
|
503
|
-
runtime.client.sendRes({ type: 'res', id:
|
|
537
|
+
runtime.client.sendRes({ type: 'res', id: replyId, ok: true });
|
|
504
538
|
reportLog({
|
|
505
539
|
level: 'info',
|
|
506
540
|
sessionId,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @arch docs/architecture/cli/native-session-fusion.md
|
|
2
|
+
// @test src/__tests__/session-manager.test.ts
|
|
3
|
+
export async function handleSessionRefresh(runtime, req) {
|
|
4
|
+
const params = req.params;
|
|
5
|
+
if (!params.sessionId) {
|
|
6
|
+
runtime.client.sendRes({
|
|
7
|
+
type: 'res',
|
|
8
|
+
id: req.id,
|
|
9
|
+
ok: false,
|
|
10
|
+
error: 'sessionId is required',
|
|
11
|
+
});
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!runtime.nativeFusion) {
|
|
15
|
+
runtime.client.sendRes({
|
|
16
|
+
type: 'res',
|
|
17
|
+
id: req.id,
|
|
18
|
+
ok: true,
|
|
19
|
+
payload: { scanned: false },
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await runtime.nativeFusion.scanNow();
|
|
24
|
+
runtime.client.sendRes({
|
|
25
|
+
type: 'res',
|
|
26
|
+
id: req.id,
|
|
27
|
+
ok: true,
|
|
28
|
+
payload: {
|
|
29
|
+
scanned: true,
|
|
30
|
+
sessionId: params.sessionId,
|
|
31
|
+
agentType: params.agentType ?? null,
|
|
32
|
+
agentSessionId: params.agentSessionId ?? null,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -7,7 +7,9 @@ import { getRegisteredAgents, unregisterAgent } from '../agents/adapter.js';
|
|
|
7
7
|
import { loadConfig } from '../config/index.js';
|
|
8
8
|
import { handleUpgradeStart, handleUpgradeStatus } from '../commands/upgrade.js';
|
|
9
9
|
import { handleAgentsRefresh, handleModelsRefresh } from './handlers/agents.js';
|
|
10
|
+
import { handleAgentConfigClear, handleAgentConfigGet, handleAgentConfigTest, handleAgentConfigUpsert, } from './handlers/agent-config.js';
|
|
10
11
|
import { handleChatAbort, handleChatSend } from './handlers/chat.js';
|
|
12
|
+
import { handleSessionRefresh } from './handlers/session-refresh.js';
|
|
11
13
|
import { cleanupPendingTransfers, handleFsLs, handleFsRead, handleFsTransfer, handleFsTransferAbort, handleFsTransferChunk, handleFsTransferFinish, handleFsTransferStart, } from './handlers/fs.js';
|
|
12
14
|
import { handleRegionProbe, handleRegionSwitch, handleUpgradeSetPolicy } from './handlers/control.js';
|
|
13
15
|
import { ManagerRuntimeService, setManagerRuntimeService } from '../manager/runtime.js';
|
|
@@ -127,6 +129,9 @@ export class SessionManager {
|
|
|
127
129
|
case 'chat.abort':
|
|
128
130
|
await handleChatAbort(runtime, req);
|
|
129
131
|
break;
|
|
132
|
+
case 'session.refresh':
|
|
133
|
+
await handleSessionRefresh(runtime, req);
|
|
134
|
+
break;
|
|
130
135
|
case 'fs.ls':
|
|
131
136
|
await handleFsLs(runtime, req);
|
|
132
137
|
break;
|
|
@@ -171,6 +176,18 @@ export class SessionManager {
|
|
|
171
176
|
case 'models.refresh':
|
|
172
177
|
await handleModelsRefresh(runtime, req);
|
|
173
178
|
break;
|
|
179
|
+
case 'agent.config.get':
|
|
180
|
+
await handleAgentConfigGet(runtime, req);
|
|
181
|
+
break;
|
|
182
|
+
case 'agent.config.upsert':
|
|
183
|
+
await handleAgentConfigUpsert(runtime, req);
|
|
184
|
+
break;
|
|
185
|
+
case 'agent.config.clear':
|
|
186
|
+
await handleAgentConfigClear(runtime, req);
|
|
187
|
+
break;
|
|
188
|
+
case 'agent.config.test':
|
|
189
|
+
await handleAgentConfigTest(runtime, req);
|
|
190
|
+
break;
|
|
174
191
|
case 'manager.channel.get':
|
|
175
192
|
case 'manager.channel.upsert':
|
|
176
193
|
await runtime.managerRuntime?.handleAppReq(req);
|
|
@@ -11,6 +11,9 @@ function emptyQueue() {
|
|
|
11
11
|
function nowIso() {
|
|
12
12
|
return new Date().toISOString();
|
|
13
13
|
}
|
|
14
|
+
function forwardedReqId(reqId) {
|
|
15
|
+
return `enqueue-send-${reqId}-${randomUUID()}`;
|
|
16
|
+
}
|
|
14
17
|
function readQueue() {
|
|
15
18
|
try {
|
|
16
19
|
const parsed = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
|
|
@@ -38,6 +41,7 @@ function normalizeAttachments(value) {
|
|
|
38
41
|
path: typeof entry.path === 'string' ? entry.path : '',
|
|
39
42
|
name: typeof entry.name === 'string' ? entry.name : '',
|
|
40
43
|
mimeType: typeof entry.mimeType === 'string' ? entry.mimeType : '',
|
|
44
|
+
...(typeof entry.previewData === 'string' && entry.previewData.trim() ? { previewData: entry.previewData.trim() } : {}),
|
|
41
45
|
};
|
|
42
46
|
return attachment.path && attachment.name && attachment.mimeType ? attachment : null;
|
|
43
47
|
})
|
|
@@ -94,9 +98,11 @@ export class ChatQueueManager {
|
|
|
94
98
|
if (!isBusy && !(readQueue().sessions[params.sessionId]?.length)) {
|
|
95
99
|
await this.opts.dispatchReq({
|
|
96
100
|
...req,
|
|
101
|
+
id: forwardedReqId(req.id),
|
|
97
102
|
method: 'chat.send',
|
|
98
103
|
params: {
|
|
99
104
|
...params,
|
|
105
|
+
responseId: req.id,
|
|
100
106
|
clientMessageId: params.clientMessageId ?? params.queueMessageId,
|
|
101
107
|
waitForDispatch: true,
|
|
102
108
|
},
|
|
@@ -263,6 +269,7 @@ export class ChatQueueManager {
|
|
|
263
269
|
clientMessageId: message.clientMessageId ?? message.id,
|
|
264
270
|
attachments: message.attachments,
|
|
265
271
|
externalChannel: message.externalChannel,
|
|
272
|
+
waitForDispatch: true,
|
|
266
273
|
},
|
|
267
274
|
});
|
|
268
275
|
}
|