ai_co_discuss 0.2.28 → 0.2.31
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/.agents/skills/discuss-host/SKILL.md +1 -1
- package/README.md +3 -3
- package/backend/free_room.py +52 -12
- package/backend/room_import.py +2 -0
- package/backend/runtime.py +5 -3
- package/backend/schema.sql +7 -0
- package/backend/server.py +7 -3
- package/docs/architecture.md +3 -1
- package/docs/cli.md +2 -2
- package/docs/runner.md +34 -0
- package/docs/workflow.md +5 -3
- package/package.json +2 -2
- package/runner/api_participant.mjs +2 -1
- package/runner/discuss.py +26 -10
- package/runner/discussion_api.mjs +20 -4
- package/runner/discussion_launch.mjs +17 -8
- package/runner/discussion_runner.mjs +43 -7
- package/runner/discussion_service.mjs +1 -1
- package/runner/free_runner.mjs +54 -5
- package/runner/scheduler.mjs +99 -0
- package/web/app.js +86 -21
- package/web/modern.css +1 -1
- package/web/modern.js +1227 -997
|
@@ -59,10 +59,16 @@ export function saved_api_keys(home) {
|
|
|
59
59
|
/** Resolve only a validated reference bound to this exact API root. */
|
|
60
60
|
export function read_api_key(home, profile) {
|
|
61
61
|
validate_api_profile(profile);
|
|
62
|
+
return read_saved_api_key(home, profile.base_url, profile.api_key_ref);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read one saved key by reference, refusing a key saved for a different root. */
|
|
66
|
+
export function read_saved_api_key(home, base_url, api_key_ref) {
|
|
67
|
+
if (!/^[a-f0-9-]{36}$/i.test(api_key_ref || '')) throw new Error('请保存此 Base URL 对应的 API Key');
|
|
62
68
|
let saved;
|
|
63
|
-
try { saved = JSON.parse(readFileSync(join(home, 'api-keys', `${
|
|
69
|
+
try { saved = JSON.parse(readFileSync(join(home, 'api-keys', `${api_key_ref}.json`), 'utf8')); }
|
|
64
70
|
catch { throw new Error('本机 API Key 不存在,请重新保存'); }
|
|
65
|
-
if (saved.base_url !== api_base_url(
|
|
71
|
+
if (saved.base_url !== api_base_url(base_url)) throw new Error('Base URL 已变化,请为新地址重新填写 API Key');
|
|
66
72
|
return saved.api_key;
|
|
67
73
|
}
|
|
68
74
|
|
|
@@ -90,6 +96,16 @@ function api_headers(protocol, key) {
|
|
|
90
96
|
return { Authorization: `Bearer ${key}` };
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/** Say whether fixing the setup or waiting helps; a wait hint never implies an uncertain run may be replayed. */
|
|
100
|
+
export function api_status_message(status) {
|
|
101
|
+
const head = `模型 API 返回 HTTP ${status}`;
|
|
102
|
+
if (status === 401 || status === 403) return `${head}:密钥无效或无权访问该模型,请核对 API Key 与模型权限`;
|
|
103
|
+
if (status === 404) return `${head}:地址或模型 ID 不存在,请核对 Base URL、请求协议和模型 ID`;
|
|
104
|
+
if (status === 429) return `${head}:请求过多或额度已用尽,请等待恢复后再试(执行中出现时,先核对房间里是否已有本次内容)`;
|
|
105
|
+
if (status >= 500) return `${head}:服务暂时不可用,请稍后再试(执行中出现时,先核对房间里是否已有本次内容)`;
|
|
106
|
+
return `${head},请核对地址、密钥、模型和请求协议`;
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
/** A bounded request; errors expose only status, never provider bodies or authorization. */
|
|
94
110
|
async function provider_fetch(profile, key, request, signal) {
|
|
95
111
|
let response;
|
|
@@ -100,8 +116,8 @@ async function provider_fetch(profile, key, request, signal) {
|
|
|
100
116
|
body: request.body === undefined ? undefined : JSON.stringify(request.body),
|
|
101
117
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
102
118
|
});
|
|
103
|
-
} catch { throw new Error(signal?.aborted ? 'API 执行已取消' : 'API
|
|
104
|
-
if (!response.ok) throw new Error(
|
|
119
|
+
} catch { throw new Error(signal?.aborted ? 'API 执行已取消' : 'API 请求超时或连接失败:可能是网络暂时不通或 Base URL 有误,请核对'); }
|
|
120
|
+
if (!response.ok) throw new Error(api_status_message(response.status));
|
|
105
121
|
try { return await response.json(); } catch { throw new Error('模型 API 返回的内容不是有效 JSON'); }
|
|
106
122
|
}
|
|
107
123
|
|
|
@@ -11,6 +11,7 @@ import { parseArgs } from 'node:util';
|
|
|
11
11
|
import { atomic_json } from './discussion_adapters.mjs';
|
|
12
12
|
import { software_models } from './discussion_models.mjs';
|
|
13
13
|
import { API_PROVIDERS, api_check, api_models, save_api_key, saved_api_keys, validate_api_profile } from './discussion_api.mjs';
|
|
14
|
+
import { SCHEDULER_PROVIDERS } from './scheduler.mjs';
|
|
14
15
|
import { acquire_lock, api, CODEX_ADAPTERS, DiscussionStore, service_drafts, task_files, validate_config } from './discussion_runner.mjs';
|
|
15
16
|
import { assert_shared_home, CONNECTION_FIELDS, configuration_snapshot, read_registry, registered_identity, register_room, service_home, service_pid, start_service } from './discussion_service.mjs';
|
|
16
17
|
|
|
@@ -220,6 +221,11 @@ function launch_input(input) {
|
|
|
220
221
|
return value;
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
/** Discussion experts the member list marks as manual: they answer with their own credential and need no local software. */
|
|
225
|
+
function manual_experts(room) {
|
|
226
|
+
return new Set((room.members || []).filter(member => member && typeof member === 'object' && member.access === 'manual').map(member => member.name));
|
|
227
|
+
}
|
|
228
|
+
|
|
223
229
|
/** Merge machine defaults and explicit room overrides, preserving the selected participants. */
|
|
224
230
|
function launch_config(home, input, room, directory, options) {
|
|
225
231
|
if (room.mode === 'free') return free_launch_config(home, input, room, directory, options);
|
|
@@ -228,12 +234,15 @@ function launch_config(home, input, room, directory, options) {
|
|
|
228
234
|
const online = Object.entries(room.experts).filter(([, mode]) => mode !== 'cli').map(([name]) => name);
|
|
229
235
|
if (online.length) throw new Error(`全自动无法接入在线专家:${online.join('、')}。请调整参与方式或改用手动推进`);
|
|
230
236
|
const defaults = options.defaults || launch_settings(home).participants;
|
|
237
|
+
const manual = manual_experts(room);
|
|
238
|
+
if (manual.has(room.host)) throw new Error('AI 主持人由本机服务续接,不能选择手动接入');
|
|
231
239
|
const participants = {};
|
|
232
240
|
for (const name of new Set([room.host, ...Object.keys(room.experts)])) {
|
|
233
|
-
participants[name] = participant_profile(name, defaults, input, name === room.host);
|
|
241
|
+
if (!manual.has(name)) participants[name] = participant_profile(name, defaults, input, name === room.host);
|
|
234
242
|
}
|
|
235
243
|
const members = launch_members(input, Object.keys(participants), options);
|
|
236
244
|
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, participants, members, api_home: home,
|
|
245
|
+
scheduler: input.scheduler || undefined,
|
|
237
246
|
cwd: input.cwd, discussion_project: input.discussion_project, project_access: input.project_access || undefined,
|
|
238
247
|
data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
|
|
239
248
|
}
|
|
@@ -248,7 +257,7 @@ function free_launch_config(home, input, room, directory, options) {
|
|
|
248
257
|
const participants = Object.fromEntries(names.map(name => [name, participant_profile(name, defaults, input, false)]));
|
|
249
258
|
const members = launch_members(input, names, options);
|
|
250
259
|
return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, mode: 'free', participants, members, api_home: home,
|
|
251
|
-
cwd: input.cwd, project_access: input.project_access || undefined,
|
|
260
|
+
scheduler: input.scheduler || undefined, cwd: input.cwd, project_access: input.project_access || undefined,
|
|
252
261
|
data_directory: join(directory, 'runtime'), draft_directory: service_drafts(home, join(directory, 'runtime')) }, directory);
|
|
253
262
|
}
|
|
254
263
|
|
|
@@ -344,15 +353,15 @@ function manual_access(path, input, name, token) {
|
|
|
344
353
|
/**
|
|
345
354
|
* Invite (add) or bring back (rejoin) one member of a running free room or discussion.
|
|
346
355
|
* The local configuration is written before the website call, so a retry reuses the same credential.
|
|
347
|
-
*
|
|
356
|
+
* A discussion's manual expert answers with its own credential and is never woken.
|
|
348
357
|
*/
|
|
349
358
|
export async function room_member(home, raw, locked = false) {
|
|
350
359
|
const { input, path, config: registered } = await member_room_context(home, raw);
|
|
351
360
|
const { action, name } = input;
|
|
352
361
|
const access = input.access || 'runner';
|
|
353
362
|
const free = registered.mode === 'free';
|
|
354
|
-
if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !
|
|
355
|
-
throw new Error(
|
|
363
|
+
if (!['add', 'rejoin'].includes(action) || !platform_of(name) || (access !== 'manual' && !clients_of(name)) || !['runner', 'manual'].includes(access)) {
|
|
364
|
+
throw new Error('action 须为 add/rejoin,成员须为支持的 AI,接入方式须为 runner/manual');
|
|
356
365
|
}
|
|
357
366
|
const release = locked ? () => {} : acquire_lock(join(dirname(path), 'configuration-edit'));
|
|
358
367
|
try {
|
|
@@ -663,8 +672,8 @@ async function perform_launch(home, context, options) {
|
|
|
663
672
|
members: [...Object.keys(config.participants).map(name => ({ name, adapter: config.participants[name].adapter,
|
|
664
673
|
token: config.members[name].token, access: 'runner', session_mode: config.participants[name].session_id ? 'borrowed' : 'new' })),
|
|
665
674
|
// Manual and online members keep the credentials the browser generated; nothing local is configured for them.
|
|
666
|
-
...(
|
|
667
|
-
.map(({ name, token, access }) => ({ name, token, access, adapter: '', session_mode: 'new' }))
|
|
675
|
+
...(input.room.members || []).filter(member => free ? !woken_member(member) : member?.access === 'manual')
|
|
676
|
+
.map(({ name, token, access }) => ({ name, token, access, adapter: '', session_mode: 'new' }))],
|
|
668
677
|
});
|
|
669
678
|
config.room = room.id;
|
|
670
679
|
await verify_control(config, room);
|
|
@@ -705,7 +714,7 @@ export async function launch_main(args = process.argv.slice(2), input_text) {
|
|
|
705
714
|
assert_shared_home(home);
|
|
706
715
|
let result;
|
|
707
716
|
if (positionals[0] === 'settings') result = { ...(input ? save_launch_settings(home, input) : launch_settings(home)), api_providers: API_PROVIDERS,
|
|
708
|
-
api_keys: saved_api_keys(home) };
|
|
717
|
+
scheduler_providers: SCHEDULER_PROVIDERS, api_keys: saved_api_keys(home) };
|
|
709
718
|
else if (positionals[0] === 'api-key' && input) result = save_api_key(home, input);
|
|
710
719
|
else if (positionals[0] === 'api-check' && input) result = await api_check(home, input);
|
|
711
720
|
else if (positionals[0] === 'room-models' && input) result = room_models(home, input);
|
|
@@ -9,6 +9,7 @@ import { parseArgs } from 'node:util';
|
|
|
9
9
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
10
|
import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
11
11
|
import { read_api_key } from './discussion_api.mjs';
|
|
12
|
+
import { suggest_interjection, validate_scheduler } from './scheduler.mjs';
|
|
12
13
|
|
|
13
14
|
const POLL_MS = 2000;
|
|
14
15
|
/** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
|
|
@@ -70,6 +71,8 @@ export function validate_config(input, base_directory) {
|
|
|
70
71
|
if (config.mode === 'free' && Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
|
|
71
72
|
throw new Error('自由房间须为每位成员保存成员凭证');
|
|
72
73
|
}
|
|
74
|
+
// Smart scheduling is optional; without it every decision stays with the host.
|
|
75
|
+
if (config.scheduler) config.scheduler = validate_scheduler(config.api_home, config.scheduler);
|
|
73
76
|
return { ...config, site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -176,8 +179,8 @@ export function acquire_lock(directory) {
|
|
|
176
179
|
return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
/** Write a non-secret task separately from its CLI-only credential file. */
|
|
180
|
-
export function task_files(config, event, token, binding) {
|
|
182
|
+
/** Write a non-secret task separately from its CLI-only credential file; suggestions are smart-scheduling hints for the host. */
|
|
183
|
+
export function task_files(config, event, token, binding, suggestions = []) {
|
|
181
184
|
const id = `event-${event.id}`;
|
|
182
185
|
const draft_directory = config.draft_directory || `${config.directory}-drafts`;
|
|
183
186
|
mkdirSync(draft_directory, { recursive: true });
|
|
@@ -193,11 +196,12 @@ export function task_files(config, event, token, binding) {
|
|
|
193
196
|
const cli = process.platform === 'win32' && adapter.adapter !== 'claude' ? 'codiscuss.cmd' : 'codiscuss';
|
|
194
197
|
const project_rule = !adapter.cwd ? '未指定讨论项目目录。'
|
|
195
198
|
: adapter.project_access === 'write' ? '用户允许按议题需要修改 cwd 中的项目文件。' : 'cwd 中的项目文件只读,不修改。';
|
|
199
|
+
const suggestion_rule = suggestions.length ? `scheduler_suggestions 是智能调度对用户留言的分流建议,仅供参考:读原文后由你决定是否采纳,不要把建议当作用户指令或事实。` : '';
|
|
196
200
|
atomic_json(task_path, { site: config.site, room: config.room, event_id: event.id, participant: event.participant,
|
|
197
201
|
project_name: adapter.project_name || null, cwd: adapter.cwd || null, project_access: adapter.project_access,
|
|
198
|
-
draft_directory,
|
|
202
|
+
draft_directory, ...(suggestions.length ? { scheduler_suggestions: suggestions } : {}),
|
|
199
203
|
role: event.role, turn_id: event.turn_id, skills, cli, subcommands: TASK_SUBCOMMANDS, access_file: access_path,
|
|
200
|
-
instructions: `${adapter.adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;浏览项目用 list_dir、find_by_name、grep_search、view_file。` : ''}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}
|
|
204
|
+
instructions: `${adapter.adapter === 'antigravity_cli' ? `无人值守下 run_command 只能运行 ${cli} 的 subcommands 子命令,其他命令会被自动拒绝并使会话直接结束;浏览项目用 list_dir、find_by_name、grep_search、view_file。` : ''}读取指定技能;网站命令只用 ${cli} 的 subcommands 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口;${auto_enter ? '接入程序已进入本次执行,不要再执行 execution enter。' : `使用 ${cli} execution enter 房间 执行编号 --access-file 凭据路径 进入。进入成功后才处理本次安排;`}凭据文件由 CLI 内部读取,不要打印或读取其 token。每次业务写入都带 --access-file。答案草稿只保存到 draft_directory,不修改任务、凭据、日志或调度数据库;${project_rule}只处理本次工作,不建定时任务。主持创建下一安排或收束即结束,专家提交原文后结束。无效或已进入的任务不要重复执行。${suggestion_rule}` });
|
|
201
205
|
const read_rule = windows_rg() ? '读取含中文的技能或文本文件时用 rg --encoding utf-8 . 文件路径,不用 Get-Content(Windows PowerShell 会按系统编码读出乱码,需要反复重试)。' : '';
|
|
202
206
|
const scope = `本次只放行任务内 subcommands 列出的 ${cli} 子命令,命令以 ${cli} 开头,不用 python discuss.py、cd、管道或其他入口。任务内 cwd 是讨论项目目录;${project_rule}项目读写仍受软件实际权限和议题授权约束。不读取凭据内容。${read_rule}`;
|
|
203
207
|
const plan_rule = event.role === 'host' ? '安排专家回应时先写发言计划,用 turn create --plan-file 一次排满本轮每位专家,不要每次只排一位。' : '';
|
|
@@ -222,6 +226,7 @@ export class DiscussionRunner {
|
|
|
222
226
|
this.running = new Map();
|
|
223
227
|
this.seen = new Set();
|
|
224
228
|
this.blocked = new Set();
|
|
229
|
+
this.suggestions = new Map();
|
|
225
230
|
}
|
|
226
231
|
/** Append one timestamped stage to the event's trace file when CODISCUSS_TRACE=1; tracing must never affect delivery. */
|
|
227
232
|
trace(id, stage, detail = {}) {
|
|
@@ -246,15 +251,17 @@ export class DiscussionRunner {
|
|
|
246
251
|
const binding = this.store.binding(event.participant);
|
|
247
252
|
if (!binding.session_id && adapter.session_id) binding.session_id = adapter.session_id;
|
|
248
253
|
const attempts = row.attempts + 1;
|
|
254
|
+
// Ask before recording the dispatch, so a slow or failed suggestion never leaves an uncertain delivery behind.
|
|
255
|
+
const suggestions = event.role === 'host' ? await this.interjection_suggestions() : [];
|
|
249
256
|
this.store.record(row.id, { status: 'dispatching', sent_at: Date.now(), attempts, error: '', retry_at: 0 });
|
|
250
257
|
let invoked = false;
|
|
251
258
|
let entered = false;
|
|
252
259
|
try {
|
|
253
|
-
const task = task_files(this.config, event, row.token, binding);
|
|
260
|
+
const task = task_files(this.config, event, row.token, binding, suggestions);
|
|
254
261
|
invoked = true;
|
|
255
262
|
const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
|
|
256
263
|
api_context: { site: this.config.site, room: this.config.room, token: row.token, home: this.config.api_home,
|
|
257
|
-
event_id: row.id, role: event.role, turn_id: event.turn_id },
|
|
264
|
+
event_id: row.id, role: event.role, turn_id: event.turn_id, scheduler_suggestions: suggestions },
|
|
258
265
|
on_trace: (stage, detail) => this.trace(row.id, stage, detail),
|
|
259
266
|
on_binding: session => {
|
|
260
267
|
this.store.bind(event.participant, session);
|
|
@@ -275,6 +282,33 @@ export class DiscussionRunner {
|
|
|
275
282
|
retry_at: not_sent ? Date.now() + (DELIVERY_RETRY_DELAYS_MS[attempts - 1] ?? 0) : 0 });
|
|
276
283
|
}
|
|
277
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* With smart scheduling on, suggest how to handle each user message the host has not processed yet.
|
|
287
|
+
* Suggestions never act on the room; any failure only leaves the host without them.
|
|
288
|
+
*/
|
|
289
|
+
async interjection_suggestions() {
|
|
290
|
+
if (!this.config.scheduler) return [];
|
|
291
|
+
try {
|
|
292
|
+
const room = await api(this.config, '');
|
|
293
|
+
if (!room.held_since) return [];
|
|
294
|
+
const page = await api(this.config, `/messages?after=${Math.max(0, room.held_since - 11)}`);
|
|
295
|
+
const experts = Object.keys(room.experts || {}).filter(name => name !== room.host);
|
|
296
|
+
const suggestions = [];
|
|
297
|
+
for (const message of page.messages.filter(item => item.id >= room.held_since && item.author === '用户')) {
|
|
298
|
+
if (!this.suggestions.has(message.id)) {
|
|
299
|
+
const suggestion = await suggest_interjection(this.config.api_home, this.config.scheduler,
|
|
300
|
+
{ title: room.title, context: page.messages, message, host: room.host, experts });
|
|
301
|
+
this.suggestions.set(message.id, suggestion);
|
|
302
|
+
this.log(`智能调度建议 #${message.id}:${suggestion ? `${suggestion.suggestion}(置信度 ${suggestion.confidence})` : '无可用建议'}`);
|
|
303
|
+
}
|
|
304
|
+
if (this.suggestions.get(message.id)) suggestions.push(this.suggestions.get(message.id));
|
|
305
|
+
}
|
|
306
|
+
return suggestions;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
this.log(`智能调度建议失败,主持人照常处理:${error.message}`);
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
278
312
|
/** Enter once per delivery; a failure is recorded so reconcile revokes the execution. */
|
|
279
313
|
async enter(row, session_id) {
|
|
280
314
|
try {
|
|
@@ -314,7 +348,7 @@ export class DiscussionRunner {
|
|
|
314
348
|
if (this.exited.has(row.id) && ['dispatched', 'started'].includes(event.status)) {
|
|
315
349
|
// A business write may have just completed the task; recheck before declaring a silent exit.
|
|
316
350
|
const latest = await api(this.config, `/events/${row.id}`, { action: 'view' });
|
|
317
|
-
if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果${this.exited.get(row.id) ? `:${this.exited.get(row.id)}` : ''}
|
|
351
|
+
if (['dispatched', 'started'].includes(latest.status)) return this.fail(row, `${event.participant} 会话已结束,但未保存本次结果${this.exited.get(row.id) ? `:${this.exited.get(row.id)}` : ''};请核对原会话确认是否已有内容`);
|
|
318
352
|
return this.reconcile(row, latest);
|
|
319
353
|
}
|
|
320
354
|
if (event.status === 'started' && event.lease_until <= Math.floor(Date.now() / 1000)) return this.fail(row, '执行租约到期;旧会话不能继续写入');
|
|
@@ -387,6 +421,8 @@ export class DiscussionRunner {
|
|
|
387
421
|
}
|
|
388
422
|
for (const { row, event } of pending) if (!this.seen.has(row.id)) { this.seen.add(row.id); this.trace(row.id, 'event_seen', { event_created_at: event.created_at }); }
|
|
389
423
|
const next = eligible.at(-1);
|
|
424
|
+
// A manual expert has no local configuration: its pending event waits for the member's own answer.
|
|
425
|
+
if (next && !this.config.participants[next.event.participant]) return;
|
|
390
426
|
if (next && this.running.get(next.event.participant) && !this.blocked.has(next.row.id)) {
|
|
391
427
|
this.blocked.add(next.row.id);
|
|
392
428
|
this.trace(next.row.id, 'blocked_by_running_process', { participant: next.event.participant });
|
|
@@ -261,7 +261,7 @@ class RoomWorker {
|
|
|
261
261
|
|
|
262
262
|
/** Keep workers aligned with the registry and tick each one per poll. */
|
|
263
263
|
export class DiscussionService {
|
|
264
|
-
constructor(home, create_runner = (config, store, log) => config.mode === 'free' ? new FreeRoomRunner(config, store) : new DiscussionRunner(config, store, undefined, log), log = () => {}) {
|
|
264
|
+
constructor(home, create_runner = (config, store, log) => config.mode === 'free' ? new FreeRoomRunner(config, store, undefined, log) : new DiscussionRunner(config, store, undefined, log), log = () => {}) {
|
|
265
265
|
this.home = home;
|
|
266
266
|
this.create_runner = create_runner;
|
|
267
267
|
this.log = log;
|
package/runner/free_runner.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import { atomic_json, deliver_task, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
|
|
7
7
|
import { api } from './discussion_runner.mjs';
|
|
8
|
+
import { route_free_message } from './scheduler.mjs';
|
|
8
9
|
|
|
9
10
|
const RENEW_MS = 30_000;
|
|
10
11
|
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
@@ -68,24 +69,38 @@ export function free_task_files(config, member, execution, binding) {
|
|
|
68
69
|
const host_rule = member.role === 'host'
|
|
69
70
|
? `你是本房间的主持人:purpose 为 route 的请求是用户未点名的消息,handoff 表示你派出的成员都已结束。先用 ${cli} room view ${config.room} 查看成员与状态,再决定由谁处理;需要派活时在回答中加 --to 成员名(可重复,不能点名自己),被点名的成员都结束后你会再被唤醒一次。不需要他人时不加 --to,本次即结束。只派分给各成员的文件或模块,避免多人改同一文件。`
|
|
70
71
|
: '';
|
|
72
|
+
const scheduled_rule = execution.requests.some(item => item.purpose === 'scheduled')
|
|
73
|
+
? 'purpose 为 scheduled 的消息用户没有点名,是智能调度判断交给你的;若它其实需要主持人拆分或协调,简短说明后结束即可。' : '';
|
|
71
74
|
atomic_json(task_path, { site: config.site, room: config.room, mode: 'free', member: member.name,
|
|
72
75
|
execution_id: execution.id, request_messages: execution.requests.map(item => item.message_id),
|
|
73
76
|
request_purposes: execution.requests.map(item => item.purpose || 'ask'), role: member.role || 'member',
|
|
74
77
|
read_after: execution.read_until, cwd: profile.cwd || null, project_access: profile.project_access, draft_directory,
|
|
75
78
|
skills: [join(config.project, '.agents/skills/discuss-participant/SKILL.md')], cli, subcommands: TASK_SUBCOMMANDS,
|
|
76
79
|
access_file: access_path,
|
|
77
|
-
instructions: `自由协作房间:用户点名了你(${messages})。先用 ${cli} message list ${config.room} --after ${execution.read_until} --access-file 凭据路径 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID --access-file 凭据路径 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
|
|
80
|
+
instructions: `自由协作房间:用户点名了你(${messages})。先用 ${cli} message list ${config.room} --after ${execution.read_until} --access-file 凭据路径 读取新消息,按时间顺序处理,后面的消息可能修正前面的,以最新指令为准。${host_rule}${scheduled_rule}完成后用 ${cli} message post ${config.room} --as ${member.name} --kind chat --execution ${execution.id} --body-file 草稿 --submission-id 新UUID --access-file 凭据路径 提交一次回答。提交被拒绝说明本次执行已被打断或过期:保留草稿并结束,不要去掉 --execution 改作普通发言。草稿只写入 draft_directory;${project_rule}不读取或打印凭据内容,不建定时任务。` });
|
|
78
81
|
const prompt = `用户在自由协作房间点名了你(${member.name}),对应消息 ${messages}。请先读取任务文件:${task_path},按其中说明读取新消息、完成请求,并用 ${cli} message post 带 --execution ${execution.id} 提交一次回答后结束。命令只用任务内 subcommands 列出的 ${cli} 子命令;${project_rule}不读取凭据内容。`;
|
|
79
82
|
return { id, participant: member.name, directory: config.directory, draft_directory,
|
|
80
83
|
skills_directory: join(config.project, '.agents', 'skills'), access_path, prompt };
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
const ADAPTER_LABELS = { codex: 'Codex App', codex_cli: 'Codex CLI', claude: 'Claude Code', antigravity: 'Antigravity App',
|
|
87
|
+
antigravity_cli: 'Antigravity CLI', api: '模型 API,只能读写房间消息' };
|
|
88
|
+
|
|
89
|
+
/** What a scheduler can know about a member without a role field: its software and project access. */
|
|
90
|
+
function member_description(name, profile) {
|
|
91
|
+
const access = profile.adapter === 'api' ? '' : profile.project_access === 'write' ? ',可修改项目文件' : ',项目文件只读';
|
|
92
|
+
return `${name}(${ADAPTER_LABELS[profile.adapter] || profile.adapter}${access})`;
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
/** Delivers free-room requests through the same software adapters as discussions. */
|
|
84
96
|
export class FreeRoomRunner {
|
|
85
|
-
constructor(config, store, deliver = deliver_task) {
|
|
97
|
+
constructor(config, store, deliver = deliver_task, log = () => {}, fetch_impl = fetch) {
|
|
86
98
|
this.config = config;
|
|
87
99
|
this.store = store;
|
|
88
100
|
this.deliver = deliver;
|
|
101
|
+
this.log = log;
|
|
102
|
+
this.fetch = fetch_impl;
|
|
103
|
+
this.routed = new Set();
|
|
89
104
|
this.children = new Set();
|
|
90
105
|
this.members = new Map();
|
|
91
106
|
this.exited = new Set();
|
|
@@ -124,8 +139,10 @@ export class FreeRoomRunner {
|
|
|
124
139
|
}
|
|
125
140
|
/** One bounded iteration: reconcile live executions, then deliver idle members with open requests. */
|
|
126
141
|
async tick() {
|
|
127
|
-
|
|
142
|
+
let inbox = await api(this.config, '/inbox');
|
|
128
143
|
const room = await api(this.config, '');
|
|
144
|
+
// Re-read after smart scheduling moved a request, so the host is not woken for it in this same pass.
|
|
145
|
+
if (this.config.scheduler && !inbox.archived && await this.route_unnamed(inbox, room)) inbox = await api(this.config, '/inbox');
|
|
129
146
|
this.room_title = room.title;
|
|
130
147
|
const active = new Map(inbox.executions.map(item => [item.id, item]));
|
|
131
148
|
const present = new Set(inbox.members.map(member => member.name));
|
|
@@ -140,6 +157,38 @@ export class FreeRoomRunner {
|
|
|
140
157
|
else if (!inbox.archived && member.pending.length && !this.members.get(member.name)?.size) await this.start(member, row);
|
|
141
158
|
}
|
|
142
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Smart scheduling: give each unnamed message waiting for the host directly to one woken member when the
|
|
162
|
+
* provider is confident. Each message is decided once; a failure or low confidence leaves it with the host.
|
|
163
|
+
*/
|
|
164
|
+
async route_unnamed(inbox, room) {
|
|
165
|
+
const host = inbox.members.find(member => member.role === 'host');
|
|
166
|
+
const waiting = host?.pending.filter(request => request.purpose === 'route' && !this.routed.has(request.id)) || [];
|
|
167
|
+
const members = inbox.members.filter(member => member !== host && this.config.participants[member.name]).map(member => ({ name: member.name,
|
|
168
|
+
description: member_description(member.name, this.config.participants[member.name]) }));
|
|
169
|
+
let moved = false;
|
|
170
|
+
for (const request of waiting) {
|
|
171
|
+
this.routed.add(request.id);
|
|
172
|
+
if (!members.length) continue;
|
|
173
|
+
try {
|
|
174
|
+
const page = await api(this.config, `/messages?after=${Math.max(0, request.message_id - 11)}`);
|
|
175
|
+
const message = page.messages.find(item => item.id === request.message_id);
|
|
176
|
+
if (!message) continue;
|
|
177
|
+
const decision = await route_free_message(this.config.api_home, this.config.scheduler,
|
|
178
|
+
{ title: room.title, context: page.messages, message, host: host.name, members }, this.fetch);
|
|
179
|
+
this.log(`智能调度 #${message.id}:${decision.member ? `派给 ${decision.member}` : '交回主持人'}(选择 ${decision.choice ?? '-'},置信度 ${decision.confidence ?? '-'})`);
|
|
180
|
+
if (!decision.member) continue;
|
|
181
|
+
const target = inbox.members.find(member => member.name === decision.member);
|
|
182
|
+
await api(this.config, `/requests/${request.id}`, { action: 'schedule', member_id: target.id,
|
|
183
|
+
provider: this.config.scheduler.provider, confidence: decision.confidence });
|
|
184
|
+
moved = true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// A request the host already took, or any provider failure, simply stays with the host.
|
|
187
|
+
this.log(`智能调度 #${request.message_id} 未改派,交回主持人:${error.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return moved;
|
|
191
|
+
}
|
|
143
192
|
/** Reserve with a persisted client id before invoking any software. */
|
|
144
193
|
async start(member, row) {
|
|
145
194
|
const record = this.save(member.name, { client_id: randomUUID(), execution_id: 0, status: 'reserving', error: '',
|
|
@@ -216,10 +265,10 @@ export class FreeRoomRunner {
|
|
|
216
265
|
if (row.error) return this.fail(member.name, execution.id, row.error);
|
|
217
266
|
if (this.exited.has(execution.id)) {
|
|
218
267
|
const latest = await api(this.config, `/executions/${execution.id}`);
|
|
219
|
-
if (['dispatched', 'started'].includes(latest.status)) return this.fail(member.name, execution.id, `${member.name}
|
|
268
|
+
if (['dispatched', 'started'].includes(latest.status)) return this.fail(member.name, execution.id, `${member.name} 会话已结束,但未提交本次回答;请核对原会话确认是否已有内容`);
|
|
220
269
|
return this.save(member.name, { status: 'idle' });
|
|
221
270
|
}
|
|
222
|
-
if (Date.now() >= execution.created_at * 1000 + minutes * 60_000) return this.fail(member.name, execution.id, `超过单次执行时限 ${minutes}
|
|
271
|
+
if (Date.now() >= execution.created_at * 1000 + minutes * 60_000) return this.fail(member.name, execution.id, `超过单次执行时限 ${minutes} 分钟;请核对原会话确认是否已有内容`);
|
|
223
272
|
if (Date.now() - row.last_renew >= RENEW_MS) {
|
|
224
273
|
await api(this.config, `/executions/${execution.id}`, { action: 'renew' });
|
|
225
274
|
this.save(member.name, { last_renew: Date.now() });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Optional smart scheduling: typed decisions from TypeSafe Jev or OpenAI Decisions, never generated text. */
|
|
2
|
+
import { api_status_message, read_saved_api_key } from './discussion_api.mjs';
|
|
3
|
+
|
|
4
|
+
/** Each provider keeps its official root; the key saved for that root is the only credential used. */
|
|
5
|
+
export const SCHEDULER_PROVIDERS = {
|
|
6
|
+
jev: { label: 'TypeSafe Jev', base_url: 'https://api.typesafe.ai/v1', path: '/systemone', models: ['jev-latest'] },
|
|
7
|
+
decisions: { label: 'OpenAI Decisions', base_url: 'https://api.openai.com/v1', path: '/decisions', models: ['gpt-6-luna'] },
|
|
8
|
+
};
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
10
|
+
/** Below this confidence a free-room message stays with the host, exactly as without scheduling. */
|
|
11
|
+
export const ROUTE_CONFIDENCE = 0.8;
|
|
12
|
+
const HOST_CHOICE = '__host__';
|
|
13
|
+
const NOTE_CHOICE = '__note__';
|
|
14
|
+
const CONTEXT_MESSAGES = 10;
|
|
15
|
+
const MAX_MESSAGE_CHARS = 600;
|
|
16
|
+
|
|
17
|
+
/** Normalize a launch's scheduler choice and prove its key exists for that provider's root. */
|
|
18
|
+
export function validate_scheduler(home, scheduler) {
|
|
19
|
+
const provider = SCHEDULER_PROVIDERS[scheduler?.provider];
|
|
20
|
+
if (!provider) throw new Error(`智能调度供应商只能为 ${Object.keys(SCHEDULER_PROVIDERS).join(' / ')}`);
|
|
21
|
+
if (!scheduler.model || typeof scheduler.model !== 'string') throw new Error('请填写智能调度模型');
|
|
22
|
+
const normalized = { provider: scheduler.provider, model: scheduler.model.trim(), base_url: provider.base_url, api_key_ref: scheduler.api_key_ref };
|
|
23
|
+
read_saved_api_key(home, normalized.base_url, normalized.api_key_ref);
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Convert neutral questions ({type: choice|predicate, name, instructions, choices}) to one provider's body. */
|
|
28
|
+
export function scheduler_request(scheduler, state, questions) {
|
|
29
|
+
const { path } = SCHEDULER_PROVIDERS[scheduler.provider];
|
|
30
|
+
if (scheduler.provider === 'jev') return { path, body: { model: scheduler.model, state, questions: Object.fromEntries(questions.map(question => [question.name,
|
|
31
|
+
question.type === 'choice'
|
|
32
|
+
? { type: 'choice', instructions: question.instructions, criteria: Object.fromEntries(question.choices.map(choice => [choice.value, choice.description])) }
|
|
33
|
+
: { type: 'noul', instructions: question.instructions }])) } };
|
|
34
|
+
return { path, body: { model: scheduler.model, input: state, questions: questions.map(question => question.type === 'choice'
|
|
35
|
+
? { type: 'choice', name: question.name, instructions: question.instructions, choices: question.choices.map(({ value, description }) => ({ value, description })) }
|
|
36
|
+
: { type: 'predicate', name: question.name, instructions: question.instructions }) } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read answers by question name: choices as {choice, confidence, probabilities}, predicates as {probability}. */
|
|
40
|
+
export function scheduler_answers(scheduler, data) {
|
|
41
|
+
if (scheduler.provider === 'jev') return Object.fromEntries(Object.entries(data?.answers || {}).map(([name, answer]) => [name,
|
|
42
|
+
answer.type === 'choice' ? { choice: answer.choice, confidence: answer.confidence, probabilities: answer.probabilities || {} } : { probability: answer.noul }]));
|
|
43
|
+
return Object.fromEntries((data?.answers || []).map(answer => [answer.name, answer.type === 'choice'
|
|
44
|
+
? { choice: answer.choice, confidence: answer.confidence, probabilities: Object.fromEntries((answer.probabilities || []).map(item => [item.value, item.probability])) }
|
|
45
|
+
: { probability: answer.probability }]));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ask the configured provider once; errors carry only the HTTP status, never response bodies or keys. */
|
|
49
|
+
export async function decide(home, scheduler, state, questions, fetch_impl = fetch) {
|
|
50
|
+
const key = read_saved_api_key(home, scheduler.base_url, scheduler.api_key_ref);
|
|
51
|
+
const request = scheduler_request(scheduler, state, questions);
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch_impl(`${scheduler.base_url}${request.path}`, { method: 'POST', redirect: 'error',
|
|
55
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
56
|
+
body: JSON.stringify(request.body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
57
|
+
} catch { throw new Error('智能调度请求超时或连接失败'); }
|
|
58
|
+
if (!response.ok) throw new Error(api_status_message(response.status));
|
|
59
|
+
return scheduler_answers(scheduler, await response.json());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Shorten a message so recent context stays small; the decision never needs whole answers. */
|
|
63
|
+
function excerpt(message) {
|
|
64
|
+
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
65
|
+
return `#${message.id} ${message.author}:${text.length > MAX_MESSAGE_CHARS ? `${text.slice(0, MAX_MESSAGE_CHARS)}…` : text}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** State shared by both uses: the room, a few earlier messages, and the message to place. */
|
|
69
|
+
export function scheduler_state(title, context, message) {
|
|
70
|
+
const earlier = context.filter(item => item.id < message.id).slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
71
|
+
return [`房间:${title || ''}`, earlier.length ? `近期消息(旧→新):\n${earlier.join('\n')}` : '近期没有其他消息。', `待处理的用户消息:\n${excerpt(message)}`].join('\n\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pick the member for one unnamed free-room message, or null to leave it with the host.
|
|
76
|
+
* Members are [{name, description}] without the host; only a confident member choice is returned.
|
|
77
|
+
*/
|
|
78
|
+
export async function route_free_message(home, scheduler, { title, context, message, host, members }, fetch_impl) {
|
|
79
|
+
const choices = [...members.map(member => ({ value: member.name, description: member.description })),
|
|
80
|
+
{ value: HOST_CHOICE, description: `交回主持人 ${host}:需要拆分任务、补充说明、多人协作,或无法确定由谁处理` }];
|
|
81
|
+
const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handler',
|
|
82
|
+
instructions: '这条用户消息没有点名成员。判断它能否直接交给一位成员独立处理;需要主持人先理解、拆分或协调时选交回主持人。', choices }], fetch_impl);
|
|
83
|
+
const answer = answers.handler || {};
|
|
84
|
+
const member = members.some(item => item.name === answer.choice) && answer.confidence >= ROUTE_CONFIDENCE ? answer.choice : null;
|
|
85
|
+
return { member, choice: answer.choice ?? null, confidence: answer.confidence ?? null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Suggest how the host might handle one interjection in a discussion; the host decides and may ignore it. */
|
|
89
|
+
export async function suggest_interjection(home, scheduler, { title, context, message, host, experts }, fetch_impl) {
|
|
90
|
+
const choices = [...experts.map(name => ({ value: name, description: `请 ${name} 回应这条留言` })),
|
|
91
|
+
{ value: HOST_CHOICE, description: `由主持人 ${host} 处理:调整目标、方向、安排,或需要主持人决定` },
|
|
92
|
+
{ value: NOTE_CHOICE, description: '仅补充信息:不需要专人回应,按原计划继续' }];
|
|
93
|
+
const answers = await decide(home, scheduler, scheduler_state(title, context, message), [{ type: 'choice', name: 'handling',
|
|
94
|
+
instructions: '讨论进行中用户发来这条留言。判断它最适合怎样处理。', choices }], fetch_impl);
|
|
95
|
+
const answer = answers.handling || {};
|
|
96
|
+
const label = answer.choice === HOST_CHOICE ? '主持人处理' : answer.choice === NOTE_CHOICE ? '仅补充信息,按原计划继续'
|
|
97
|
+
: experts.includes(answer.choice) ? `请 ${answer.choice} 回应` : '';
|
|
98
|
+
return label ? { message_id: message.id, suggestion: label, confidence: answer.confidence ?? null } : null;
|
|
99
|
+
}
|