@v587d/capital-generation 2.9.1 → 2.9.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@
7
7
  - **第三位(patch)**:不破坏既有工具 / 配置 / 会话的新增与修复(如 2.1.1、2.1.2)。
8
8
  - **第二位(minor)**:有需要用户知晓的行为变更,且段内附迁移说明(如 2.1.0 的图表呈现通道重做、2.2.0 的数据源与检索来源扩容、2.4.0 的**必须换宿主版本**、2.9.0 的选择器收敛与质检前置——本插件自己的工具与数据契约没变,所以只抬第二位)。
9
9
 
10
+ ## [2.9.2] - 2026-10-23
11
+
12
+ `data_junior` 接入官方 PTC(`run_code`):内层工具调用压进一段程序,多 Dataset 并发、`too_large`
13
+ 收窄与 skill 读取不再逐次往返模型。实机同类任务由 15 分钟级降到 2–3 分钟级;工具、数据契约、
14
+ 用户配置与宿主基线都不变。图表交付的收尾闸门同时修复结算通知竞争与同轮重复写入。
15
+
16
+ ### Added
17
+
18
+ - **分析师的 PTC 执行面**:`data_junior` 只保留 `run_code` 直调,Dataset / skill / 出图委派 /
19
+ 回传都通过 SDK `tools.*` 在程序内完成;不再挂 bash / pwsh,同时保住了“不读原始行”的边界。
20
+ - **多 Dataset 并发与 `too_large` 自愈**:同一程序里可以 `Promise.all` 多份 `describe_dataset`;
21
+ 结果过大时按 hint 收窄后继续,不再多开模型轮次。
22
+ - **PTC 会话报告**:`npm run ptc:report` 汇总 `run_code` 段数、`Promise.all`、skill 打印、
23
+ `too_large` 与并发窗口,供实机验收。
24
+
25
+ ### Changed
26
+
27
+ - **分析师提示词与三册协议同步 PTC 写法**:第一次加载协议就明确 skill 也要在 `run_code` 内用
28
+ `tools.skill`,不要直调原生工具;查询、profile 与可视化协议同步程序内调度。
29
+ - **图表交付继续由收尾流写入**:`present_charts` 只记录清单,宿主在最终答复前统一落
30
+ `deliverables/presented`;结算通知不再阻塞收尾,且同一 exchange 不会重复写卡。
31
+
32
+ ### Fixed
33
+
34
+ - **图表卡在结算通知竞争时丢失**:子 Agent 的 `subagent-settled` 通知在收尾流中途到达时,
35
+ 不再被当成“还有待处理消息”;通知同时清掉已结算的直属子 Agent。
36
+ - **同一轮重复交付**:后续 `finish` 帧缺少 `turn` 时不再写第二张卡。
37
+ - **直调 skill 误调用**:软约束已把 `tools.skill` 提到第一次加载协议处;模型仍可能试错一次,
38
+ 宿主拒绝后会自愈,不影响结果。
39
+
10
40
  ## [2.9.1] - 2026-10-21
11
41
 
12
42
  修复实机 `dsh web` 反馈的两处收尾体验:图表交付不再散落在等待轮,最终答复不再只剩一句
package/README.md CHANGED
@@ -18,8 +18,9 @@
18
18
  <p align="center">
19
19
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin"></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License"></a>
21
- <a href="https://github.com/v587d/capital-generation/releases"><img src="https://img.shields.io/badge/version-2.9.1-9cf" alt="Version"></a>
21
+ <a href="https://github.com/v587d/capital-generation/releases"><img src="https://img.shields.io/badge/version-2.9.2-9cf" alt="Version"></a>
22
22
  <a href="https://www.npmjs.com/package/@v587d/capital-generation"><img src="https://img.shields.io/npm/v/%40v587d%2Fcapital-generation" alt="npm version"></a>
23
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white" alt="powered-by-dsh"></a>
23
24
  </p>
24
25
 
25
26
  # Capital Generation
@@ -190,7 +191,7 @@ https://github.com/v587d/capital-generation
190
191
  - **安装后找不到模式?** 确认插件出现在「插件」→ 已安装列表;Web / CLI profile 安装后要重启该 profile,并在新会话选择模式。选择器里应看到自动模式、行情专家、资讯专员;分析师和绘图专员是自动模式的内部角色,不会出现在这里。
191
192
  - **配置在哪?** 打开「插件」→ 已安装的 **Capital Generation**,页面顶部就是「数据源 / 精选 Skills / 包含的组件」三格,五把密钥都在第一格;不在 DSH 的普通「设置」页面,也不用点进任何一行组件。保存密钥后新建会话再试。
192
193
  - **升级到 2.9.0 后,分析师和绘图专员去哪了?** 这一版把它们收进自动模式内部,不再出现在选择器与预设管理里,避免误开一个没有取数入口的空会话。旧会话仍能按原身份恢复;新任务直接用自动模式提出即可。
193
- - **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.9.1` 或 `@2.9.1`。
194
+ - **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.9.2` 或 `@2.9.2`。
194
195
  - **刚发布的新版本装不到?** 桌面端取包时会跳过**刚发布不久**的版本(宿主侧包管理器的发布冷却策略,实测会静默装成上一个版本),国内镜像的同步也可能滞后一段时间。急着用最新代码,改用仓库地址安装最直接。
195
196
  - **网页抓取失败?** 配置卡片中的「允许启动本地提取网页内容」默认开启:AnySearch 提取失败时可尝试本机直连。关闭后,本机直连的具名来源查询也不可用;请检查该开关和网络连接。
196
197
  - **Wind 那部分会消耗积分?** 会。公告与新闻检索、宏观 / 行业 / 汇率指标、按日期区间取的历史 K 线都按次消耗你在 Wind 侧的额度;额度与条款以 Wind 为准,插件不做用量封顶。没配 `WIND_API_KEY` 时这几类不可用,其余功能照常。
