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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.2.36",
3
+ "version": "0.2.37",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -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) throw new Error('模型未调用房间工具保存结果,请核对模型是否支持工具调用');
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: experts.filter(name => name !== room.host) }, this.fetch);
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
- return restored || skip('round', '本轮还有专家未发言但没有后续安排');
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
- const round = room.completed_rounds + 1;
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: order.map(expert => ({ expert, question: peer_round_question(round) })) };
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 === 'initial' ? '欢迎加入:请先读取议题,独立给出你的开场观点。' : '欢迎加入:请先读取议题和已有回答,给出你的观点。';
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('、')} 开场` : ''}`;
@@ -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}凭证文本在下方:原样设为环境变量 DISCUSS_ACCESS(不要回显,不要写进命令行参数),命令省略 --access-file;先执行 codiscuss room view ${room.id} --site ${location.origin} --as ${name},后续命令保持本站点、房间和身份。模型与思考强度在原软件中设置。${manual_trigger_instruction(room)}${workspace_instruction()}`;
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 })}` : "";