Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

## Unreleased

## 2.0.13 - 2026-09-14

This is a small Dashboard artifact, credential, and session-guard release on the 2.0 TypeScript runtime.
Generated plots and reports can appear in the right pane without a manual open. Same-URL credentials are chosen automatically so chat is not blocked before send. Empty-body diagnostics stay in the UI, not the next model prompt. A one-line tool promise with no tool call is retried once with a new gateway session binding. Permission mode ids are unchanged.

### Added

- Dashboard lists script-generated previewable files and opens the latest exhibit automatically.
- Optional `gatewaySessionAffinity` is persisted so a session can rebind the gateway conversation without changing the local session id.

### Fixed

- Multiple keys on one gateway URL no longer block chat until a settings click. HTTP 401/403 then asks the user to inspect or switch the active credential.
- Empty-visible-text diagnostics are no longer written into model context.
- A short “I will call this tool now” stop with no tool call is retried once after rotating gateway session affinity.
- Unsolicited Responses `web_search_call` events fail as a protocol mismatch instead of a silent completion.
- Dashboard event-stream disconnects show unknown task status instead of implying the turn finished.

### Upgrade

```sh
git pull
npm ci
npm run verify:install
npm link
ant-code --version
```

`ant-code --version` should print `2.0.13`. Restart a running Dashboard
and hard-refresh the browser. Gateway config and `.lab-agent` sessions
do not need to be recreated.

## 2.0.12 - 2026-09-14

