ai_co_discuss 0.2.36 → 0.2.37
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/docs/runner.md +441 -441
- package/docs/workflow.md +1 -1
- package/package.json +1 -1
- package/runner/api_participant.mjs +15 -1
- package/runner/discussion_runner.mjs +31 -8
- package/runner/scheduler.mjs +19 -0
- package/web/app.js +1 -1
package/docs/workflow.md
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
新建讨论时可选“点名追问”,默认关闭;之前创建的房间视为开启,保持原有方式。
|
|
44
44
|
|
|
45
45
|
- **开启**:每轮结束后主持人读完回答,给每位专家写针对性的追问。
|
|
46
|
-
- **关闭**:首轮仍由主持人拆题出题;之后专家统一按“先读上一轮回答、回应不同意或需补充的观点、可 @
|
|
46
|
+
- **关闭**:首轮仍由主持人拆题出题;之后专家统一按“先读上一轮回答、回应不同意或需补充的观点、可 @ 彼此提问”发言,主持人用自己的主持说明引导方向。同时开启“智能调度”时,每轮结束由决策模型判断:还有分歧就自动开下一轮(先请最需要回应的专家,不是上一轮最后发言者),可以收束、跑偏或卡住才唤醒主持人。决策模型每次只安排一位发言人,那位答完后再判断下一位(含点“跳过”、邀请或成员变动后队列为空的情况);手动接入的专家不由它安排。自动开轮受轮次与时长上限约束,到限照常交给主持人处理。
|
|
47
47
|
|
|
48
48
|
## 讨论上限
|
|
49
49
|
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ const MAX_MODEL_TURNS = 24;
|
|
|
11
11
|
// API participants follow the same skills that local software loads, read fresh for every execution.
|
|
12
12
|
const SKILLS = fileURLToPath(new URL('../.agents/skills/', import.meta.url));
|
|
13
13
|
const SITE_TIMEOUT_MS = 10_000;
|
|
14
|
+
const REPLY_EXCERPT_CHARS = 300;
|
|
14
15
|
const MODEL_RETRIES = 2;
|
|
15
16
|
const MODEL_RETRY_DELAY_MS = 3000;
|
|
16
17
|
const NO_TOOL_REMINDER = '普通文本不会被保存。请调用写入工具提交真实结果;确实无法继续才不调用工具直接结束。';
|
|
@@ -154,6 +155,13 @@ export function system_prompt(context) {
|
|
|
154
155
|
return [environment, skill_text('discuss-participant'), ...(host && context.mode !== 'free' ? [skill_text('discuss-host')] : [])].join('\n\n---\n\n');
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/** Plain text of the model's latest reply, so a stop without a write can show its stated reason. */
|
|
159
|
+
function last_reply_text(history) {
|
|
160
|
+
const item = history.at(-1) || {};
|
|
161
|
+
const parts = item.parts || (Array.isArray(item.content) ? item.content : [{ text: item.content }]);
|
|
162
|
+
return parts.map(part => (typeof part === 'string' ? part : part.text || '')).join('').trim().slice(0, REPLY_EXCERPT_CHARS);
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
/** Retry a model request that failed before any room write; history is only extended on success. */
|
|
158
166
|
async function generate_with_retry(profile, key, state, signal) {
|
|
159
167
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -174,15 +182,21 @@ async function run_participant(profile, key, context) {
|
|
|
174
182
|
...(context.scheduler_suggestions?.length ? { scheduler_suggestions: context.scheduler_suggestions } : {}) } });
|
|
175
183
|
const state = { system: system_prompt(context), history: api_history(profile, prompt), tools: definitions };
|
|
176
184
|
let reminded = false;
|
|
185
|
+
let used_tools = false;
|
|
177
186
|
for (let index = 0; index < MAX_MODEL_TURNS; index++) {
|
|
178
187
|
const calls = await generate_with_retry(profile, key, state, context.signal);
|
|
179
188
|
if (!calls.length) {
|
|
180
189
|
// Some models answer in plain text mid-run; one reminder avoids pausing on a recoverable slip.
|
|
181
|
-
if (reminded)
|
|
190
|
+
if (reminded) {
|
|
191
|
+
const reply = last_reply_text(state.history);
|
|
192
|
+
throw new Error(used_tools ? `模型未提交结果就结束了本次执行,可能在等你决定或补充${reply ? `。模型说明:${reply}` : ''}`
|
|
193
|
+
: `模型未调用房间工具保存结果,请核对模型是否支持工具调用${reply ? `(其回复:${reply})` : ''}`);
|
|
194
|
+
}
|
|
182
195
|
reminded = true;
|
|
183
196
|
state.history.push(profile.api_protocol === 'gemini' ? { role: 'user', parts: [{ text: NO_TOOL_REMINDER }] } : { role: 'user', content: NO_TOOL_REMINDER });
|
|
184
197
|
continue;
|
|
185
198
|
}
|
|
199
|
+
used_tools = true;
|
|
186
200
|
const results = [];
|
|
187
201
|
for (const call of calls) {
|
|
188
202
|
context.signal.throwIfAborted();
|
|
@@ -9,7 +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 { decide_interjection, decide_round, validate_scheduler } from './scheduler.mjs';
|
|
12
|
+
import { decide_interjection, decide_next_speaker, decide_round, validate_scheduler } from './scheduler.mjs';
|
|
13
13
|
|
|
14
14
|
const POLL_MS = 2000;
|
|
15
15
|
/** Headless CLIs know their session at start, so the runner enters the execution instead of spending model turns on it. */
|
|
@@ -41,6 +41,11 @@ export function peer_round_question(round) {
|
|
|
41
41
|
return `第 ${round} 轮:先读取上一轮其他专家的最新回答,回应你不同意或需要补充的观点;被其他专家 @ 提问时优先回答,也可以 @ 某位专家提出问题。`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** The prompt for an expert speaking for the first time, such as a newly invited one. */
|
|
45
|
+
function opening_question(phase) {
|
|
46
|
+
return phase === 'initial' ? '欢迎加入:请先读取议题,独立给出你的开场观点。' : '欢迎加入:请先读取议题和已有回答,给出你的观点。';
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
/** Ask one expert to answer a user's interjection before the rest of the plan continues. */
|
|
45
50
|
function interjection_question(message) {
|
|
46
51
|
const text = String(message.text || '').replace(/\s+/g, ' ').trim();
|
|
@@ -356,6 +361,8 @@ export class DiscussionRunner {
|
|
|
356
361
|
if (room.limits?.reached) return skip('round', '已到讨论上限');
|
|
357
362
|
const page = await api(this.config, `/messages?after=${Math.max(0, room.latest_id - CONTEXT_MESSAGES)}`);
|
|
358
363
|
const experts = Object.keys(room.experts || {});
|
|
364
|
+
// Only experts the runner can wake are offered to the decision model; manual ones wait for the host.
|
|
365
|
+
const automatic = experts.filter(name => this.config.participants[name]);
|
|
359
366
|
const context = { title: room.title, goal: room.goal, user_request: room.user_request, context: page.messages, host: room.host };
|
|
360
367
|
if (room.held_since) {
|
|
361
368
|
const pending = page.messages.filter(item => item.id >= room.held_since && item.author === '用户');
|
|
@@ -363,7 +370,7 @@ export class DiscussionRunner {
|
|
|
363
370
|
if (pending.length !== 1) return skip('interjection', pending.length ? '同时有多条留言' : '话题设置有修改');
|
|
364
371
|
const message = pending[0];
|
|
365
372
|
const decision = await decide_interjection(this.config.api_home, this.config.scheduler,
|
|
366
|
-
{ ...context, message, experts:
|
|
373
|
+
{ ...context, message, experts: automatic.filter(name => name !== room.host) }, this.fetch);
|
|
367
374
|
const plan = { ...decision, kind: 'interjection', message_id: message.id, after_message_id: anchor };
|
|
368
375
|
if (decision.action === 'ask') {
|
|
369
376
|
return { ...plan, turns: [{ expert: decision.expert, question: interjection_question(message) },
|
|
@@ -377,17 +384,33 @@ export class DiscussionRunner {
|
|
|
377
384
|
const round_open = !room.rounds?.at(-1)?.completed;
|
|
378
385
|
if (round_open) {
|
|
379
386
|
const restored = await this.restore_voided_plan(room, experts, anchor);
|
|
380
|
-
|
|
387
|
+
if (restored) return restored;
|
|
388
|
+
const spoken = new Set(room.rounds?.at(-1)?.speakers || []);
|
|
389
|
+
// Experts without local software cannot be woken by the runner, so only automatic ones are candidates.
|
|
390
|
+
const waiting = experts.filter(name => name !== room.host && !spoken.has(name) && this.config.participants[name]);
|
|
391
|
+
if (!waiting.length) return skip('round', '本轮未发言的专家都是手动接入,无法自动安排');
|
|
392
|
+
// An expert the user just skipped is not picked again unless nobody else is left.
|
|
393
|
+
const skipped = room.current_turn?.status === 'cancelled' ? room.current_turn.expert : null;
|
|
394
|
+
const candidates = waiting.filter(name => name !== skipped);
|
|
395
|
+
return this.next_speaker_plan(room, context, candidates.length ? candidates : waiting, anchor);
|
|
381
396
|
}
|
|
382
397
|
if (room.named_followup) return skip('round', '点名追问已开启,由主持人安排下一轮');
|
|
383
398
|
const decision = await decide_round(this.config.api_home, this.config.scheduler,
|
|
384
|
-
{ ...context, experts, last_speaker: room.current_turn?.expert }, this.fetch);
|
|
399
|
+
{ ...context, experts: automatic, last_speaker: room.current_turn?.expert }, this.fetch);
|
|
385
400
|
const plan = { ...decision, kind: 'round', after_message_id: anchor };
|
|
386
401
|
if (decision.action !== 'continue') return plan;
|
|
387
|
-
|
|
388
|
-
const order = [decision.first, ...experts.filter(name => name !== decision.first)].slice(0, MAX_PLAN_TURNS);
|
|
402
|
+
// Only the first speaker is arranged; each answer ends with an empty queue, so the next one is judged then.
|
|
389
403
|
return { ...plan, phase: room.phase === 'initial' ? 'discussing' : undefined,
|
|
390
|
-
turns:
|
|
404
|
+
turns: [{ expert: decision.first, question: peer_round_question(room.completed_rounds + 1) }] };
|
|
405
|
+
}
|
|
406
|
+
/** Ask the decision model for the next speaker among candidates; an expert who never spoke gets the opening prompt. */
|
|
407
|
+
async next_speaker_plan(room, context, candidates, anchor) {
|
|
408
|
+
const next = await decide_next_speaker(this.config.api_home, this.config.scheduler, { ...context, candidates }, this.fetch);
|
|
409
|
+
const plan = { ...next, kind: 'round', after_message_id: anchor };
|
|
410
|
+
if (next.action !== 'next') return plan;
|
|
411
|
+
const has_spoken = (room.rounds || []).some(round => round.speakers.includes(next.expert));
|
|
412
|
+
const question = has_spoken ? peer_round_question(room.completed_rounds + 1) : opening_question(room.phase);
|
|
413
|
+
return { ...plan, turns: [{ expert: next.expert, question }] };
|
|
391
414
|
}
|
|
392
415
|
/**
|
|
393
416
|
* A roster change voids queued turns, leaving them skipped after the last real arrangement. Put back the ones whose
|
|
@@ -402,7 +425,7 @@ export class DiscussionRunner {
|
|
|
402
425
|
const spoken = new Set(room.rounds?.at(-1)?.speakers || []);
|
|
403
426
|
const planned = new Set(voided.map(turn => turn.expert));
|
|
404
427
|
const joining = experts.filter(name => !spoken.has(name) && !planned.has(name));
|
|
405
|
-
const opening = room.phase
|
|
428
|
+
const opening = opening_question(room.phase);
|
|
406
429
|
const plan_turns = [...voided.map(({ expert, question }) => ({ expert, question })), ...joining.map(expert => ({ expert, question: opening }))].slice(0, MAX_PLAN_TURNS);
|
|
407
430
|
if (!plan_turns.length) return null;
|
|
408
431
|
const label = `成员有变化:${voided.length ? `补回原计划(${voided.map(turn => turn.expert).join('、')})` : '原计划成员已离开'}${joining.length ? `,请 ${joining.join('、')} 开场` : ''}`;
|
package/runner/scheduler.mjs
CHANGED
|
@@ -126,6 +126,25 @@ export async function decide_interjection(home, scheduler, { title, goal, user_r
|
|
|
126
126
|
return { action: 'host', confidence, label: choice === HOST_CHOICE && sure ? '这条留言需要主持人处理' : '拿不准这条留言如何处理,交给主持人' };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Mid-round with no queued turn left: pick the next speaker among the experts who can be woken and have not spoken
|
|
131
|
+
* this round, or hand back to the host when unsure or when the discussion needs steering.
|
|
132
|
+
*/
|
|
133
|
+
export async function decide_next_speaker(home, scheduler, { title, goal, user_request, context, host, candidates }, fetch_impl) {
|
|
134
|
+
const recent = context.slice(-CONTEXT_MESSAGES).map(excerpt);
|
|
135
|
+
const state = [topic_lines(title, goal, user_request), recent.length ? `本轮及最近发言(旧→新):\n${recent.join('\n')}` : '还没有发言。'].join('\n\n');
|
|
136
|
+
const choices = [...candidates.map(name => ({ value: name, description: `请 ${name} 接着发言` })),
|
|
137
|
+
{ value: HOST_CHOICE, description: `交给主持人 ${host}:讨论偏离目标、卡住,或需要主持人调整安排` }];
|
|
138
|
+
const answers = await decide(home, scheduler, state, [{ type: 'choice', name: 'speaker',
|
|
139
|
+
instructions: '本轮还有专家没发言,安排被取消或用完。结合已有发言判断下一位应请谁发言:优先选被点名追问、最能回应当前焦点的人。', choices }], fetch_impl);
|
|
140
|
+
const choice = answers.speaker?.choice;
|
|
141
|
+
const confidence = certainty(answers.speaker);
|
|
142
|
+
if (candidates.includes(choice) && confidence !== null && confidence >= ROUTE_CONFIDENCE) {
|
|
143
|
+
return { action: 'next', expert: choice, confidence, label: `本轮继续,请 ${choice} 接着发言` };
|
|
144
|
+
}
|
|
145
|
+
return { action: 'host', confidence, label: '拿不准下一位该请谁发言,交给主持人' };
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
/**
|
|
130
149
|
* At the end of a round without named follow-ups: continue with another peer round, or wake the host
|
|
131
150
|
* (to conclude, or because the discussion drifted, repeats itself or is stuck). The first speaker is never the last one.
|
package/web/app.js
CHANGED
|
@@ -2236,7 +2236,7 @@ async function copy_member_join(name, with_credential = false) {
|
|
|
2236
2236
|
let instruction;
|
|
2237
2237
|
if (member.access === "online") instruction = "你是在线只读成员,请在原聊天回答,由用户或授权主持人代发;不要尝试直接写入房间。模型与思考强度在原网页设置。";
|
|
2238
2238
|
else if (member.access === "runner") instruction = `${skills}等待本机程序交付有效任务,按 task 中的 access_file 和 execution 凭据操作${room.mode === "free" ? ";自由协作的回答必须绑定本次有效 execution_id" : ""}。不要另建定时任务;只处理本次有效执行,失效或撤销后停止提交。下载的持久成员私有文件不能替代自动执行凭据。`;
|
|
2239
|
-
else if (with_credential) instruction = `${skills}
|
|
2239
|
+
else if (with_credential) instruction = `${skills}凭证文本在下方:每条 codiscuss 命令都在同一命令内把它原样设为环境变量 DISCUSS_ACCESS(不回显;首条 room view 也要带,否则无法识别你),命令省略 --access-file;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name},后续命令保持本站点、房间和身份。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
|
|
2240
2240
|
else instruction = `${skills}使用属于你的私有文件 member-${room.id}-${name}.json,由用户提供它在本机的实际完整路径;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name} --access-file "实际完整路径",后续命令保持本站点、房间和身份。不要读取或公开文件中的 token;若用户给的是凭证文本而非文件,把它设为环境变量 DISCUSS_ACCESS(不要回显)并省略 --access-file。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
|
|
2241
2241
|
const credential = with_credential ? `
|
|
2242
2242
|
凭证文本(含 token,仅限你使用):${JSON.stringify({ site: location.origin, room: room.id, token: member_credentials(room.id)[name].token })}` : "";
|