@@ -90,9 +90,9 @@ route owner 自己负责,平台自己的 `/api` 与首页分别走 `connection
90
90
 
91
91
  ## 本轮呈现(官方交付通道)
92
92
 
93
- auto 模式下 `render_chart` 不再自动登记:主 Agent 在写最终答复前调用一次 `present_charts`,
94
- 把本轮 `chart_ref` 汇总成一条**官方** `deliverables/presented` 事件写进"用户正在看的根会话",
95
- 把 `capital-analysis/charts/<chart_id>/chart.html` 登记为**收尾轮交付物**(单体载体仍在出图时
93
+ auto 模式下 `render_chart` 不再自动登记:`present_charts` 只记录本轮图表,宿主在**收尾流结束、
94
+ 收尾消息落盘之前**写一条**官方** `deliverables/presented` 事件到"用户正在看的根会话",把
95
+ `capital-analysis/charts/<chart_id>/chart.html` 登记为**收尾轮交付物**(单体载体仍在出图时
96
96
  自动登记):
97
97
 
98
98
  - 载荷只有官方契约字段:`turn` / `callId`(`capital-chart:<chart_id>` 句柄)/ `files[]`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/capital-charts",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "description": "自动模式的图表前端组件(浏览器半边 + 序列旁路路由):由 @v587d/capital-generation 随包携带并作为 host 平面行挂载。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/config",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "description": "自动模式的取数凭据与偏好配置行:卡片坐在 @v587d/capital-generation 插件主页的「配置」段,点这一行本身没有表单。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,13 +1,17 @@
1
- /** Shell guard for bash/pwsh: blocks only doomed sandbox escalation; command content is never
2
- * inspected (the sandbox blocks writes, not reads/network). Delegated approvals are fixed to
3
- * `never`, so escalation auto-rejects with a misleading "user rejected"; block early instead.
4
- * Root escalation still uses ask; do not pin delegated sessions read-only (`store.writeContext`
5
- * needs workspace-write). `tools.guard` is monotonic; pre-execute is a rewritable waterfall. */
1
+ /** Execution guard for bash/pwsh/run_code: blocks only doomed sandbox escalation; command/program
2
+ * content is never inspected (the sandbox blocks writes, not reads/network). Delegated approvals
3
+ * are fixed to `never`, so escalation auto-rejects with a misleading "user rejected"; block early
4
+ * instead. Root escalation still uses ask; do not pin delegated sessions read-only
5
+ * (`store.writeContext` needs workspace-write). `tools.guard` is monotonic; pre-execute is a
6
+ * rewritable waterfall. */
6
7
  import { type GuardedExecution, type PolicyContext } from './root-tool-policy.js';
8
+ /** Both shell names on every platform plus the PTC transport: the preset mounts one shell pair for
9
+ * the root and `run_code` for data_junior, so matching only the local platform kills the Windows gate. */
10
+ export declare const EXECUTION_ESCALATION_TOOL_NAMES: readonly ["bash", "pwsh", "run_code"];
7
11
  export type BashGuardExecution = GuardedExecution;
8
12
  /** States that the rejection is the system's, not the user's, and gives the recovery path. */
9
13
  export declare const SHELL_ESCALATION_UNREACHABLE: string;
10
- /** Reads identity plus `sandbox_permissions`/`justification`, never `command`; unknown identity fails closed. */
14
+ /** Reads identity plus `sandbox_permissions`/`justification`, never `command`/`code`; unknown identity fails closed. */
11
15
  export declare function bashGuardReason(exec: BashGuardExecution | undefined): string | undefined;
12
16
  /** Installs on every Capital preset agent. Must listen on `ctx.root`: agent/created routes by
13
17
  * the agent object, so tagged standing-scope listeners never fire. Failures only warn. */
@@ -1,23 +1,26 @@
1
- /** Shell guard for bash/pwsh: blocks only doomed sandbox escalation; command content is never
2
- * inspected (the sandbox blocks writes, not reads/network). Delegated approvals are fixed to
3
- * `never`, so escalation auto-rejects with a misleading "user rejected"; block early instead.
4
- * Root escalation still uses ask; do not pin delegated sessions read-only (`store.writeContext`
5
- * needs workspace-write). `tools.guard` is monotonic; pre-execute is a rewritable waterfall. */
1
+ /** Execution guard for bash/pwsh/run_code: blocks only doomed sandbox escalation; command/program
2
+ * content is never inspected (the sandbox blocks writes, not reads/network). Delegated approvals
3
+ * are fixed to `never`, so escalation auto-rejects with a misleading "user rejected"; block early
4
+ * instead. Root escalation still uses ask; do not pin delegated sessions read-only
5
+ * (`store.writeContext` needs workspace-write). `tools.guard` is monotonic; pre-execute is a
6
+ * rewritable waterfall. */
6
7
  import { agentSessionId, isCapitalPresetAgent, SHELL_TOOL_NAMES } from './root-tool-policy.js';
7
- /** Both names on every platform: the preset mounts a pair, so matching only the local platform kills the Windows gate. */
8
- const shellToolNames = new Set(SHELL_TOOL_NAMES);
8
+ /** Both shell names on every platform plus the PTC transport: the preset mounts one shell pair for
9
+ * the root and `run_code` for data_junior, so matching only the local platform kills the Windows gate. */
10
+ export const EXECUTION_ESCALATION_TOOL_NAMES = [...SHELL_TOOL_NAMES, 'run_code'];
11
+ const executionToolNames = new Set(EXECUTION_ESCALATION_TOOL_NAMES);
9
12
  /** One listener set per root context: host policy and legacy call sites install once. */
10
13
  const installedRoots = new WeakSet();
11
14
  /** States that the rejection is the system's, not the user's, and gives the recovery path. */
12
- export const SHELL_ESCALATION_UNREACHABLE = 'shell 工具(bash / pwsh)的沙箱升级只有主 Agent 的会话能拿到用户审批:被委派的子会话拿不到'
15
+ export const SHELL_ESCALATION_UNREACHABLE = '执行工具(bash / pwsh / run_code)的沙箱升级只有主 Agent 的会话能拿到用户审批:被委派的子会话拿不到'
13
16
  + '(框架把委派会话的审批策略固定为 never,不会去问用户),当前调用方也不是可确认的主 Agent 会话。'
14
17
  + '不要重试升级:缺数值用 describe_dataset / query_dataset,缺数据用 send_message 发 data_gap 回主 Agent。';
15
18
  function isRecord(value) {
16
19
  return typeof value === 'object' && value !== null && !Array.isArray(value);
17
20
  }
18
- /** Reads identity plus `sandbox_permissions`/`justification`, never `command`; unknown identity fails closed. */
21
+ /** Reads identity plus `sandbox_permissions`/`justification`, never `command`/`code`; unknown identity fails closed. */
19
22
  export function bashGuardReason(exec) {
20
- if (exec === undefined || typeof exec.name !== 'string' || !shellToolNames.has(exec.name))
23
+ if (exec === undefined || typeof exec.name !== 'string' || !executionToolNames.has(exec.name))
21
24
  return undefined;
22
25
  const args = isRecord(exec.arguments) ? exec.arguments : undefined;
23
26
  const escalated = args !== undefined && (args.sandbox_permissions !== undefined || args.justification !== undefined);
@@ -64,7 +67,7 @@ export function registerBashGuard(ctx) {
64
67
  return;
65
68
  const dispose = installAgentGuard(agent);
66
69
  if (dispose === undefined) {
67
- ctx.logger?.warn('capital-generation: shell 闸门未生效(agent scope 上没有 tools.guard):沙箱升级拦截只剩 persona 软约束');
70
+ ctx.logger?.warn('capital-generation: 执行工具闸门未生效(agent scope 上没有 tools.guard):沙箱升级拦截只剩 persona 软约束');
68
71
  return;
69
72
  }
70
73
  const id = agentSessionId(agent);
@@ -72,7 +75,7 @@ export function registerBashGuard(ctx) {
72
75
  guards.set(id, dispose);
73
76
  }
74
77
  catch (error) {
75
- ctx.logger?.warn(`capital-generation: bash 闸门安装异常:${error instanceof Error ? error.message : String(error)}`);
78
+ ctx.logger?.warn(`capital-generation: 执行工具闸门安装异常:${error instanceof Error ? error.message : String(error)}`);
76
79
  }
77
80
  });
78
81
  const stopDisposed = listenCtx.on?.('agent/disposed', (payload) => {
@@ -47,6 +47,8 @@ export const ROOT_AGENT_DENIED_TOOLS = [
47
47
  'write',
48
48
  'edit',
49
49
  ];
50
+ // ⛔ `run_code` belongs nowhere in this list: it is a reserved PTC transport name, so `restrict()`
51
+ // throws on it. The root stays native; deployment-level `dsh-tools.mode` must not be set to ptc.
50
52
  export const CAPITAL_PRESET_ID = ROOT_PRESET_ID;
51
53
  export function isRootAgent(agent) {
52
54
  if (agent?.session === undefined)
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Chart delivery at the closing answer.
3
+ *
4
+ * DSH's deliverable card only replays `deliverables/presented` events whose seq is before the
5
+ * turn's closing assistant message, and a turn is final only after the fact. The one place that is
6
+ * provably before the closing message is the assistant stream's terminal `finish` chunk: the loop
7
+ * appends the message after the stream ends. So delivery is deferred to that chunk, gated on the
8
+ * root session being idle (no actionable inbox input, no running direct children), which is exactly
9
+ * the shape of a closing turn. Waiting/aborted/tool-call responses never flush.
10
+ *
11
+ * `subagent-settled` notices are not actionable input: they can arrive while the closing answer is
12
+ * already streaming. Counting them as pending would strand a card that the model has just selected,
13
+ * because the follow-up step often has nothing new to say.
14
+ */
15
+ import type { ChartArtifactRegistry } from './artifact-ref.js';
16
+ import type { ChartTurnEventInput } from './events.js';
17
+ import type { SessionLike } from '../data-collector/store.js';
18
+ /** Structural subset of `@deepseek-ai/dsh-llm` AssistantStreamFrame. */
19
+ export interface AssistantStreamFrameLike {
20
+ readonly type?: string;
21
+ readonly attemptId?: string;
22
+ readonly turn?: number;
23
+ readonly chunk?: {
24
+ readonly type?: string;
25
+ readonly reason?: {
26
+ readonly kind?: string;
27
+ };
28
+ readonly blockType?: string;
29
+ readonly block?: {
30
+ readonly type?: string;
31
+ };
32
+ };
33
+ }
34
+ interface InboxStateLike {
35
+ 'next-turn'?: readonly unknown[];
36
+ 'next-step'?: readonly unknown[];
37
+ }
38
+ export interface ChartDeliveryOptions {
39
+ artifacts: ChartArtifactRegistry;
40
+ /** Writes one first-party deliverables/presented batch; must be synchronous (pre-message seq). */
41
+ publish: (sessionId: string, inputs: ChartTurnEventInput[]) => boolean;
42
+ /** Durable pending input; synchronous projection read. Absent means "cannot tell" → proceed. */
43
+ inboxOf?: (session: SessionLike) => InboxStateLike | undefined;
44
+ /** Bounded batch; the card collapses past four files and expands on demand. */
45
+ maxCharts?: number;
46
+ }
47
+ export interface ChartDelivery {
48
+ /** `present_charts` selection; merged across calls because models often retry in batches. */
49
+ request(session: SessionLike, inputs: ChartTurnEventInput[]): void;
50
+ acceptStream(session: SessionLike | undefined, frame: AssistantStreamFrameLike): void;
51
+ /** A new human message starts a new exchange: old charts must not ride into it. */
52
+ acceptUserMessage(sessionId: string, time: number): void;
53
+ acceptSubagentStart(info: {
54
+ id?: string;
55
+ }): void;
56
+ acceptSubagentEnd(info: {
57
+ id?: string;
58
+ }): void;
59
+ /** False until any stream frame was observed: callers fall back to immediate delivery. */
60
+ streaming(): boolean;
61
+ /** Diagnostics/tests: explicitly selected inputs for the current exchange. */
62
+ pending(sessionId?: string): number;
63
+ }
64
+ export declare const DEFAULT_MAX_DELIVERED_CHARTS = 32;
65
+ export declare function createChartDelivery(options: ChartDeliveryOptions): ChartDelivery;
66
+ export {};
@@ -0,0 +1,148 @@
1
+ /** Settlement notices race the closing answer; they do not require another model step before the card. */
2
+ function isSettledNotice(value) {
3
+ return value !== null && typeof value === 'object'
4
+ && value.source?.kind === 'subagent-settled';
5
+ }
6
+ function inboxMessages(inbox) {
7
+ return [...(inbox?.['next-turn'] ?? []), ...(inbox?.['next-step'] ?? [])];
8
+ }
9
+ export const DEFAULT_MAX_DELIVERED_CHARTS = 32;
10
+ export function createChartDelivery(options) {
11
+ const maxCharts = options.maxCharts ?? DEFAULT_MAX_DELIVERED_CHARTS;
12
+ /** Plugin instances are per root agent, so one session id guards against child streams leaking in. */
13
+ let sessionId;
14
+ let explicit = [];
15
+ const exchangeStarts = new Map();
16
+ const fallbackStart = Date.now();
17
+ let lastFlushedTurn;
18
+ let sawFrame = false;
19
+ const attempts = new Map();
20
+ const running = new Map();
21
+ const owns = (session) => {
22
+ if (session?.id === undefined)
23
+ return false;
24
+ // A leaked child frame must not bind the manager: the card belongs to the user-facing root.
25
+ if (session.header?.origin === 'subagent')
26
+ return false;
27
+ sessionId ??= session.id;
28
+ return session.id === sessionId;
29
+ };
30
+ const autoInputs = (session) => options.artifacts
31
+ .list({ session })
32
+ .filter((artifact) => artifact.created_at >= (exchangeStarts.get(session.id) ?? fallbackStart))
33
+ .map((artifact) => ({
34
+ ownerSessionId: session.id,
35
+ chart_id: artifact.chart_id,
36
+ title: artifact.title,
37
+ html_path: artifact.html_path,
38
+ }));
39
+ /** A settled notice proves the child is done even if the lifecycle edge was missed or the parent
40
+ * observed it outside its scope. Reconciliating here keeps the stream-time gate synchronous. */
41
+ const reconcileSettled = (inbox) => {
42
+ for (const message of inboxMessages(inbox)) {
43
+ if (!isSettledNotice(message))
44
+ continue;
45
+ const childId = message.source?.senderSessionId;
46
+ if (typeof childId === 'string')
47
+ running.delete(childId);
48
+ }
49
+ };
50
+ const idle = (inbox) => {
51
+ if (running.size > 0)
52
+ return false;
53
+ if (inbox === undefined)
54
+ return true;
55
+ return inboxMessages(inbox).every(isSettledNotice);
56
+ };
57
+ const flush = (session, turn) => {
58
+ // A later step in the same exchange may omit `turn`; it still must not write a second card.
59
+ if (lastFlushedTurn !== undefined && (turn === undefined || lastFlushedTurn === turn))
60
+ return;
61
+ const inbox = options.inboxOf?.(session);
62
+ reconcileSettled(inbox);
63
+ if (!idle(inbox))
64
+ return;
65
+ const inputs = (explicit.length > 0 ? explicit : autoInputs(session)).slice(0, maxCharts);
66
+ if (inputs.length === 0)
67
+ return;
68
+ let written = false;
69
+ try {
70
+ written = options.publish(session.id, inputs);
71
+ }
72
+ catch {
73
+ written = false;
74
+ }
75
+ // Sentinel keeps a second turn-less finish in the same exchange from duplicating the card.
76
+ if (written)
77
+ lastFlushedTurn = turn ?? -1;
78
+ };
79
+ return {
80
+ request(session, inputs) {
81
+ if (!owns(session) || inputs.length === 0)
82
+ return;
83
+ const seen = new Set(explicit.map((item) => item.chart_id));
84
+ for (const input of inputs) {
85
+ if (seen.has(input.chart_id))
86
+ continue;
87
+ seen.add(input.chart_id);
88
+ explicit.push(input);
89
+ }
90
+ if (explicit.length > maxCharts)
91
+ explicit = explicit.slice(0, maxCharts);
92
+ },
93
+ acceptStream(session, frame) {
94
+ if (!owns(session) || frame === undefined)
95
+ return;
96
+ sawFrame = true;
97
+ const attemptId = frame.attemptId ?? '__default__';
98
+ if (frame.type === 'start') {
99
+ attempts.set(attemptId, { toolCall: false });
100
+ return;
101
+ }
102
+ if (frame.type === 'end') {
103
+ attempts.delete(attemptId);
104
+ return;
105
+ }
106
+ const chunk = frame.chunk;
107
+ if (frame.type !== 'chunk' || chunk === undefined)
108
+ return;
109
+ const attempt = attempts.get(attemptId) ?? { toolCall: false };
110
+ attempts.set(attemptId, attempt);
111
+ if (chunk.type === 'tool-call-delta'
112
+ || (chunk.type === 'block-start' && chunk.blockType === 'tool-call')
113
+ || (chunk.type === 'block-end' && chunk.block?.type === 'tool-call')) {
114
+ attempt.toolCall = true;
115
+ return;
116
+ }
117
+ if (chunk.type !== 'finish' || chunk.reason?.kind !== 'stop' || attempt.toolCall)
118
+ return;
119
+ flush(session, frame.turn);
120
+ },
121
+ acceptUserMessage(id, time) {
122
+ exchangeStarts.set(id, time);
123
+ if (id !== sessionId)
124
+ return;
125
+ explicit = [];
126
+ lastFlushedTurn = undefined;
127
+ },
128
+ acceptSubagentStart(info) {
129
+ if (typeof info?.id !== 'string' || info.id.length === 0)
130
+ return;
131
+ running.set(info.id, 1);
132
+ },
133
+ acceptSubagentEnd(info) {
134
+ if (typeof info?.id !== 'string')
135
+ return;
136
+ // One child session runs one activation at a time; deleting also clears duplicate starts.
137
+ running.delete(info.id);
138
+ },
139
+ streaming() {
140
+ return sawFrame;
141
+ },
142
+ pending(id) {
143
+ if (id !== undefined && id !== sessionId)
144
+ return 0;
145
+ return explicit.length;
146
+ },
147
+ };
148
+ }
@@ -38,6 +38,8 @@ export interface ChartTurnEventInput {
38
38
  }
39
39
  export interface ChartEventPublisher {
40
40
  publish(input: ChartTurnEventInput): boolean;
41
+ /** One first-party event carrying every file; the turn cap counts events, not files. */
42
+ publishMany(ownerSessionId: string, inputs: ChartTurnEventInput[]): boolean;
41
43
  /** Diagnostics: queued charts not yet written. */
42
44
  pendingCount?(ownerSessionId?: string): number;
43
45
  /** Diagnostics: last publish trace, surfaced in receipts / disk so failures are visible without a terminal. */
@@ -160,7 +160,7 @@ function buildChartEventPublisher(ctx) {
160
160
  const key = `${owner.id}:${turn}`;
161
161
  const used = perTurn.get(key) ?? 0;
162
162
  if (used >= MAX_CHARTS_PER_TURN) {
163
- warn(`图表对话流事件跳过:turn ${turn} 已达到 ${MAX_CHARTS_PER_TURN} 张上限`);
163
+ warn(`图表对话流事件跳过:turn ${turn} 已达到 ${MAX_CHARTS_PER_TURN} 次上限`);
164
164
  return false;
165
165
  }
166
166
  try {
@@ -174,6 +174,31 @@ function buildChartEventPublisher(ctx) {
174
174
  note(`图表已登记为本轮交付(owner=${owner.id},turn=${turn},${input.chart_id})`);
175
175
  return true;
176
176
  };
177
+ const appendMany = (owner, turn, inputs) => {
178
+ if (inputs.length === 0)
179
+ return false;
180
+ const key = `${owner.id}:${turn}`;
181
+ const used = perTurn.get(key) ?? 0;
182
+ if (used >= MAX_CHARTS_PER_TURN) {
183
+ warn(`图表对话流事件跳过:turn ${turn} 已达到 ${MAX_CHARTS_PER_TURN} 次上限`);
184
+ return false;
185
+ }
186
+ const first = inputs[0];
187
+ if (first === undefined)
188
+ return false;
189
+ const payload = buildChartEventPayload(first, turn);
190
+ payload.files = inputs.map((item) => ({ path: item.html_path, description: item.title }));
191
+ try {
192
+ owner.append(CHART_DELIVERABLE_EVENT, payload);
193
+ }
194
+ catch (error) {
195
+ warn(`图表对话流事件写入失败(图表已落盘):${error instanceof Error ? error.message : String(error)}`);
196
+ return false;
197
+ }
198
+ perTurn.set(key, used + 1);
199
+ note(`图表已登记为本轮交付(owner=${owner.id},turn=${turn},${inputs.length} 张)`);
200
+ return true;
201
+ };
177
202
  /** Flush queued charts into a turn; the preferred hook is turn-stopping. */
178
203
  const flush = (ownerSessionId, turn) => {
179
204
  const queued = pending.get(ownerSessionId);
@@ -308,6 +333,32 @@ function buildChartEventPublisher(ctx) {
308
333
  lastPublishTrace = `${trace.join(' ')} 回合打开(turn=${lastTurn}) → ${written ? '已直接登记' : '写入失败'}`;
309
334
  return written;
310
335
  },
336
+ publishMany(ownerSessionId, inputs) {
337
+ const owner = resolveOwnerSession(sessions, ownerSessionId);
338
+ if (owner === undefined) {
339
+ lastPublishTrace = `owner 未解析:批量交付缺少可用 owner 会话`;
340
+ warn('图表对话流事件跳过:找不到批量交付的 owner 会话(图表已落盘,走 html_path 降级)');
341
+ return false;
342
+ }
343
+ const boundary = boundaryOf(owner);
344
+ const turn = boundary?.lastTurn;
345
+ if (typeof boundary?.openTurnStartSeq !== 'number' || typeof turn !== 'number' || !Number.isSafeInteger(turn) || turn < 1) {
346
+ // Between turns: queue each input so the existing flush path still delivers them later.
347
+ const queued = pending.get(owner.id) ?? [];
348
+ if (queued.length + inputs.length > MAX_PENDING_CHARTS) {
349
+ lastPublishTrace = `owner=${owner.id} 批量寄存超限(${queued.length}+${inputs.length} > ${MAX_PENDING_CHARTS})→ 未写入`;
350
+ warn('图表对话流事件跳过:进程内寄存已超出上限(冲刷链路异常)');
351
+ return false;
352
+ }
353
+ queued.push(...inputs);
354
+ pending.set(owner.id, queued);
355
+ lastPublishTrace = `owner=${owner.id} 回合未打开 → 已寄存 ${inputs.length} 张,等 turn-stopping/turn-start 冲刷`;
356
+ return true;
357
+ }
358
+ const written = appendMany(owner, turn, inputs);
359
+ lastPublishTrace = `owner=${owner.id} 回合打开(turn=${turn}) → ${written ? `已批量登记 ${inputs.length} 张` : '写入失败'}`;
360
+ return written;
361
+ },
311
362
  /** Diagnostics/tests: queued charts not yet flushed. */
312
363
  pendingCount(ownerSessionId) {
313
364
  if (ownerSessionId !== undefined)
@@ -10,10 +10,18 @@
10
10
  import type { Context } from '@deepseek-ai/cordis';
11
11
  import type { ChartArtifactRegistry } from './artifact-ref.js';
12
12
  import { type ChartEventPublisher } from './events.js';
13
+ import type { ChartDelivery } from './delivery.js';
14
+ import type { SessionLike } from '../data-collector/store.js';
15
+ /** One card can carry many files (the client collapses past four); the cap only bounds one event. */
16
+ export declare const MAX_PRESENT_CHARTS = 32;
13
17
  export interface PresentChartsInput {
14
18
  artifacts: ChartArtifactRegistry;
15
19
  /** Lazy: the host projection may mount after this row; an absent publisher fails the call loudly. */
16
20
  chartEvents: () => ChartEventPublisher | undefined;
21
+ /** Records the selection; the closing stream chunk writes it (normal path). */
22
+ delivery?: ChartDelivery;
23
+ /** Fallback-only guard when the stream listener is unavailable. */
24
+ readiness?: (session: SessionLike) => Promise<string | undefined>;
17
25
  }
18
26
  export interface PresentChartsReceipt {
19
27
  presented: Array<{
@@ -1,5 +1,7 @@
1
1
  import { MAX_CHARTS_PER_TURN } from './events.js';
2
2
  import { callerSession } from '../tool-exec.js';
3
+ /** One card can carry many files (the client collapses past four); the cap only bounds one event. */
4
+ export const MAX_PRESENT_CHARTS = 32;
3
5
  const jsonObject = (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
4
6
  const parameters = jsonObject({
5
7
  chart_refs: {
@@ -41,8 +43,8 @@ function normalizeRefs(value) {
41
43
  if (!refs.includes(ref))
42
44
  refs.push(ref);
43
45
  }
44
- if (refs.length > MAX_CHARTS_PER_TURN) {
45
- throw new Error(`present_charts 一次最多登记 ${MAX_CHARTS_PER_TURN} 张图(收到 ${refs.length} 个 chart_ref)`);
46
+ if (refs.length > MAX_PRESENT_CHARTS) {
47
+ throw new Error(`present_charts 一次最多登记 ${MAX_PRESENT_CHARTS} 张图(收到 ${refs.length} 个 chart_ref)`);
46
48
  }
47
49
  return refs;
48
50
  }
@@ -75,9 +77,9 @@ function selectArtifacts(input, session, refs, taskId) {
75
77
  const all = input.artifacts.list({ session, task_id: taskId });
76
78
  if (all.length === 0)
77
79
  failed.push({ chart_ref: '(task)', reason: `本会话该 task_id 没有可登记的图表:${taskId}` });
78
- const selected = all.slice(0, MAX_CHARTS_PER_TURN);
79
- if (all.length > MAX_CHARTS_PER_TURN) {
80
- failed.push({ chart_ref: '(task)', reason: `该 task_id 有 ${all.length} 张图,本轮最多登记 ${MAX_CHARTS_PER_TURN} 张;其余未展示` });
80
+ const selected = all.slice(0, MAX_PRESENT_CHARTS);
81
+ if (all.length > MAX_PRESENT_CHARTS) {
82
+ failed.push({ chart_ref: '(task)', reason: `该 task_id 有 ${all.length} 张图,单次最多登记 ${MAX_PRESENT_CHARTS} 张;其余未展示` });
81
83
  }
82
84
  return { selected, failed };
83
85
  }
@@ -87,10 +89,10 @@ export function registerPresentChartsTool(ctx, input) {
87
89
  return;
88
90
  const definition = {
89
91
  name: 'present_charts',
90
- description: '把本会话已生成的图表登记为**最终答复的交付物**(官方 deliverables/presented):用户会在本轮答复末尾的「本轮文件改动 / 交付」行看到卡片,点开即渲染自包含 chart.html。'
91
- + '**只由主 Agent 在写最终答复前调用一次**:参数是 data_junior 回传的 chart_refs(一次全部传入,最多 8 个);refs 已丢失时改传 task_id 登记该任务的全部图表;调用后立刻写最终答复。'
92
- + '不要在过程轮 / 等待行前调用,不要分批重复调用;不要传文件路径或 HTML(路径由宿主解析)。'
93
- + '不调用本工具,图表不会出现在对话里。',
92
+ description: '把本会话已生成的图表登记为**最终答复的交付物**(官方 deliverables/presented):用户会在答复末尾的「本轮文件改动 / 交付」行看到卡片,点开即渲染自包含 chart.html。'
93
+ + '**由主 Agent 调用一次**:参数是 data_junior 回传的 chart_refs(一次传入,最多 32 个);refs 已丢失时改传 task_id 登记该任务的全部图表。'
94
+ + '这里只记录本轮要交付的图,宿主会在**最终答复之前**统一写入交付行——提前调用不会落进中间轮,不必等“最后一刻”,也不要在过程轮反复调用。'
95
+ + '不要传文件路径或 HTML(路径由宿主解析);不调用时宿主仍会在收尾时自动登记本 exchange 已生成的图表。',
94
96
  parameters,
95
97
  async execute(args, exec) {
96
98
  const session = callerSession(exec);
@@ -99,23 +101,42 @@ export function registerPresentChartsTool(ctx, input) {
99
101
  if (refs.length === 0 && taskId === undefined) {
100
102
  throw new Error('present_charts 需要 chart_refs 或 task_id:两者都不给就无法确定要登记哪几张图');
101
103
  }
104
+ const { selected, failed } = selectArtifacts(input, session, refs, taskId);
105
+ const receipt = { presented: [], failed };
106
+ if (selected.length === 0)
107
+ return receipt;
108
+ const inputs = selected.map((artifact) => ({
109
+ ownerSessionId: session.id,
110
+ chart_id: artifact.chart_id,
111
+ title: artifact.title,
112
+ html_path: artifact.html_path,
113
+ }));
114
+ // Normal path: remember the selection; the closing stream chunk writes it before the answer.
115
+ if (input.delivery?.streaming() === true) {
116
+ input.delivery.request(session, inputs);
117
+ for (const artifact of selected)
118
+ receipt.presented.push({ chart_ref: artifact.chart_ref, title: artifact.title });
119
+ return receipt;
120
+ }
121
+ // Fallback path (stream listener unavailable): write now, but only when the turn is idle.
122
+ const blocked = await input.readiness?.(session);
123
+ if (blocked !== undefined)
124
+ throw new Error(blocked);
102
125
  const publisher = input.chartEvents();
103
126
  if (publisher === undefined) {
104
127
  throw new Error('图表交付通道不可用(sessions / sessionProjections 解析不到):图表已落盘,可在 workspace 打开 chart.html');
105
128
  }
106
- const { selected, failed } = selectArtifacts(input, session, refs, taskId);
107
- const receipt = { presented: [], failed };
108
- for (const artifact of selected) {
109
- const accepted = publisher.publish({
110
- ownerSessionId: session.id,
111
- chart_id: artifact.chart_id,
112
- title: artifact.title,
113
- html_path: artifact.html_path,
114
- });
115
- if (accepted)
129
+ const accepted = publisher.publishMany !== undefined
130
+ ? publisher.publishMany(session.id, inputs)
131
+ : inputs.every((item) => publisher.publish(item));
132
+ if (accepted) {
133
+ for (const artifact of selected)
116
134
  receipt.presented.push({ chart_ref: artifact.chart_ref, title: artifact.title });
117
- else
118
- receipt.failed.push({ chart_ref: artifact.chart_ref, reason: `本轮登记已达 ${MAX_CHARTS_PER_TURN} 张上限或回合已关闭,未写入交付行` });
135
+ }
136
+ else {
137
+ for (const artifact of selected) {
138
+ receipt.failed.push({ chart_ref: artifact.chart_ref, reason: `本轮登记已达 ${MAX_CHARTS_PER_TURN} 次上限或回合已关闭,未写入交付行` });
139
+ }
119
140
  }
120
141
  return receipt;
121
142
  },
@@ -364,7 +364,7 @@ export function registerDatasetTools(ctx, store, options = {}) {
364
364
  withIsoTimeColumns(result, prepared.axis);
365
365
  return result;
366
366
  }),
367
- tool('describe_dataset', `读取一份 Dataset 的**默认入口**:一次调用完成 inspect(元数据与可读性/形状)、profile(宿主算基础质量与四类事实,写入 profile_ref)与受控 query(可选);原始 rows 不进入模型上下文。一次只处理一个 dataset_id——本轮有多份数据时,请在**同一条消息里一次发出多个调用**(本工具可并发执行,结果会一起返回),不要一份一份等。行集合返回:① 元数据 artifact_ref/capability/source_label/captured_at/retention_until 与 query_access.shape(array / envelope_item = 行集合;document = 顶层文档对象,只能 profile,queries 由宿主跳过);② profile 四类事实——statistics(count/sum/min/max/mean/分位数)、categories(distinct + 最多 5 个高频取值)、time_facts(覆盖范围与首行/末行取值;回答"最新值、区间涨跌"必须用它,不要用 min/max 代替)、structure(嵌套路径摘要);③ queries——每条的 QueryResult,或该条自己的 error(单条失败不影响其余)。columns_of_interest 只对这些列返回 statistics/categories/schema(并同样收窄 time_facts 的首末值投影,时间列永远保留),是收窄结果体积的唯一手段。结果超过 ${SAFE_RESULT_CHARS} 码点时宿主不返回超长载荷,而返回 {status:'too_large', profile_ref, columns, bytes, facts_bytes, queries_bytes, hint}:facts_bytes/queries_bytes 是体积归因(哪边大砍哪边),按 hint 重发一次即可——queries 大就先去掉 queries(聚合改单独走 query_dataset),facts 大就用 columns_of_interest 收窄列(或减少 queries,最多 ${MAX_DESCRIBE_QUERIES} 条)。单点复核仍可用 inspect_dataset / profile_dataset / query_dataset;不接受任何文件路径、脚本内容或自定义表达式。`, describeParametersSchema, describeResultSchema, async (args, exec) => describeDataset({
367
+ tool('describe_dataset', `读取一份 Dataset 的**默认入口**:一次调用完成 inspect(元数据与可读性/形状)、profile(宿主算基础质量与四类事实,写入 profile_ref)与受控 query(可选);原始 rows 不进入模型上下文。一次只处理一个 dataset_id——本轮有多份数据时请并发调用本工具(PTC 在同一个 run_code 程序里用 Promise.all,native 在同一条消息里一次发出多个调用),不要一份一份等。行集合返回:① 元数据 artifact_ref/capability/source_label/captured_at/retention_until 与 query_access.shape(array / envelope_item = 行集合;document = 顶层文档对象,只能 profile,queries 由宿主跳过);② profile 四类事实——statistics(count/sum/min/max/mean/分位数)、categories(distinct + 最多 5 个高频取值)、time_facts(覆盖范围与首行/末行取值;回答"最新值、区间涨跌"必须用它,不要用 min/max 代替)、structure(嵌套路径摘要);③ queries——每条的 QueryResult,或该条自己的 error(单条失败不影响其余)。columns_of_interest 只对这些列返回 statistics/categories/schema(并同样收窄 time_facts 的首末值投影,时间列永远保留),是收窄结果体积的唯一手段。结果超过 ${SAFE_RESULT_CHARS} 码点时宿主不返回超长载荷,而返回 {status:'too_large', profile_ref, columns, bytes, facts_bytes, queries_bytes, hint}:facts_bytes/queries_bytes 是体积归因(哪边大砍哪边),按 hint 重发一次即可——queries 大就先去掉 queries(聚合改单独走 query_dataset),facts 大就用 columns_of_interest 收窄列(或减少 queries,最多 ${MAX_DESCRIBE_QUERIES} 条)。单点复核仍可用 inspect_dataset / profile_dataset / query_dataset;不接受任何文件路径、脚本内容或自定义表达式。`, describeParametersSchema, describeResultSchema, async (args, exec) => describeDataset({
368
368
  store,
369
369
  session: authorizedSession(ctx, exec, 'describe_dataset', DATASET_READING_PRESET_IDS),
370
370
  request: normalizeDescribeArgs(args),
@@ -1,9 +1,19 @@
1
1
  /**
2
- * `tools/present` row: present_charts only. The root Agent's final-turn delivery step — the
3
- * auto-mode chart pipeline returns refs upward and the main Agent redeems them here right before
4
- * writing the closing answer (rationale: docs/dev/chart-delivery-events.md §6.3).
2
+ * `tools/present` row: present_charts + the closing-answer delivery listener.
3
+ *
4
+ * The model only records which charts belong to the answer. The actual first-party
5
+ * `deliverables/presented` append happens in the terminal assistant-stream chunk, before the closing
6
+ * message commits, gated on an idle root (no actionable inbox input, no running direct children). That is the only
7
+ * point where "the final answer" is knowable, so early/no `present_charts` calls cannot strand the
8
+ * card in a folded process turn (rationale: docs/dev/chart-delivery-events.md §6.3).
5
9
  */
6
10
  import type { Context } from '@deepseek-ai/cordis';
11
+ import type { SessionLike } from './data-collector/store.js';
7
12
  export declare const name = "capital-present-tools";
8
13
  export declare const inject: string[];
14
+ /**
15
+ * Fallback only: when the stream listener never fires, immediate delivery still has to avoid a
16
+ * pending turn. The normal path defers to the closing stream chunk and does not consult this.
17
+ */
18
+ export declare function chartDeliveryBlocker(ctx: Context, session: SessionLike): Promise<string | undefined>;
9
19
  export declare function apply(ctx: Context): void;
@@ -1,13 +1,114 @@
1
+ import { createChartDelivery } from './chart/delivery.js';
1
2
  import { createChartEventPublisher } from './chart/events.js';
2
3
  import { chartEventContextOf } from './chart/event-context.js';
3
4
  import { registerPresentChartsTool } from './chart/present-tool.js';
4
5
  import { capitalSharedRuntimeOf } from './chart-source-entry.js';
5
6
  export const name = 'capital-present-tools';
6
7
  export const inject = ['tools'];
8
+ /**
9
+ * Fallback only: when the stream listener never fires, immediate delivery still has to avoid a
10
+ * pending turn. The normal path defers to the closing stream chunk and does not consult this.
11
+ */
12
+ export async function chartDeliveryBlocker(ctx, session) {
13
+ const projections = ctx.get('sessionProjections');
14
+ try {
15
+ const inbox = projections?.stateOf(session, 'inbox');
16
+ // A settlement notice can race the closing answer; it must not block the card. Any relay or
17
+ // human input still means another model step is pending.
18
+ const pending = [...(inbox?.['next-turn'] ?? []), ...(inbox?.['next-step'] ?? [])]
19
+ .filter((message) => {
20
+ const source = message?.source;
21
+ return source?.kind !== 'subagent-settled';
22
+ }).length;
23
+ if (pending > 0) {
24
+ return `还有 ${pending} 条待处理消息(子 Agent 回传 / 结算):现在登记会让卡片留在被折叠的中间轮。`
25
+ + '等这些消息处理完、写最终答复的那一步再调用 present_charts(refs 记不清就传 task_id)。';
26
+ }
27
+ }
28
+ catch {
29
+ // Projection unavailable: delivery availability matters more than the guard.
30
+ }
31
+ const subagents = ctx.get('subagents');
32
+ if (typeof subagents?.listDescendants === 'function') {
33
+ try {
34
+ const rows = await subagents.listDescendants(session.id);
35
+ const running = rows.filter((row) => row?.kind === 'child' && row?.activity === 'running').length;
36
+ if (running > 0) {
37
+ return `还有 ${running} 个子 Agent 在运行:现在登记会让卡片留在被折叠的中间轮。`
38
+ + '等它们全部结算、写最终答复的那一步再调用 present_charts(refs 记不清就传 task_id)。';
39
+ }
40
+ }
41
+ catch {
42
+ // Subagent service unavailable: don't block delivery for a missing optional check.
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ function inboxOf(ctx) {
48
+ const projections = ctx.get('sessionProjections');
49
+ return (session) => {
50
+ try {
51
+ return projections?.stateOf(session, 'inbox');
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ };
57
+ }
7
58
  export function apply(ctx) {
8
59
  const shared = capitalSharedRuntimeOf(ctx);
60
+ const publisherOf = () => createChartEventPublisher(chartEventContextOf(ctx));
61
+ const eventCtx = ctx;
62
+ const delivery = createChartDelivery({
63
+ artifacts: shared.artifacts,
64
+ publish: (sessionId, inputs) => publisherOf()?.publishMany(sessionId, inputs) ?? false,
65
+ inboxOf: inboxOf(ctx),
66
+ });
67
+ // The preset row is applied in the root agent's scope: these events are the root's own.
68
+ const disposers = [];
69
+ if (typeof eventCtx.on === 'function') {
70
+ disposers.push(eventCtx.on('agent/assistant-stream', (...args) => {
71
+ const payload = args[0];
72
+ if (payload?.frame === undefined)
73
+ return;
74
+ delivery.acceptStream(payload.agent?.session, payload.frame);
75
+ }));
76
+ disposers.push(eventCtx.on('subagent/start', (...args) => delivery.acceptSubagentStart(args[0])));
77
+ disposers.push(eventCtx.on('subagent/end', (...args) => delivery.acceptSubagentEnd(args[0])));
78
+ }
79
+ // Session events are not agent-scoped: listen on the unscoped root and filter by payload.
80
+ const listen = (eventCtx.root ?? ctx);
81
+ if (typeof listen.on === 'function') {
82
+ disposers.push(listen.on('session/event', (...args) => {
83
+ const session = args[0];
84
+ const event = args[1];
85
+ if (event?.type !== 'user/message')
86
+ return;
87
+ const source = event.data?.source;
88
+ // The settlement notice is authoritative even when the lifecycle edge was missed; clearing
89
+ // here keeps the stream-time idle check from waiting on a child that is already done.
90
+ if (source?.kind === 'subagent-settled') {
91
+ if (typeof source.senderSessionId === 'string')
92
+ delivery.acceptSubagentEnd({ id: source.senderSessionId });
93
+ return;
94
+ }
95
+ if (source?.kind !== 'user')
96
+ return;
97
+ if (typeof session?.id !== 'string')
98
+ return;
99
+ delivery.acceptUserMessage(session.id, typeof event.time === 'number' ? event.time : Date.now());
100
+ }));
101
+ }
102
+ ctx.effect(() => () => {
103
+ for (const dispose of disposers) {
104
+ if (typeof dispose === 'function')
105
+ dispose();
106
+ }
107
+ }, 'capital-generation.chart-delivery()');
9
108
  registerPresentChartsTool(ctx, {
10
109
  artifacts: shared.artifacts,
11
- chartEvents: () => createChartEventPublisher(chartEventContextOf(ctx)),
110
+ chartEvents: publisherOf,
111
+ readiness: (session) => chartDeliveryBlocker(ctx, session),
112
+ delivery,
12
113
  });
13
114
  }
@@ -3,7 +3,7 @@
3
3
  * Known limit: DNS resolves per request but the IP is not pinned to the connection,
4
4
  * so a rebinding window remains (same trade-off as dsh-search-first).
5
5
  */
6
- export declare const LOCAL_FETCH_CLIENT_VERSION = "2.9.1";
6
+ export declare const LOCAL_FETCH_CLIENT_VERSION = "2.9.2";
7
7
  export declare const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
8
8
  export type LocalFetchErrorCode = 'ABORTED' | 'TIMEOUT' | 'INVALID_URL' | 'BLOCKED_URL' | 'DNS' | 'REDIRECT' | 'HTTP' | 'TOO_LARGE' | 'UNSUPPORTED_CONTENT_TYPE';
9
9
  export declare class LocalFetchError extends Error {
@@ -4,7 +4,7 @@
4
4
  * so a rebinding window remains (same trade-off as dsh-search-first).
5
5
  */
6
6
  import { htmlToMarkdown, stripInvisibleText } from './html-markdown.js';
7
- export const LOCAL_FETCH_CLIENT_VERSION = '2.9.1';
7
+ export const LOCAL_FETCH_CLIENT_VERSION = '2.9.2';
8
8
  export const LOCAL_FETCH_MAX_URL_LENGTH = 2048;
9
9
  export class LocalFetchError extends Error {
10
10
  code;
@@ -35,7 +35,7 @@ export interface WindCallResult {
35
35
  code?: WindErrorCode;
36
36
  }
37
37
  /** Must match `package.json` version; a test pins the two together. */
38
- export declare const WIND_CLIENT_VERSION = "2.9.1";
38
+ export declare const WIND_CLIENT_VERSION = "2.9.2";
39
39
  export interface WindClient {
40
40
  callTool(toolName: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<WindCallResult>;
41
41
  }
@@ -19,7 +19,7 @@ export const WIND_SERVER_ENDPOINTS = {
19
19
  analytics_data: 'https://mcp.wind.com.cn/vserver_analytics_data/mcp/',
20
20
  };
21
21
  /** Must match `package.json` version; a test pins the two together. */
22
- export const WIND_CLIENT_VERSION = '2.9.1';
22
+ export const WIND_CLIENT_VERSION = '2.9.2';
23
23
  function errorText(error) {
24
24
  return error instanceof Error ? error.message : String(error);
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/capital-generation",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "description": "Capital Generation:面向中国散户的 AI 投资助手插件(自动模式 Agent Preset)",
5
5
  "icon": "assets/cg-icon.png",
6
6
  "author": "v587d",
@@ -140,7 +140,8 @@
140
140
  "contract:probe": "node scripts/contract-probe.mjs",
141
141
  "docs:capabilities": "npm run build && node scripts/gen-capability-table.mjs",
142
142
  "verify:sessions": "node scripts/verify-sessions.mjs",
143
- "audit:tool-errors": "node scripts/audit-tool-errors.mjs"
143
+ "audit:tool-errors": "node scripts/audit-tool-errors.mjs",
144
+ "ptc:report": "node scripts/ptc-session-report.mjs"
144
145
  },
145
146
  "peerDependencies": {
146
147
  "@deepseek-ai/cordis": "^4.0.2",
@@ -148,15 +148,32 @@
148
148
  (新工具必须先有族行带它、行契约同步);更新 `AGENTS.md`、`docs/dev/data-roles.md` 与
149
149
  `test/persona.test.mjs` 的预留期断言。
150
150
 
151
- ## 5.1 shell 与文件口:开 read、保留 bash、不给写(2026-10-15 用户决策)
151
+ ## 5.1 shell 与文件口:主 Agent 保留 shell,data_junior 用 PTC(2026-10-15 / 2026-10-23)
152
152
 
153
153
  - **动机**:`query_dataset` 只有 `count/min/max/avg/sum`,变化量 / 增幅 / CAGR / 比值这类派生
154
154
  指标没有宿主工具;模型只能拿 `time_facts.first/last` 心算——那正是 §1.3 那次 41,961 字符
155
- 时间戳心算的同型浪费。所以给 data_junior 一个纯计算兜底。**不是**给它开取数通道。
155
+ 时间戳心算的同型浪费。所以给 data_junior 一个程序面。**不是**给它开取数通道。
156
156
  - **2026-10-15 起主 Agent 也持有 shell 与 `read`**(用户决策「开 read、同时保留 bash」,推翻
157
157
  2026-09-30 的"主 Agent 不给 shell"):主 Agent 需要 `read` 读 skill 分册与工作区文件
158
158
  (此前只能 `bash cat`,能力等价但绕远),shell 保留纯计算兜底。`ROOT_AGENT_DENIED_TOOLS`
159
159
  里的 `bash` / `pwsh` 随之移出,名单改为出图三件套 + 十三个出网工具 + `write` / `edit`。
160
+
161
+ ### 5.1.1 PTC:只给 data_junior,主 Agent 仍是 native
162
+
163
+ - **一行怎么落**:junior preset 挂 `@deepseek-ai/dsh-agent-tool-presentation`(`mode: ptc`),
164
+ 同时删掉 `tool-bash` / `tool-pwsh`。宿主 `ptc-runtime` 由 `dsh-base` 提供,preset 写不进去;
165
+ `smoke:boot` 同时断言 presentation 行 `fiberState=2` 与 `ctx.get('ptcRuntime')` 在场。
166
+ - **为什么删 shell 而不是并存**:PTC SDK 在可见 `bash` 时会主动塞一条 `tools.bash({command:'pwd'})`
167
+ 示例,并存等于官方提示词把模型往 shell 引;junior 只需要一个执行面。主 Agent 仍是 native,
168
+ 拿到的是原生 shell,根侧 `restrict()` 不能点名保留名 `run_code`(只在部署级 `tools.mode`
169
+ 被改成 `ptc` 时才会出现,本仓只允许 preset 行)。
170
+ - **省的是回合与抄写,不是安全升级**:`run_code` 里的 Node 仍能 `import('node:fs/promises')` /
171
+ `fetch` / `child_process`;宿主沙箱只拦写。`# PROGRAM DISCIPLINE` 与
172
+ `capital-data-query` §3.3 是纪律,不是边界;要硬隔离只能换容器 / VM / 远端 runtime。
173
+ - **结果体积不变**:describe 仍是 7000 码点小回执 + hint;`run_code` 外层只过宿主 pruner(8192),
174
+ 本仓不新设第二道闸门。per-call(每次新进程)是默认,不使用 `persistent-in-memory`。
175
+ - **回滚**:整颗改动(composition 行 + persona + 三册 skill + guard)是一个 commit;回滚后
176
+ junior 回到 bash + native,恢复两行 YAML 与 `# BASH DISCIPLINE` 即可。
160
177
  - **文件写口不开,但"不开"靠的是调用级闸门,不是行配置**:根 preset 那行 `read: true /
161
178
  write: false / edit: false` 只决定**本层变体**——2026-10-19 实测手开 Capital 里 `write` 仍在工具表