Published 2026-09-14 02:04:48 UTC (2026-09-14 10:04:48 UTC+08:00): [v2.0.12](https://github.com/wicm84266964/Ant-Code/releases/tag/v2.0.12).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ ant-code doctor
ant-code
```

`ant-code --version` should print `2.0.12`. If you previously linked a
`ant-code --version` should print `2.0.13`. If you previously linked a
JavaScript install, run `npm link` again so the global command points at
`src/cli/index.ts`.

Expand Down
43 changes: 43 additions & 0 deletions docs/releases/2.0.13-artifacts-credentials-session-guard_zh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Ant Code v2.0.13:产物自动展出、凭据选择与会话短停防护

发布日期:2026-09-14

这是 2.0 TypeScript 运行时上的小版本。Dashboard 会把脚本生成的图、报表和导出文件自动列入右侧产物栏并打开预览。同一网关来源有多份凭据时先自动选定一份再发请求,只有凭据被拒绝才提示去设置检查或切换。同时避免把空正文诊断写进模型上下文,并对「只口头承诺调用工具就结束」做一次换绑定重试。权限模式 id 不变。本说明处于公开发布候选阶段,不表示 CI、tag 或 GitHub Release 已经完成。

## 对用户工作流的直接变化

- Dashboard 右侧「产物与文件」会收集 `write_file` / `edit_file` 以及脚本刚写出的可预览文件(图、PDF、表格、Office、Markdown 等),生成后自动打开最新一份。源码编辑仍出现在列表,但不抢预览。中文路径和 Markdown 链接也能入栏。
- 同一网关 URL(含端口和路径)下有多份 Key 时,不再因为「未选手动生效凭据」而拦下聊天。程序按已保存选择、同一把 Key、当前活动配置或该来源第一份配置自动选定。
- 自动选定的凭据若被上游拒绝(HTTP 401/403),聊天里提示去设置页切换或检查生效凭据。网关超时、5xx 不套这句。
- 模型只回 thinking、没有可见正文时,界面仍可显示本地诊断,但这段话不再写进下一轮模型上下文。
- 若一轮只回一句「马上调用某工具」且没有实际 tool call,Ant Code 会换网关会话绑定后再要一轮,且不把那句承诺写回模型。正常长回复和真正的工具调用不受影响。
- Responses 协议若返回未请求的原生 `web_search_call`,不再当成正常结束,而是报告协议错配。
- Dashboard 事件连接中断时标明任务状态未知,避免当成已经结束;保留自动重连。

## 配置、数据与安全边界

- 不要求手动迁移配置或历史会话。已有网关、权限和 `.lab-agent` 会话可继续使用。
- 生效凭据选择仍保存在现有通用配置里;Key 不离开原有凭据存储。会话可另存可选的网关绑定字段,默认仍用原会话 ID。
- 产物栏只收集工作区内可预览文件,忽略 `node_modules`、`.git` 等目录;工作区外路径不会入栏。
- 空正文诊断仍只给界面看。已经写进旧会话模型上下文的诊断不会自动清除。
- 本次不更新其他产品的内嵌运行时,不同步真实会话、凭据或研究数据。自动化测试不等于所有外部模型供应商或每份私人研究数据都已验证。

## 升级与产物位置

需要 Node.js 22.18+。从源码更新:

```sh
git pull
npm ci
npm run verify:install
npm link
ant-code --version
```

`ant-code --version` 应显示 `2.0.13`。若以前 link 过旧安装,需要重新 `npm link`。正在跑的 Dashboard 需要重启并硬刷新浏览器。现有网关配置和会话不必重建。若同一来源有多份不同 Key,可在设置页确认或改选生效凭据。

## 验证、限制与待完成门禁

- 开发阶段已覆盖产物收集与自动预览、凭据自动选定与 401 提示、空正文诊断不进模型上下文、口头承诺调工具的换绑定重试、原生搜索协议错配,以及会话绑定落盘恢复。
- 开发仓完整 `npm run check` 与跨仓一致性检查在脱敏同步时记录实际数字。候选提交后仍须 GitHub PR 和 main CI 通过,随后创建标签与 Release;实际完成状态记录在公开 CHANGELOG。
- 未对每个外部模型供应商做真实账户验收。口头承诺重试只覆盖「短正文 + 点名本地工具 + 无 tool call」;普通短答不会触发。网关绑定重试不能证明所有第三方代理的内部路由实现。原生 `web_search_call` 会报错,不会自动改成本地搜索工具。
4 changes: 2 additions & 2 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ant-code/cli",
"version": "2.0.12",
"version": "2.0.13",
"description": "AGPL-licensed local coding agent with a terminal UI, local dashboard, tool permissions, skills, MCP integration, and model gateway adapters.",
"type": "module",
"private": true,
Expand Down
55 changes: 47 additions & 8 deletions src/config/source-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,57 @@ export function sourceCredentialState(config: RecordValue) {
for (const [url, members] of groups) {
const explicit = lab.sourceCredentialSelections?.[url];
const retained = lab.resolvedSourceCredentials?.[url];
const requested = explicit ?? retained;
if (requested !== undefined) {
result[url] = members.find((p) => p.id === requested)?.id ?? null;
continue;
}
const keys = new Set(members.map((p) => JSON.stringify([p.gatewayApiKeyDisabled === true, p.gatewayApiKey || ""])));
const active = members.find((p) => p.id === lab.activeGatewayProfile);
result[url] = keys.size === 1 ? members[0].id : active?.id ?? null;
result[url] = pickSourceCredential(members, lab, explicit ?? retained);
}
return result;
}

function pickSourceCredential(members: RecordValue[], lab: RecordValue, requested: unknown) {
const requestedId = typeof requested === "string" ? requested.trim() : "";
if (requestedId) {
const saved = members.find((profile) => profile.id === requestedId);
if (saved) {
return saved.id;
}
}
const keys = new Set(members.map((profile) => JSON.stringify([profile.gatewayApiKeyDisabled === true, profile.gatewayApiKey || ""])));
if (keys.size === 1) {
return members[0].id;
}
const active = members.find((profile) => profile.id === lab.activeGatewayProfile);
if (active) {
return active.id;
}
return members[0]?.id ?? null;
}

export function sourceHasAlternateCredentials(config: RecordValue) {
return distinctSourceCredentialCount(config) > 1;
}

export function sourceCredentialFailureMessage(canSwitch: boolean, status: number | null = null) {
const action = canSwitch
? "请打开设置,为这个来源切换生效凭据后再试。"
: "请到设置页检查这份凭据。";
const http = Number.isInteger(status) && Number(status) > 0 ? `(HTTP ${status})` : "";
return `当前生效凭据不能用。${action}${http}`;
}

function distinctSourceCredentialCount(config: RecordValue) {
const lab = config.lab ?? {};
const url = String(lab.gatewayUrl || "");
if (!url) {
return 0;
}
const profiles: RecordValue[] = Array.isArray(lab.gatewayProfiles) ? lab.gatewayProfiles : [];
const keys = new Set(
profiles
.filter((profile) => String(profile.gatewayUrl || "") === url)
.map((profile) => JSON.stringify([profile.gatewayApiKeyDisabled === true, profile.gatewayApiKey || ""]))
);
return keys.size;
}

export function applySourceCredentials<T extends RecordValue>(config: T): T {
const lab = config.lab ?? {};
const resolved = sourceCredentialState(config);
Expand Down
52 changes: 49 additions & 3 deletions src/core/session-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,50 @@ export function formatAssistantOutput(data: import("../model-gateway/protocol.ts
return "模型本轮没有返回可展示正文。";
}

const PROMISED_TOOL_STOP_REASONS = new Set(["", "stop", "end_turn", "completed"]);
const PROMISED_TOOL_VISIBLE_LIMIT = 160;

export function looksLikePromisedToolWithoutCall(
data: import("../model-gateway/protocol.ts").NormalizedGatewayResponse,
visibleText: string,
thinkingText: string,
toolNames: string[] | undefined
) {
if (data?.toolCalls?.length) {
return false;
}
const stopReason = String(data?.stopReason ?? "").toLowerCase();
if (!PROMISED_TOOL_STOP_REASONS.has(stopReason)) {
return false;
}
const text = String(visibleText ?? "").trim();
if (text.length > PROMISED_TOOL_VISIBLE_LIMIT) {
return false;
}
const names = (toolNames ?? []).map((name) => String(name ?? "").trim()).filter((name) => name.length >= 3);
if (names.length === 0) {
return false;
}
const haystack = `${thinkingText}\n${text}`;
const mentioned = names.filter((name) => haystack.includes(name));
if (mentioned.length === 0) {
return false;
}
if (/(马上|立刻|现在|补测|随后|然后|调用|let me|i will|i'll|going to)/i.test(haystack)) {
return true;
}
return Boolean(thinkingText) && text.length <= 80;
}

export function analyzeAssistantOutputHealth(data: import("../model-gateway/protocol.ts").NormalizedGatewayResponse, finalOutput: unknown, thinking: { text?: string; bytes?: number } | null | undefined) {

export function analyzeAssistantOutputHealth(
data: import("../model-gateway/protocol.ts").NormalizedGatewayResponse,
_finalOutput: unknown,
thinking: { text?: string; bytes?: number } | null | undefined,
options: { toolNames?: string[] } = {}
) {
const reasons = [];
const text = String(finalOutput ?? "").trim();
const text = assistantResponseText(data).trim();
const stopReason = String(data?.stopReason ?? "").toLowerCase();
const thinkingText = String(thinking?.text ?? "");
const thinkingBytes = thinking && Number.isFinite(thinking.bytes) ? Number(thinking.bytes) : gatewayThinkingBytes(data);
Expand All @@ -141,7 +181,7 @@ export function analyzeAssistantOutputHealth(data: import("../model-gateway/prot
if (["length", "max_tokens", "token_limit", "context_length_exceeded"].includes(stopReason)) {
reasons.push(`stop_reason:${stopReason}`);
}
if (text.length > 0 && data?.toolCalls?.length === 0 && thinkingBytes >= 1024 && dataTextBytes(data) === 0 && ["length", "max_tokens", "token_limit"].includes(stopReason)) {
if (data?.toolCalls?.length === 0 && thinkingBytes >= 1024 && dataTextBytes(data) === 0 && ["length", "max_tokens", "token_limit"].includes(stopReason)) {
reasons.push("reasoning_only_length");
}
if (data?.toolCalls?.length === 0 && dataTextBytes(data) === 0 && thinkingBytes >= 4096 && looksLikeRepetitiveThinkingLoop(thinkingText)) {
Expand All @@ -159,6 +199,9 @@ export function analyzeAssistantOutputHealth(data: import("../model-gateway/prot
if (looksLikeTruncatedSentence(text)) {
reasons.push("truncated_visible_text");
}
if (looksLikePromisedToolWithoutCall(data, text, thinkingText, options.toolNames)) {
reasons.push("promised_tool_without_call");
}

return {
ok: reasons.length === 0,
Expand Down Expand Up @@ -262,6 +305,9 @@ export function buildOutputHealthRepairPrompt(health: { reasons?: string[] } | n
reasoningOnlyLength
? "The previous model call exhausted its completion budget in reasoning/thinking without producing visible user-facing text."
: "",
reasons.includes("promised_tool_without_call")
? "The previous model call only promised a tool call in a short sentence and did not emit a tool call. Call the tool now; do not answer with another one-line promise."
: "",
repetitiveThinking
? "The previous model call repeated internal planning text in a thinking loop. Break the loop and answer directly."
: "",
Expand Down
38 changes: 27 additions & 11 deletions src/core/session-persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createSessionStore } from "../storage/session-store.ts";
import { serializeToolResult } from "../tools/result.ts";
import { countLineChanges } from "../tools/diff.ts";
import { createToolRuntime } from "../tools/runtime.ts";
import { serializeExhibitArtifacts } from "../tools/artifacts.ts";
import { createWorkflowState, formatWorkflowContext, summarizeWorkflow, syncWorkflowCompletionOnFinal, type WorkflowState } from "../tools/workflow-tools.ts";
import { getAgentProfile } from "../agents/profiles.ts";
import { resolveMaxParallelReadonlyAgentRuns } from "../agents/orchestration-config.ts";
Expand Down Expand Up @@ -182,23 +183,32 @@ export async function appendSessionMessages(session: AgentSession, data: import(
hooksTrusted?: boolean;
eventOptions?: Record<string, unknown>;
} = {}) {
const assistantContent = data.content.length > 0
? data.content
: [{ type: "text", text: fallbackText }];
const assistantMessage: SessionMessage = {
const thinking = normalizeAssistantThinking(options.thinking);
const modelContent = Array.isArray(data.content) ? data.content : [];
const contextAssistant: SessionMessage = {
role: "assistant",
content: assistantContent
content: modelContent
};
const thinking = normalizeAssistantThinking(options.thinking);
if (thinking) {
assistantMessage.thinking = thinking;
contextAssistant.thinking = thinking;
}
if (Array.isArray(data.toolCalls) && data.toolCalls.length > 0) {
contextAssistant.toolCalls = data.toolCalls;
}

const transcriptAssistant: SessionMessage = {
role: "assistant",
content: modelContent.length > 0 ? modelContent : [{ type: "text", text: fallbackText }]
};
if (thinking) {
transcriptAssistant.thinking = thinking;
}

const turnMessages = Array.isArray(options.turnMessages) ? options.turnMessages : [];
const transcriptMessages = Array.isArray(options.transcriptMessages) ? options.transcriptMessages : turnMessages;
session.messages.push(...turnMessages, assistantMessage);
appendTranscriptMessages(session, [...transcriptMessages, assistantMessage]);
appendModelContextArchiveMessages(session, [...turnMessages, assistantMessage]);
session.messages.push(...turnMessages, contextAssistant);
appendTranscriptMessages(session, [...transcriptMessages, transcriptAssistant]);
appendModelContextArchiveMessages(session, [...turnMessages, contextAssistant]);

return compactSessionContextWithModel(session, {
reason: "automatic",
Expand Down Expand Up @@ -243,6 +253,7 @@ export async function persistSessionMetadata(store: ReturnType<typeof createSess
metadata.usage = usage;
metadata.lastProviderUsage = usage.last ?? null;
metadata.workflow = summarizeWorkflow(session.workflow);
metadata.artifacts = serializeExhibitArtifacts(session.cwd, session.workflow?.changes);
metadata.context = summarizeContextWindow(session);
metadata.goal = serializeSessionGoal(session.goal);
metadata.permissionMode = session.permissionMode;
Expand Down Expand Up @@ -310,6 +321,7 @@ export async function persistSessionSnapshot(session: AgentSession, options: { e
metadata.allowCommand = session.allowCommand;
metadata.context = summarizeContextWindow(session);
metadata.workflow = summarizeWorkflow(session.workflow);
metadata.artifacts = serializeExhibitArtifacts(session.cwd, session.workflow?.changes);
metadata.goal = serializeSessionGoal(session.goal);
const committed = await commitSessionSnapshot(store, metadata, session, {
requireExisting: options.requireExisting !== false
Expand Down Expand Up @@ -526,7 +538,10 @@ export function refreshSessionModelSelection(session: AgentSession) {
/** @param {AgentSession} session @returns {Record<string, any>} */
export function sessionModelMetadata(session: AgentSession): Record<string, unknown> {
const selection = refreshSessionModelSelection(session);
if (selection) return patchSessionModelSelectionMetadata({}, selection as Record<string, unknown>);
const routing = typeof session.gatewaySessionAffinity === "string" && session.gatewaySessionAffinity.trim()
? { gatewaySessionAffinity: session.gatewaySessionAffinity.trim() }
: {};
if (selection) return patchSessionModelSelectionMetadata(routing, selection as Record<string, unknown>);
if (session.config?.configV2?.enabled === true) {
throw new SessionModelSelectionUnresolvedError({
reason: "invalid-runtime-selection",
Expand All @@ -535,6 +550,7 @@ export function sessionModelMetadata(session: AgentSession): Record<string, unkn
});
}
return {
...routing,
metadataVersion: 1,
model: String(session.model ?? ""),
reasoningEffort: typeof session.config?.reasoningEffort === "string"
Expand Down
1 change: 1 addition & 0 deletions src/core/session-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ export async function resolveResumeMetadata(options: { cwd: string; config: LabA
config: runtimeConfig,
metadata: {
id: result.metadata.id,
gatewaySessionAffinity: result.metadata.gatewaySessionAffinity,
startedAt: result.metadata.startedAt,
turnCount: Number.isFinite(result.metadata.turnIndex) ? result.metadata.turnIndex : 0,
metadataPath: result.path,
Expand Down
Loading