chatccc 0.2.286 → 0.2.288

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.
@@ -459,6 +459,10 @@ async function runRepl(args) {
459
459
  }
460
460
  let currentAbort = null;
461
461
  const ctrlCState = createCtrlCState();
462
+ // 协作式让位:agent 运行期间用户仍可输入,新行进入注入队列,由内核在每个
463
+ // model step 边界 drain 后吸收到当前 turn(而非等待整轮结束)。
464
+ let chatRunning = false;
465
+ const pendingInjections = [];
462
466
  rl.prompt();
463
467
  rl.on("line", async (line) => {
464
468
  ctrlCState.reset();
@@ -467,6 +471,17 @@ async function runRepl(args) {
467
471
  rl.prompt();
468
472
  return;
469
473
  }
474
+ if (chatRunning) {
475
+ // 运行期间不并发启动新 turn:把输入注入当前 turn 的 step 边界。
476
+ if (input === "exit" || input.startsWith("/")) {
477
+ process.stdout.write(`${C.dim}[running] 命令将在本轮结束后生效;或按两次 Ctrl+C 中断${C.reset}\n`);
478
+ return;
479
+ }
480
+ pendingInjections.push(buildAttachmentPrompt(input, pendingAttachments));
481
+ pendingAttachments = [];
482
+ process.stdout.write(`${C.dim}[queued] 消息将在当前步骤后注入本轮${C.reset}\n`);
483
+ return;
484
+ }
470
485
  if (input === "exit") {
471
486
  process.stdout.write(`${C.dim}bye${C.reset}\n`);
472
487
  rl.close();
@@ -510,10 +525,11 @@ async function runRepl(args) {
510
525
  const chatInput = buildAttachmentPrompt(input, pendingAttachments);
511
526
  pendingAttachments = [];
512
527
  let lastAccumulated = "";
513
- for await (const event of session.chat(chatInput, signal)) {
528
+ chatRunning = true;
529
+ for await (const event of session.chat(chatInput, signal, () => pendingInjections.shift())) {
514
530
  if (renderer && view) {
515
531
  view = reduceProgress(view, event);
516
- if (event.type === "text" || event.type === "compact" || event.type === "status") {
532
+ if (event.type === "text" || event.type === "compact" || event.type === "status" || event.type === "input_injected") {
517
533
  renderer.render(view);
518
534
  }
519
535
  else {
@@ -543,6 +559,9 @@ async function runRepl(args) {
543
559
  const status = event.is_error ? "error" : "ok";
544
560
  console.log(`${C.dim}[tool result] ${event.name ?? event.tool_use_id} ${status}${C.reset}`);
545
561
  }
562
+ else if (event.type === "input_injected") {
563
+ console.log(`\n${C.dim}[injected] ${event.text}${C.reset}`);
564
+ }
546
565
  else if (event.type === "error") {
547
566
  console.log(`\n${C.yellow}[error] ${event.message}${C.reset}`);
548
567
  }
@@ -561,6 +580,7 @@ async function runRepl(args) {
561
580
  finally {
562
581
  activeRenderer = null;
563
582
  activeView = null;
583
+ chatRunning = false;
564
584
  if (renderer && view && !rendererEnded) {
565
585
  // 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
566
586
  renderer.end(view);
@@ -246,6 +246,17 @@ function normalizeAnthropicBaseURL(baseURL) {
246
246
  // DeepSeek Anthropic 端点示例:https://api.deepseek.com/anthropic/v1。
247
247
  return baseURL.trim().replace(/\/+$/, "");
248
248
  }
249
+ /**
250
+ * 协作式让位的内部中断信号:在 prepareStep(每个模型 step 前)检测到外部
251
+ * 注入消息时抛出,触发 chat() 持久化当前中间态 → 注入 user 消息 → 以新
252
+ * messages 重启 streamText,让 agent 在当前 turn 内吸收新指令而非结束整轮。
253
+ */
254
+ class InputInjectionInterrupt extends Error {
255
+ constructor() {
256
+ super("input injection requested at step boundary");
257
+ this.name = "InputInjectionInterrupt";
258
+ }
259
+ }
249
260
  export class ChatSession {
250
261
  model;
251
262
  /** 子模型实例;未配置 subModel 时与主模型同一实例 */
@@ -396,7 +407,7 @@ export class ChatSession {
396
407
  }
397
408
  return systemContent.join("\n");
398
409
  }
399
- async *chat(userMessage, signal) {
410
+ async *chat(userMessage, signal, drainInput) {
400
411
  this.context.appendMessage({ role: "user", content: userMessage });
401
412
  let fullText = "";
402
413
  let safeAccumulated = "";
@@ -469,6 +480,13 @@ export class ChatSession {
469
480
  ? { [OPENAI_COMPATIBLE_PROVIDER_NAME]: { reasoningEffort: this.effort } }
470
481
  : { anthropic: { effort: this.effort } };
471
482
  }
483
+ // 协作式让位(仅 streaming 模式):外部可在每个 model step 边界 drain 一条
484
+ // 新消息注入当前 turn。non-streaming 下 generateText 是原子请求,无法在 step
485
+ // 边界取回中间工具结果,因此忽略 drainInput(消息留在上层队列,整轮结束后消费)。
486
+ const canInject = this.streaming && typeof drainInput === "function";
487
+ // pendingInjection 是 prepareStep 与 catch 之间的注入信号槽。用标志而非
488
+ // instanceof 判断,因为 provider SDK 可能包装抛出的错误。
489
+ let pendingInjection = null;
472
490
  const baseGenerationOptions = {
473
491
  model: this.model,
474
492
  system,
@@ -483,6 +501,13 @@ export class ChatSession {
483
501
  stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
484
502
  abortSignal: signal,
485
503
  prepareStep: ({ messages, stepNumber }) => {
504
+ if (canInject) {
505
+ const injected = drainInput();
506
+ if (injected) {
507
+ pendingInjection = injected;
508
+ throw new InputInjectionInterrupt();
509
+ }
510
+ }
486
511
  const compacted = compactToolLoopMessages(messages);
487
512
  if (compacted.compactedResults > 0) {
488
513
  rawLog?.writeLine(safeRawStreamJson({
@@ -500,151 +525,198 @@ export class ChatSession {
500
525
  : {}),
501
526
  ...(effortProviderOptions ? { providerOptions: effortProviderOptions } : {}),
502
527
  };
503
- for (let attempt = 0; attempt < 2; attempt += 1) {
504
- fullText = "";
505
- safeAccumulated = "";
506
- toolContext = [];
507
- toolCallsById.clear();
508
- toolCallOrder.length = 0;
509
- timeline.length = 0;
510
- let lastReasoningProgressAt;
511
- const attemptMessages = attempt === 0
512
- ? modelMessages
513
- : [...modelMessages, { role: "user", content: TOOL_PROTOCOL_RECOVERY_PROMPT }];
514
- const generationOptions = {
515
- ...baseGenerationOptions,
516
- messages: attemptMessages,
517
- };
518
- let stream;
519
- let requiresFinish = false;
520
- let receivedFinish = false;
521
- let finishReason;
522
- if (this.streaming) {
523
- const result = streamText(generationOptions);
524
- requiresFinish = result.fullStream != null;
525
- stream = result.fullStream ?? textStreamToFullStream(result.textStream);
526
- }
527
- else {
528
- const result = await generateText(generationOptions);
529
- finishReason = result.finishReason;
530
- stream = generateResultToFullStream(result);
531
- }
532
- for await (const part of stream) {
533
- rawLog?.writeLine(safeRawStreamJson(part));
534
- if (part.type === "finish") {
535
- receivedFinish = true;
536
- finishReason = part.finishReason;
537
- }
538
- if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
539
- // Reasoning content remains private. A throttled heartbeat is enough
540
- // for ChatCCC to distinguish active inference from a stalled stream.
541
- const now = Date.now();
542
- if (lastReasoningProgressAt === undefined || now - lastReasoningProgressAt >= 1_000) {
543
- lastReasoningProgressAt = now;
544
- yield { type: "progress", phase: "reasoning" };
545
- }
546
- }
547
- else if (part.type === "text-delta") {
548
- fullText += part.text;
549
- const previous = timeline[timeline.length - 1];
550
- if (previous?.type === "text")
551
- previous.text += part.text;
552
- else
553
- timeline.push({ type: "text", text: part.text });
554
- // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
555
- // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
556
- const safeText = applyPrivacy(part.text);
557
- safeAccumulated += safeText;
558
- yield { type: "text", text: safeText, accumulated: safeAccumulated };
559
- }
560
- else if (part.type === "tool-call") {
561
- const input = safeJson(part.input);
562
- toolContext.push(`tool_call ${part.toolName}: ${input}`);
563
- toolCallsById.set(part.toolCallId, { id: part.toolCallId, name: part.toolName, input });
564
- toolCallOrder.push(part.toolCallId);
565
- timeline.push({ type: "tool_use", id: part.toolCallId, name: part.toolName, input });
566
- yield {
567
- type: "tool_use",
568
- id: part.toolCallId,
569
- name: part.toolName,
570
- input: applyPrivacyToJson(part.input),
571
- };
572
- }
573
- else if (part.type === "tool-result") {
574
- const output = truncateToolContext(safeJson(part.output));
575
- toolContext.push(`tool_result ${part.toolName}: ${output}`);
576
- const call = toolCallsById.get(part.toolCallId);
577
- if (call)
578
- call.output = output;
579
- timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output });
580
- yield {
581
- type: "tool_result",
582
- tool_use_id: part.toolCallId,
583
- name: part.toolName,
584
- content: applyPrivacyToJson(part.output),
585
- is_error: false,
528
+ // 注入重启后 context 已变(中间 assistant + 注入 user),需要重新构建并应用
529
+ // 与首次一致的 recovery-hint / anthropic 兼容提示。
530
+ const decorateMessages = (msgs) => {
531
+ const hinted = maybeAppendCompactionRecoveryHint(msgs, this.context.summary, appConfig.rawStreamLogs.enabled, this.context.sessionId);
532
+ return this.provider === "anthropic"
533
+ ? addAnthropicToolJsonCompatibilityNote(hinted)
534
+ : hinted;
535
+ };
536
+ let currentMessages = modelMessages;
537
+ while (true) {
538
+ pendingInjection = null;
539
+ try {
540
+ for (let attempt = 0; attempt < 2; attempt += 1) {
541
+ fullText = "";
542
+ toolContext = [];
543
+ toolCallsById.clear();
544
+ toolCallOrder.length = 0;
545
+ timeline.length = 0;
546
+ // safeAccumulated 是整轮展示累积:注入延续时不重置,只有工具协议恢复
547
+ // (重新生成、已 yield text_reset)才重置。
548
+ if (attempt === 1)
549
+ safeAccumulated = "";
550
+ let lastReasoningProgressAt;
551
+ const attemptMessages = attempt === 0
552
+ ? currentMessages
553
+ : [...currentMessages, { role: "user", content: TOOL_PROTOCOL_RECOVERY_PROMPT }];
554
+ const generationOptions = {
555
+ ...baseGenerationOptions,
556
+ messages: attemptMessages,
586
557
  };
587
- }
588
- else if (part.type === "tool-error") {
589
- const message = errorMessage(part.error);
590
- toolContext.push(`tool_error ${part.toolName}: ${message}`);
591
- const call = toolCallsById.get(part.toolCallId);
592
- if (call) {
593
- call.output = message;
594
- call.is_error = true;
558
+ let stream;
559
+ let requiresFinish = false;
560
+ let receivedFinish = false;
561
+ let finishReason;
562
+ if (this.streaming) {
563
+ const result = streamText(generationOptions);
564
+ requiresFinish = result.fullStream != null;
565
+ stream = result.fullStream ?? textStreamToFullStream(result.textStream);
595
566
  }
596
- timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output: message, is_error: true });
597
- yield {
598
- type: "tool_result",
599
- tool_use_id: part.toolCallId,
600
- name: part.toolName,
601
- content: applyPrivacy(message),
602
- is_error: true,
603
- };
604
- }
605
- else if (part.type === "error") {
606
- const message = errorMessage(part.error);
607
- yield { type: "error", message: applyPrivacy(message) };
608
- throw new Error(message);
567
+ else {
568
+ const result = await generateText(generationOptions);
569
+ finishReason = result.finishReason;
570
+ stream = generateResultToFullStream(result);
571
+ }
572
+ for await (const part of stream) {
573
+ rawLog?.writeLine(safeRawStreamJson(part));
574
+ if (part.type === "finish") {
575
+ receivedFinish = true;
576
+ finishReason = part.finishReason;
577
+ }
578
+ if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
579
+ // Reasoning content remains private. A throttled heartbeat is enough
580
+ // for ChatCCC to distinguish active inference from a stalled stream.
581
+ const now = Date.now();
582
+ if (lastReasoningProgressAt === undefined || now - lastReasoningProgressAt >= 1_000) {
583
+ lastReasoningProgressAt = now;
584
+ yield { type: "progress", phase: "reasoning" };
585
+ }
586
+ }
587
+ else if (part.type === "text-delta") {
588
+ fullText += part.text;
589
+ const previous = timeline[timeline.length - 1];
590
+ if (previous?.type === "text")
591
+ previous.text += part.text;
592
+ else
593
+ timeline.push({ type: "text", text: part.text });
594
+ // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
595
+ // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
596
+ const safeText = applyPrivacy(part.text);
597
+ safeAccumulated += safeText;
598
+ yield { type: "text", text: safeText, accumulated: safeAccumulated };
599
+ }
600
+ else if (part.type === "tool-call") {
601
+ const input = safeJson(part.input);
602
+ toolContext.push(`tool_call ${part.toolName}: ${input}`);
603
+ toolCallsById.set(part.toolCallId, { id: part.toolCallId, name: part.toolName, input });
604
+ toolCallOrder.push(part.toolCallId);
605
+ timeline.push({ type: "tool_use", id: part.toolCallId, name: part.toolName, input });
606
+ yield {
607
+ type: "tool_use",
608
+ id: part.toolCallId,
609
+ name: part.toolName,
610
+ input: applyPrivacyToJson(part.input),
611
+ };
612
+ }
613
+ else if (part.type === "tool-result") {
614
+ const output = truncateToolContext(safeJson(part.output));
615
+ toolContext.push(`tool_result ${part.toolName}: ${output}`);
616
+ const call = toolCallsById.get(part.toolCallId);
617
+ if (call)
618
+ call.output = output;
619
+ timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output });
620
+ yield {
621
+ type: "tool_result",
622
+ tool_use_id: part.toolCallId,
623
+ name: part.toolName,
624
+ content: applyPrivacyToJson(part.output),
625
+ is_error: false,
626
+ };
627
+ }
628
+ else if (part.type === "tool-error") {
629
+ const message = errorMessage(part.error);
630
+ toolContext.push(`tool_error ${part.toolName}: ${message}`);
631
+ const call = toolCallsById.get(part.toolCallId);
632
+ if (call) {
633
+ call.output = message;
634
+ call.is_error = true;
635
+ }
636
+ timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output: message, is_error: true });
637
+ yield {
638
+ type: "tool_result",
639
+ tool_use_id: part.toolCallId,
640
+ name: part.toolName,
641
+ content: applyPrivacy(message),
642
+ is_error: true,
643
+ };
644
+ }
645
+ else if (part.type === "error") {
646
+ if (pendingInjection !== null) {
647
+ // prepareStep 里的注入中断被 provider 转成了 error part。
648
+ throw new InputInjectionInterrupt();
649
+ }
650
+ const message = errorMessage(part.error);
651
+ yield { type: "error", message: applyPrivacy(message) };
652
+ throw new Error(message);
653
+ }
654
+ }
655
+ if (!signal?.aborted) {
656
+ if (requiresFinish && !receivedFinish)
657
+ throw new Error("DeepCCC 输出流中断:未收到模型完成事件,回复可能不完整");
658
+ if (finishReason === "error" || finishReason === "length")
659
+ throw new Error(`DeepCCC 未正常完成:finishReason=${finishReason},回复可能不完整`);
660
+ if (!fullText.trim() && toolCallOrder.length === 0)
661
+ throw new Error("DeepCCC 本轮未产生有效回复");
662
+ }
663
+ if (hasMalformedToolProtocolText(fullText)) {
664
+ console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
665
+ + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
666
+ rawLog?.writeLine(safeRawStreamJson({
667
+ type: "deepccc_tool_protocol_recovery",
668
+ attempt: attempt + 1,
669
+ structuredToolCalls: toolCallOrder.length,
670
+ }));
671
+ yield { type: "text_reset" };
672
+ if (attempt === 0 && toolCallOrder.length === 0) {
673
+ yield { type: "status", phase: "generating" };
674
+ continue;
675
+ }
676
+ throw new Error(toolCallOrder.length > 0
677
+ ? "工具调用协议异常:检测到混合的结构化调用与伪造工具文本,为避免重复执行工具,本轮已安全终止"
678
+ : "工具调用协议异常:模型重试后仍输出了无效或伪造的工具调用文本");
679
+ }
680
+ completed = true;
681
+ const collectedToolCalls = toolCallOrder
682
+ .map((id) => toolCallsById.get(id))
683
+ .filter((call) => call !== undefined);
684
+ this.context.appendMessage(buildPersistedAssistantMessage({
685
+ fullText,
686
+ transcriptLines: toolContext,
687
+ toolCalls: collectedToolCalls,
688
+ timeline,
689
+ }));
690
+ yield { type: "done", text: safeAccumulated };
691
+ return;
609
692
  }
610
693
  }
611
- if (!signal?.aborted) {
612
- if (requiresFinish && !receivedFinish)
613
- throw new Error("DeepCCC 输出流中断:未收到模型完成事件,回复可能不完整");
614
- if (finishReason === "error" || finishReason === "length")
615
- throw new Error(`DeepCCC 未正常完成:finishReason=${finishReason},回复可能不完整`);
616
- if (!fullText.trim() && toolCallOrder.length === 0)
617
- throw new Error("DeepCCC 本轮未产生有效回复");
618
- }
619
- if (hasMalformedToolProtocolText(fullText)) {
620
- console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
621
- + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
622
- rawLog?.writeLine(safeRawStreamJson({
623
- type: "deepccc_tool_protocol_recovery",
624
- attempt: attempt + 1,
625
- structuredToolCalls: toolCallOrder.length,
626
- }));
627
- yield { type: "text_reset" };
628
- if (attempt === 0 && toolCallOrder.length === 0) {
629
- yield { type: "status", phase: "generating" };
694
+ catch (err) {
695
+ if (pendingInjection !== null) {
696
+ // 协作式让位:持久化本段中间态 注入 user → 重建 messages → 继续当前 turn。
697
+ if (fullText.trim() || toolCallOrder.length > 0) {
698
+ const collectedToolCalls = toolCallOrder
699
+ .map((id) => toolCallsById.get(id))
700
+ .filter((call) => call !== undefined);
701
+ this.context.appendMessage(buildPersistedAssistantMessage({
702
+ fullText,
703
+ transcriptLines: toolContext,
704
+ toolCalls: collectedToolCalls,
705
+ timeline: timeline.map((entry) => ({ ...entry })),
706
+ }));
707
+ }
708
+ const injectedText = pendingInjection;
709
+ this.context.appendMessage({ role: "user", content: injectedText });
710
+ rawLog?.writeLine(safeRawStreamJson({
711
+ type: "deepccc_input_injected",
712
+ text: injectedText,
713
+ }));
714
+ yield { type: "input_injected", text: injectedText };
715
+ currentMessages = decorateMessages(this.context.buildModelMessages());
630
716
  continue;
631
717
  }
632
- throw new Error(toolCallOrder.length > 0
633
- ? "工具调用协议异常:检测到混合的结构化调用与伪造工具文本,为避免重复执行工具,本轮已安全终止"
634
- : "工具调用协议异常:模型重试后仍输出了无效或伪造的工具调用文本");
718
+ throw err;
635
719
  }
636
- completed = true;
637
- const collectedToolCalls = toolCallOrder
638
- .map((id) => toolCallsById.get(id))
639
- .filter((call) => call !== undefined);
640
- this.context.appendMessage(buildPersistedAssistantMessage({
641
- fullText,
642
- transcriptLines: toolContext,
643
- toolCalls: collectedToolCalls,
644
- timeline,
645
- }));
646
- yield { type: "done", text: safeAccumulated };
647
- return;
648
720
  }
649
721
  }
650
722
  catch (err) {
@@ -102,5 +102,11 @@ export function reduceProgress(prev, event) {
102
102
  case "compact":
103
103
  // 旧上下文压缩不影响当前过程展示
104
104
  return prev;
105
+ case "input_injected": {
106
+ // 协作式让位:在当前 turn 的 step 边界注入了新消息,仅在头部提示,
107
+ // 不改动正文与工具状态。
108
+ const preview = event.text.length > 40 ? `${event.text.slice(0, 40)}…` : event.text;
109
+ return withProgressView(prev, { headerTitle: `已注入新消息:${preview}` });
110
+ }
105
111
  }
106
112
  }
@@ -52,7 +52,7 @@ export function createCccAdapter(options = {}) {
52
52
  const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
53
53
  const session = new ChatSession(chatConfig, toChatSessionOptions(normalizedSessionId, cwd, options));
54
54
  const completion = createTurnCompletion("CCC Agent");
55
- for await (const event of session.chat(userText, signal)) {
55
+ for await (const event of session.chat(userText, signal, _promptOptions?.drainInput)) {
56
56
  if (event.type === "text")
57
57
  completion.observe({ type: "assistant", blocks: [{ type: "text", text: event.text }] });
58
58
  if (event.type === "text_reset")
@@ -78,6 +78,12 @@ export function createCccAdapter(options = {}) {
78
78
  blocks: [{ type: "text_reset" }],
79
79
  };
80
80
  }
81
+ else if (event.type === "input_injected") {
82
+ yield {
83
+ type: "assistant",
84
+ blocks: [{ type: "input_injected", text: event.text }],
85
+ };
86
+ }
81
87
  else if (event.type === "text") {
82
88
  yield {
83
89
  type: "assistant",