162
179
  中且真的落盘,缺席不等于移除(`ScopedLayers.merge` 用 global 打底,账本 L33)。所以拦下写口的只有一颗:全局 `tools.guard()`
@@ -167,25 +184,26 @@
167
184
  本来就有正路(`store.writeContext` 等工具实现)。角色一侧同一条判据:本插件的六颗 preset 一律
168
185
  不许直接写文件,原始 rows 也永不出工具层。
169
186
  - **平台成对挂载**:Windows 上 host 层挂的是 `pwsh-sandbox`,没有 `bash-sandbox`;只挂
170
- `tool-bash` 会让名为 bash 的工具实际执行 PowerShell。所以两行互补 `disabled: !!js`。0.2.1 起
171
- 这份 YAML 里不再出现工具名,shell 名改为在 `src/agents/role-tool-policy.ts` 按平台取一颗
172
- (`SHELL_TOOL`)进 data_junior 的清单——取错那颗会整批 restrict 报错、该角色完全不收敛,
173
- 所以 `test/bash-guard.test.mjs` 另钉一条"preset 挂的每一行 shell,闸门与角色工具面都必须认",
187
+ `tool-bash` 会让名为 bash 的工具实际执行 PowerShell。所以两行互补 `disabled: !!js`。
188
+ 0.2.1 起这份 YAML 里不再出现工具名,shell 名在 `src/agents/root-tool-policy.ts` 的
189
+ `SHELL_TOOL_NAMES` 定义一份,`bash-guard.ts` 再并进 `run_code`;
190
+ `test/bash-guard.test.mjs` 钉"preset 挂的每一行 shell,闸门都必须认",
174
191
  成对挂载与后台执行那一格仍由 `test/persona.test.mjs`「shell 行:平台成对挂载……」看着。
192
+ 2026-10-23 起 junior 不再挂这两行,成对挂载只剩主 Agent。
175
193
  - **不给后台执行**:`enableRunInBackground: false`。本 preset 不挂 `tool-jobs`,开着后台只会让
176
194
  模型拿到一个永远读不回来的 jobId(本 preset 压根没挂 `tool-jobs` 那一行)。
177
- - **shell 闸门只剩一层,且不是安全边界**(`src/agents/bash-guard.ts`):拦掉
195
+ - **执行面闸门只剩一层,且不是安全边界**(`src/agents/bash-guard.ts`):拦掉
178
196
  `sandbox_permissions` / `justification`——被委派会话的审批策略由框架固定为 `never`,升级必然
179
197
  失败,而且模型会收到「the user rejected…」这种把系统拒绝说成用户拒绝的文案;被委派或身份
180
198
  不可考的调用方 fail closed。主 Agent 的升级走正常 ask 审批(2026-10-15 前的"只对被委派子会话
181
- 开放"一层随决策移除,断言迁移见 `test/bash-guard.test.mjs`)。**刻意不解析 `command`**:本仓
182
- 实测沙箱只拦写、不拦读(`~/.dsh/.credentials.yaml` 与 `sessions/**` 整机可读)、不拦网络
183
- (`curl` 直连 200),而正则挡不住 `node -e` / `python3 -c`——那正是引入 bash 的目的。禁区与
184
- 出网纪律是 data_junior persona 的 `# BASH DISCIPLINE` 软约束。
199
+ 开放"一层随决策移除,断言迁移见 `test/bash-guard.test.mjs`)。**刻意不解析 `command` / `code`**:
200
+ 本仓实测沙箱只拦写、不拦读(`~/.dsh/.credentials.yaml` 与 `sessions/**` 整机可读)、不拦网络
201
+ (`curl` 直连 200),而正则挡不住 `node -e` / `python3 -c` / 动态 `import()`——那正是引入程序面的
202
+ 目的。禁区与出网纪律是 data_junior persona 的 `# PROGRAM DISCIPLINE` 软约束。
185
203
  - **写边界收不窄到会话**:`store.writeContext()` 用**调用方 session** 的 policy 且要求
186
204
  `mode ∈ {workspace-write, danger-full-access}`,而 `describe_dataset` 会用 data_junior 的会话写
187
- `profile.json`;把它的会话钉成 `read-only` 会直接打断 describe_dataset。bash 与 Dataset 管线
188
- 共用同一把尺子(session policy),所以模型侧写口只能靠上一条的调用期闸门收。
205
+ `profile.json`;把它的会话钉成 `read-only` 会直接打断 describe_dataset。`run_code` / bash 与
206
+ Dataset 管线共用同一把尺子(session policy),所以模型侧写口只能靠上一条的调用期闸门收。
189
207
  - **要真正收紧**只有两条路:换掉 `ctx.shell`(容器 / 微 VM / 远端执行器),或给 dsh 进程做出网
190
208
  限制。两者都不是 `ctx.sandbox` 能提供的。
191
209
 
@@ -133,24 +133,24 @@
133
133
  (不做投资判断、不输出买卖建议),也不改写原始 Dataset。你是主 Agent 的直接子 Agent,
134
134
  与 data_collector 是兄弟:你不会被 data_collector 创建或指挥,互通一律经主 Agent 中继;
135
135
  visualization_specialist 是你的异步子 Agent,不向主 Agent 直接发消息。
136
- 就本插件工具面而言,你无外部行情 API、无网页检索、无凭据读取能力;宿主若仍暴露 web_search / web_fetch 等通用工具,也不得用于取数或查资料。不索取或保存账户密码。
136
+ 工具面没有外部行情 API、网页检索与凭据入口;run_code 里的 Node 也不得用于取数、联网或读凭据;宿主若暴露 web_search / web_fetch 也不得用于取数或查资料。不索取或保存账户密码。
137
137
 
138
138
  # DUTIES
139
- 0. 第一步先加载协议:调用 Dataset 工具前先 skill capital-data-profile;要写 query_request
140
- 或做派生算术再加 skill capital-data-query;描述完成
141
- (describe_dataset / profile_dataset)后无条件加载 skill capital-visualization-protocol,
139
+ 0. 第一步先加载协议:Dataset 工具前先 skill capital-data-profile(必须 tools.skill,别直调);写 query_request
140
+ 或做算术再加 skill capital-data-query;描述后无条件加载 skill capital-visualization-protocol,
142
141
  再选择 gate。载荷样例、事实读法、data_gap 与 QuerySpec 细则都在 skill 里。
143
- 1. 启动自检:缺少 describe_dataset(或 inspect_dataset/profile_dataset/query_dataset)时 send_message 回告;正常时登记 data_junior_ready。
142
+ 1. 启动自检:先看 SDK 有无 tools.describe_dataset,没有就 send_message 回告;正常时登记 data_junior_ready。
144
143
 
145
144
  2. 只用受控工具:dataset_ids 按主 Agent 转发的原样使用;一切路径都是 opaque
146
- artifact_ref(workspace://...),由受控工具解析;不接受路径、脚本或自定义表达式。
147
- 3. 默认用 describe_dataset 读 Dataset(一次一份;多份在**同一条消息**里一次发完,可并发);inspect_dataset/profile_dataset/query_dataset 只用于单点复核。优先用 profile 的 time_facts 与 structure;query_dataset 只做受控聚合,你取不到原始行。
145
+ artifact_ref(workspace://...),由受控工具解析;Dataset 工具只收 dataset_id 与 QuerySpec,
146
+ run_code 只调度工具与算术,不得读文件。
147
+ 3. 默认用 describe_dataset 读 Dataset(多份在同一 run_code 程序里 Promise.all,见 PROGRAM DISCIPLINE);inspect_dataset/profile_dataset/query_dataset 只用于单点复核。优先用 profile 的 time_facts 与 structure;query_dataset 只做受控聚合,你取不到原始行。
148
148
 
149
149
  4. 数据缺口:先分清「数据里确实没有」与「那份数据还没取」。后者不要写成「无法确认」,
150
150
  立即用 send_message 向父 Agent 发一次 data_gap(task_id / dataset_id / need / reason /
151
151
  blocking),同一条缺口只发一次,这是请求不是结论。你不得自行取数、也不得直接找
152
152
  data_collector(取数由主 Agent 转交);不要为了填空编造能力名。
153
- 5. 回传:逐份回传结构化载荷(type / task_id / dataset_id / profile_ref
153
+ 5. 回传:用 tools.send_message 逐份回传结构化载荷(type / task_id / dataset_id / profile_ref
154
154
  或 query_result),字段按工具返回原样填入——字段名、数值、日期一律不改写;可附表或
155
155
  摘要,禁止只回传 markdown 汇总。失败回传 profile_failed / query_failed 与 error/code,
156
156
  绝不编造统计或查询结果;失败不影响原始 Dataset,可按主 Agent 指示重试。
@@ -163,12 +163,11 @@
163
163
  任何时间窗/时间戳换算一律交给宿主:时间窗优先 resolve_data_time_range(传 dataset_id + period),
164
164
  拿 bounds 填 filter;绝不自行把日期算成毫秒,看到 epoch 要加减乘除才能得出结论,就是走错了路。
165
165
 
166
- # BASH DISCIPLINE
167
- bash 只做纯计算兜底,只用于算变化量 / 增幅 / CAGR / 比值 / 单位换算这类派生指标,
168
- 不是取数通道:不联网、不跑 curl 或 wget 取任何外部数据,数据缺失走 data_gap 回告主 Agent。
169
- 不读文件、不碰 capital-data 下的任何路径(那是原始行与 Dataset 的边界)。
170
- 输出只留结论数字(原始行与中间数据不进回传),并注明算法与口径。
171
- 输入只敲标量(≤20 个)、逐数核对再算,不整行复制。
166
+ # PROGRAM DISCIPLINE
167
+ 调用一律写进 run_code;skill 也走 tools.skill,console.log(s.content),只 await 等于没读。
168
+ 字段直接取返回值,不手抄;变化量/增幅/CAGR 在程序里算。
169
+ 多份 describe 用 Promise.all,只返回结论;回传用 tools.send_message,不用原生名。
170
+ 不读文件、不碰 capital-data、不 fetch、不申请升级(sandbox_permissions);缺数据发 data_gap。
172
171
 
173
172
  - id: agent-instructions
174
173
  name: '@deepseek-ai/dsh-agent-instructions'
@@ -185,6 +184,12 @@
185
184
  - id: tool-skill
186
185
  name: '@deepseek-ai/dsh-tool-skill'
187
186
 
187
+ # PTC: presentation is preset-scoped; the host runtime (dsh-base) stays per-call.
188
+ - id: tool-presentation
189
+ name: '@deepseek-ai/dsh-agent-tool-presentation'
190
+ config:
191
+ mode: ptc
192
+
188
193
  # ── compaction: both rows must live in one realm; thresholdChars 8192 is the instance-wide single value ──
189
194
  - id: compaction
190
195
  name: cordis:group
@@ -206,19 +211,6 @@
206
211
  headChars: 4096
207
212
  tailChars: 1024
208
213
 
209
- - id: tool-bash
210
- name: '@deepseek-ai/dsh-tool-bash'
211
- disabled: !!js process.platform === 'win32'
212
- config:
213
- # No tool-jobs row here: a background run would hand the model a jobId it can never read back.
214
- enableRunInBackground: false
215
-
216
- - id: tool-pwsh
217
- name: '@deepseek-ai/dsh-tool-pwsh'
218
- disabled: !!js process.platform !== 'win32'
219
- config:
220
- enableRunInBackground: false
221
-
222
214
  - id: tool-subagent-control
223
215
  name: '@deepseek-ai/dsh-tool-subagent-control'
224
216
 
@@ -511,7 +503,7 @@
511
503
  # OUTPUT CONVENTIONS
512
504
  关键指标与截止用表格;子 Agent 回传按原样引用。
513
505
  图表由 data_junior 的可视化 gate 统一出品:你不画图、不评价图;派的时机与收窄见下方「数据调度纪律」。
514
- 图表交付(硬):收到 chart_refs 后,**写最终答复前先调用一次 present_charts**(一次传全部 ref;refs 丢了就传 task_id),它把图表登记为最终答复的交付物(答复末尾的「交付」行);不要在过程轮调用、不要分批、正文不要提图表、不在正文里罗列图表文件 / 路径 / HTML。图表是呈现不是分析:不用图推断数字,数值结论一律以 profile / query 回传为准。
506
+ 图表交付:收到 chart_refs 后调用一次 present_charts(一次传全部 ref;refs 丢了就传 task_id)——它只记录清单,宿主会在最终答复前写入答复末尾的「交付」行,提前调用不会落进中间轮。正文不要提图表、不在正文里罗列图表文件 / 路径 / HTML。图表是呈现不是分析:不用图推断数字,数值结论一律以 profile / query 回传为准。
515
507
  最终答(硬):必须自足——结论、关键证据与来源标注、局限与风险、观察 / 验证 / 防守选项;归因准确。**禁止“分析已完成 / 结论见上 / 如上”这类指针式收尾**:界面只展示最后一轮答复、前面轮次会被折叠,指针句会让用户什么也看不到。被通知唤醒且无新增信息时:不输出可见正文(让上一条完整结论成为最后一轮),或重发一份自足总结;绝不写指针句。
516
508
 
517
509
  # RULES & DISCIPLINE
@@ -608,7 +600,7 @@
608
600
  不得冒充行情证据或补位缺失能力。不支持的频率如实披露,用已有粒度降级,不编造。
609
601
 
610
602
 
611
- # Shell rows: bash/pwsh pair per platform, shared with data_junior; boundaries in ./README.md §5.1.
603
+ # Shell rows: bash/pwsh pair per platform, main Agent only (junior uses PTC `run_code`); README §5.1.
612
604
  - id: tool-bash
613
605
  name: '@deepseek-ai/dsh-tool-bash'
614
606
  disabled: !!js process.platform === 'win32'
@@ -642,7 +634,6 @@
642
634
  provider: spawn
643
635
  toolName: subagent_data_junior
644
636
  agentPreset: capital-data-junior
645
- # Platform-selected shell name; must match the shell row above (restrict() fails on unknown names).
646
637
  - id: tool-subagent-web-retriever
647
638
  name: '@deepseek-ai/dsh-tool-subagent'
648
639
  config:
@@ -15,6 +15,7 @@ description: Use when reading a Dataset back — describe_dataset as the default
15
15
 
16
16
  `data_junior` 读一份 Dataset 用 **`describe_dataset`**(一次调用完成 inspect + profile + 可选 queries),
17
17
  **不要再走 `inspect_dataset → profile_dataset → query_dataset` 三步**——那三步只保留给单点复核。
18
+ `run_code` 与 `Promise.all` 的执行方式只对 data_junior 生效;主 Agent 只读事实块与回传格式。
18
19
 
19
20
  ```json
20
21
  {
@@ -27,8 +28,8 @@ description: Use when reading a Dataset back — describe_dataset as the default
27
28
  }
28
29
  ```
29
30
 
30
- - **一次一份**:一次调用只传一个 `dataset_id`。本轮有多份数据时,**在同一条 assistant 消息里一次发完**
31
- 多个调用(宿主会并发执行,结果一起返回),不要一份一份等。
31
+ - **一次一份**:一次调用只传一个 `dataset_id`。本轮有多份数据时,在同一个 `run_code` 程序里用
32
+ `Promise.all` 并发 describe;结果留在程序里,只把结论 `return`。不要一份一份等。
32
33
  - 返回 = inspect 元数据(`artifact_ref` / `capability` / `source_label` / `captured_at` / `retention_until` /
33
34
  `query_access.shape`)+ profile 四类事实(`statistics` / `categories` / `time_facts` / `structure`)+
34
35
  `profile_ref` + 每条 query 的 `result`,或该条自己的 `error`(单条失败不影响其余)。
@@ -36,8 +37,9 @@ description: Use when reading a Dataset back — describe_dataset as the default
36
37
  - `columns_of_interest` 只对这些列返回 `statistics` / `categories` / `schema`(并同样收窄 `time_facts` 的首末值投影;时间列永远保留),是收窄结果体积的唯一手段。
37
38
  - **体积闸门**:结果超过 7000 码点时返回
38
39
  `{"status":"too_large","profile_ref":"...","columns":[...],"hint":"..."}`,而不是超长载荷。
39
- 按 hint 用 `columns_of_interest`(或减少 `queries`,一次最多 8 条)重发一次;文档型按 hint 直接调
40
- `profile_dataset`。**看到 `too_large` 不是失败**——它带着完整列名,用来让你收窄。
40
+ 在同一个程序里按 hint 用 `columns_of_interest`(或减少 `queries`,一次最多 8 条)重发一次;文档型按
41
+ hint 直接调 `profile_dataset`。**看到 `too_large` 不是失败**——它带着完整列名,用来让你收窄;
42
+ 不要把它当异常、也不要另起一轮。
41
43
  - 单点复核(只看元数据 / 只要一份完整 profile / 补一条查询)仍可用 `inspect_dataset`、`profile_dataset`、
42
44
  `query_dataset`。
43
45
 
@@ -153,6 +155,9 @@ data_junior 发现「问题需要的那份数据还没取」时(不是「数
153
155
  }
154
156
  ```
155
157
 
158
+ PTC 下发送是 `await tools.send_message({ agent_id: PARENT, message: JSON.stringify(payload) })`;
159
+ payload 的 JSON 形状不变,父 Agent id 仍来自系统提示。
160
+
156
161
  | 字段 | 规则 |
157
162
  |------|------|
158
163
  | `type` | 固定 `data_gap` |
@@ -1,46 +1,61 @@
1
1
  ---
2
2
  name: capital-data-query
3
- description: Use when aggregating a Dataset or computing a derived number — the controlled QuerySpec (filters, group_by, aggregates, calendar time values, error codes) and data_junior's bash channel for derived arithmetic. Volume 3 of the Capital data protocol; payloads in capital-data-protocol, profile facts and the time axis in capital-data-profile.
3
+ description: Use when aggregating a Dataset or computing a derived number — the controlled QuerySpec (filters, group_by, aggregates, calendar time values, error codes) and data_junior's run_code programs for derived arithmetic. Volume 3 of the Capital data protocol; payloads in capital-data-protocol, profile facts and the time axis in capital-data-profile.
4
4
  ---
5
5
 
6
6
  # Capital 数据协议 · 第三册:受控查询与派生算术
7
7
 
8
- 本册是数据协议的第三册(§3.3 bash 只做纯计算 / §4 受控查询)。时间轴与 `time_facts` 在第二册
8
+ 本册是数据协议的第三册(§3.3 run_code 只做派生算术 / §4 受控查询)。时间轴与 `time_facts` 在第二册
9
9
  `capital-data-profile` §3.2,载荷契约与重试纪律在第一册 `capital-data-protocol`。
10
10
 
11
- ## 3.3 bash:派生算术的兜底,不是第二条取数通道
11
+ ## 3.3 run_code:派生算术,不是第二条取数通道
12
12
 
13
- 只有 data_junior 有 `bash`。它存在的唯一理由是 `query_dataset` 的聚合只有
14
- `count / min / max / avg / sum`——**变化量、增幅、CAGR、比值、单位换算**这类派生指标没有宿主
15
- 工具,硬心算既慢又容易错。
13
+ 只有 data_junior 有 `run_code`;主 Agent 读本节是为了理解回传格式,不要照着执行。它存在的唯一
14
+ 理由是 `query_dataset` 的聚合只有 `count / min / max / avg / sum`——**变化量、增幅、CAGR、比值、
15
+ 单位换算**这类派生指标没有宿主工具。数字必须从工具返回值直接取,不手抄、不整行复制、不中转到
16
+ 别的语言。
16
17
 
17
- **可以做的**(拿手里已有的数字算):
18
+ **可以做的**(值来自工具返回对象,算完只返回结论)。第一段程序只读协议,`content` 必须打印出来,
19
+ 只 `await` 等于没读;也不要和 describe 混在同一段:
18
20
 
19
- ```bash
20
- node -e 'const a=98.2,b=120.5; console.log(((b-a)/a*100).toFixed(2))' # 区间涨幅
21
- python3 -c "print((120.5/98.2)**(252/60)-1)" # 年化
21
+ ```ts
22
+ const s = await tools.skill({ name: 'capital-data-profile' })
23
+ console.log(s.content)
22
24
  ```
23
25
 
24
- - **只敲最终参与计算的标量,一次 ≤20 个**:不整行/整表复制原始数字进脚本(2026-10-10 真机:
25
- 两次 bash 把整段聚合行逐数敲进去,转写出笔误又花 7.2K 字符发更正);从工具回执到脚本的每个
26
- 数字**逐一核对**再执行,算完抽查 1-2 个对得上来源。要算的数超过 20 个就拆几批。
26
+ ```ts
27
+ const r = await tools.describe_dataset({ dataset_id, task_id })
28
+ const a = r.time_facts.first.close_price
29
+ const b = r.time_facts.last.close_price
30
+ return { pct: ((b - a) / a) * 100, from: a, to: b }
31
+ ```
32
+
33
+ - `describe_dataset` 返回 `{status:'too_large', columns, hint}` 时,它在同一个程序里按 hint 用
34
+ `columns_of_interest` 重发一次;这是返回值不是异常,别用 `catch` 接、也别另起一轮。
35
+ - 多份 Dataset 用 `Promise.all` 并发 describe(只有 `describe_dataset` 声明了并发安全);
36
+ `query_dataset` 串行,不要混进同一批。某份可能失败时用 `Promise.allSettled`。
37
+ - 一次程序只做一段事:先 `console.log(s.content)` 读协议,再 describe/算数,最后回传;
38
+ 不要把整份 profile 或多个工具结果 `return` 回去(外层结果会过 8192 剪枝)。
39
+ - 回传与 `data_gap` 用 `await tools.send_message({ agent_id, message })`;它返回 `{messageId}`,
40
+ 不结束本轮。`agent_id` 取系统提示里的 parent agent id。
27
41
 
28
42
  **不许做的**(这是纪律,不是沙箱限制——沙箱只拦写,不拦读与网络):
29
43
 
30
44
  | 不许 | 为什么 | 正确做法 |
31
45
  |---|---|---|
32
- | 读 `capital-data/**`、`raw.json` | 原始行**永不**进入你的上下文;而且工具结果会被剪枝成 head+tail,读了也算错 | 数值走 `describe_dataset` / `query_dataset` 的统计与聚合 |
46
+ | 读 `capital-data/**`、`raw.json` | 原始行**永不**进入你的上下文;读了也算错 | 数值走 `describe_dataset` / `query_dataset` 的统计与聚合 |
33
47
  | 读 `~/.dsh/**`、`sessions/**` | 那是宿主凭据与全部会话日志 | 不碰;需要元数据用工具回执 |
34
48
  | 读 / 改 `preset/`、`src/`、`test/` | 那是装配与门禁本身 | 不碰 |
35
- | `curl` / `wget` / 任何网络请求、`git clone`、`npm i`、`pip install` | 你的角色没有网络能力,外部材料只经主 Agent 转交 `web_retriever` | 缺外部事实就在回传里写明缺口 |
36
- | 申请更高权限(`sandbox_permissions`) | 委派会话的审批策略被框架固定为 `never`,必然失败 | 不要试;缺数据发 `data_gap` |
49
+ | `fetch` / 任何网络请求、`git clone`、`npm i`、`pip install` | 你的角色没有网络职责,外部材料只经主 Agent 转交 `web_retriever` | 缺外部事实就在回传里写明缺口 |
50
+ | 申请更高权限(`sandbox_permissions`) | 委派会话的审批固定为 `never`,必然失败 | 不要试;缺数据发 `data_gap` |
37
51
 
38
52
  另外两条:
39
53
 
40
- - **输出只留结论数字**:bash 的工具结果同样会过剪枝器(>8192 字符中间段被剪掉),把中间过程
41
- 打印出来只会挤掉真正要看的东西;回传时注明算法与口径(如「按首末收盘价、未复权」)。
54
+ - **输出只留结论数字**:`run_code` 的外层结果同样会过剪枝器(>8192 字符中间段被剪掉),把中间
55
+ 过程或整份 profile 打印出来只会挤掉真正要看的东西;回传时注明算法与口径(如「按首末收盘价、
56
+ 未复权」)。只返回结论,不要 `return` 整份 profile。
42
57
  - **时间窗仍归宿主**:窗口边界一律用 `time_facts.windows` / `resolve_data_time_range`
43
- (`capital-data-profile` §3.2),不要用 `date` 命令自己造边界——口径错了数字就全错,而且没人会发现。
58
+ (`capital-data-profile` §3.2),不要在程序里把日期算成毫秒——口径错了数字就全错。
44
59
 
45
60
  `profile_dataset` 的统计项:数值字段的 count 与 sum(合计多少、覆盖多少条)、字符串字段的
46
61
  `distinct_count` 与高频取值、字段类型(observed / contract)、缺失值与显式 null、类型冲突、
@@ -44,4 +44,7 @@ description: Use when the user requests a complete research report or comprehens
44
44
 
45
45
  每个会影响判断的数值注明能力/来源、报告期或交易日及采集时间;材料结论给可追溯的原文链接,或文件标题、发布主体与页码(无法提供原文时明说)。不要用已实施分红或历史价格推断股价下限,不把同业倍数排序写成“便宜/贵”的充分证据。对缺失、冲突和数据延迟给出它们如何改变判断的解释;未评估清单仅列与本轮问题或结论有关的缺口。区分投资者可采取的验证步骤与交易指令;不承诺收益、不输出没有可复现模型支持的目标价、概率或胜率。
46
46
 
47
- **这一段就是最后一轮的唯一可见内容**:写之前先调用一次 `present_charts`(有 chart_refs 时),报告正文不罗列图表文件,也不从图上反推数字;写完不要再补一行“已完成/结论见上”把报告盖掉。
47
+ **这一段就是最后一轮的唯一可见内容**:写之前先调用一次 `present_charts`(有 chart_refs 时;refs 记不清就传 task_id 一次登记全部),报告正文不罗列图表文件,也不从图上反推数字;写完不要再补一行“已完成/结论见上”把报告盖掉。
48
+
49
+ `present_charts` 只记录本轮图表清单,宿主会在**最终答复之前**统一写入交付行;提前调用也安全,
50
+ 不必留到最后一刻。调用后继续把取证做完也可以——最终答复仍由宿主在收尾时附上图表。
@@ -122,7 +122,8 @@ ohlc
122
122
  prepare_chart_source({ dataset_id, task_id })
123
123
  ```
124
124
 
125
- 只使用工具返回的 `chart_source_ref`。不要自己构造 token、路径或 `raw.json` 地址。
125
+ data_junior 在 `run_code` 里调用 `tools.prepare_chart_source(...)`;specialist 是 native,
126
+ 照旧直调 `render_chart`,不经过 `run_code`。只使用工具返回的 `chart_source_ref`。不要自己构造 token、路径或 `raw.json` 地址。
126
127
 
127
128
  **token 有 15 分钟 TTL,同一 task_id + dataset_id 重复签发幂等返回同一个 token**:仍建议先定好
128
129
  要出哪几张图、每张用哪份 Dataset,再一次性签发(省往返);万一改了视图方案再签一次也无妨——
@@ -196,6 +197,9 @@ prepare_chart_source({ dataset_id, task_id })
196
197
 
197
198
  ## 4. 调用 render_chart
198
199
 
200
+ data_junior 只在 `run_code` 程序里用 `tools.subagent_visualization_specialist(...)` 创建 specialist;
201
+ specialist 是 native,照旧直调 `render_chart`。以下步骤属于 specialist。
202
+
199
203
  第一次调用前加载:
200
204
 
201
205
  ```text
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/watchlist",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
4
4
  "description": "自动模式的用户自选股(宿主级清单 + Fuyao 报价快照 + /capital-watchlist 路由 + 输入框菜单弹窗):由 @v587d/capital-generation 随包携带并作为 host 平面行挂载。",
5
5
  "license": "MIT",
6
6
  "type": "module",