@thincoder/core 0.9.1
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/abort-provenance.mjs +116 -0
- package/advisor/citations.mjs +139 -0
- package/advisor/compaction.mjs +174 -0
- package/advisor/convergence.mjs +80 -0
- package/advisor/history.mjs +77 -0
- package/advisor/loop.mjs +288 -0
- package/advisor/messages.mjs +299 -0
- package/advisor/notice.mjs +141 -0
- package/advisor/project-context.mjs +197 -0
- package/advisor/repos.mjs +150 -0
- package/advisor/run.mjs +190 -0
- package/advisor/truncate.mjs +57 -0
- package/advisor.mjs +281 -0
- package/agent/child-marks.mjs +24 -0
- package/agent/completion.mjs +145 -0
- package/agent/dispatch.mjs +493 -0
- package/agent/family-tools.mjs +174 -0
- package/agent/helpers.mjs +412 -0
- package/agent/post-turn.mjs +70 -0
- package/agent/record-results.mjs +174 -0
- package/agent/relay-prefix.mjs +39 -0
- package/agent/run-stages.mjs +244 -0
- package/agent/setup-reminders.mjs +199 -0
- package/agent/setup.mjs +234 -0
- package/agent/spawn-child.mjs +258 -0
- package/agent/suspension.mjs +240 -0
- package/agent/write-gate.mjs +87 -0
- package/agent-tools/advisor-async.mjs +481 -0
- package/agent-tools/advisor-settle.mjs +240 -0
- package/agent-tools/advisor.mjs +280 -0
- package/agent-tools/async-discard.mjs +143 -0
- package/agent-tools/async-settle.mjs +299 -0
- package/agent-tools/batch-segment.mjs +265 -0
- package/agent-tools/child-permission.mjs +45 -0
- package/agent-tools/consult.mjs +471 -0
- package/agent-tools/design-token.mjs +117 -0
- package/agent-tools/digest-budget.mjs +76 -0
- package/agent-tools/eng.mjs +102 -0
- package/agent-tools/escalate-async.mjs +302 -0
- package/agent-tools/goal.mjs +119 -0
- package/agent-tools/panel-blocks.mjs +24 -0
- package/agent-tools/parent-channel.mjs +231 -0
- package/agent-tools/plan.mjs +86 -0
- package/agent-tools/read-history.mjs +309 -0
- package/agent-tools/recent-changes.mjs +24 -0
- package/agent-tools/review-facts.mjs +31 -0
- package/agent-tools/settings.mjs +268 -0
- package/agent-tools/skill.mjs +63 -0
- package/agent-tools/spawn-gates.mjs +109 -0
- package/agent-tools/subagent-actions.mjs +496 -0
- package/agent-tools/subagent-async.mjs +456 -0
- package/agent-tools/subagent-panel.mjs +160 -0
- package/agent-tools/subagent-run.mjs +208 -0
- package/agent-tools/subagent-scheduler.mjs +446 -0
- package/agent-tools/subagent-spawn.mjs +478 -0
- package/agent-tools/subagent.mjs +419 -0
- package/agent-tools/task.mjs +87 -0
- package/agent-tools/timer.mjs +46 -0
- package/agent-tools/verify.mjs +295 -0
- package/agent-tools.mjs +23 -0
- package/agent.mjs +430 -0
- package/auto-think.mjs +115 -0
- package/compress-form.mjs +24 -0
- package/config-io.mjs +277 -0
- package/config-migrate.mjs +178 -0
- package/config-presets.mjs +49 -0
- package/config.mjs +419 -0
- package/context.mjs +495 -0
- package/conventions.mjs +223 -0
- package/embedding.mjs +120 -0
- package/escape.mjs +152 -0
- package/expand-home.mjs +16 -0
- package/explore-distill.mjs +152 -0
- package/generate-title.mjs +123 -0
- package/git/checkpoint.mjs +448 -0
- package/git/gitmem.mjs +100 -0
- package/history-window.mjs +179 -0
- package/hooks.mjs +108 -0
- package/i18n.mjs +106 -0
- package/index-bin.mjs +48 -0
- package/index-discover.mjs +176 -0
- package/ledger-cmd.mjs +209 -0
- package/ledger-db.mjs +86 -0
- package/ledger-surface.mjs +76 -0
- package/ledger.mjs +202 -0
- package/log.mjs +195 -0
- package/manifest.mjs +338 -0
- package/markdown.mjs +106 -0
- package/mcp/helpers.mjs +51 -0
- package/mcp/transport-http.mjs +248 -0
- package/mcp/transport-stdio.mjs +140 -0
- package/mcp/transport-ws.mjs +122 -0
- package/mcp.mjs +295 -0
- package/memory/code-index.mjs +219 -0
- package/memory/code-sync.mjs +427 -0
- package/memory/core.mjs +318 -0
- package/memory/delete.mjs +242 -0
- package/memory/docs.mjs +431 -0
- package/memory/file-walk.mjs +109 -0
- package/memory/origin.mjs +24 -0
- package/memory/scan.mjs +177 -0
- package/memory/schema.mjs +460 -0
- package/memory.mjs +21 -0
- package/model-ref.mjs +66 -0
- package/model-specs.mjs +277 -0
- package/package.json +32 -0
- package/peer-domains.mjs +265 -0
- package/peer-instances.mjs +178 -0
- package/permission.mjs +79 -0
- package/process-probe.mjs +315 -0
- package/prompt-files.mjs +113 -0
- package/prompt-overlays.mjs +78 -0
- package/prompts/advisor-design.md +43 -0
- package/prompts/advisor-round1.md +41 -0
- package/prompts/advisor-round2.md +46 -0
- package/prompts/advisor-round3.md +42 -0
- package/prompts/common.md +158 -0
- package/prompts/consult-base.md +19 -0
- package/prompts/discipline-engineering.md +123 -0
- package/prompts/discipline-normal.md +206 -0
- package/prompts/persona-coder.md +21 -0
- package/prompts/persona-eng-coder.md +41 -0
- package/prompts/persona-eng-designer.md +80 -0
- package/prompts/persona-engineering.md +160 -0
- package/prompts/persona-explore.md +15 -0
- package/prompts/persona-normal.md +35 -0
- package/prompts/persona-plan.md +27 -0
- package/provider/anthropic.mjs +225 -0
- package/provider/core.mjs +491 -0
- package/provider/errors.mjs +101 -0
- package/provider/google.mjs +257 -0
- package/provider/index.mjs +7 -0
- package/provider/list-models.mjs +163 -0
- package/provider/normalize.mjs +81 -0
- package/provider/rate.mjs +168 -0
- package/provider/responses.mjs +495 -0
- package/provider/retry.mjs +88 -0
- package/provider/sse.mjs +264 -0
- package/provider/wait-status.mjs +59 -0
- package/proxy.mjs +274 -0
- package/rules.mjs +53 -0
- package/session-gc.mjs +248 -0
- package/session-guard.mjs +59 -0
- package/session-lifecycle.mjs +305 -0
- package/session-migrate.mjs +48 -0
- package/session-rename.mjs +38 -0
- package/session-segments.mjs +99 -0
- package/session-slot-write.mjs +168 -0
- package/session-slots-manifest.mjs +264 -0
- package/session-slots.mjs +298 -0
- package/session-store.mjs +441 -0
- package/session.mjs +244 -0
- package/skills.mjs +234 -0
- package/text-budget.mjs +79 -0
- package/token-ttl.mjs +285 -0
- package/tool-docs/apply_patch.md +15 -0
- package/tool-docs/bash.md +38 -0
- package/tool-docs/delete.md +13 -0
- package/tool-docs/edit.md +30 -0
- package/tool-docs/execute.md +21 -0
- package/tool-docs/fetch.md +12 -0
- package/tool-docs/file_ops.md +17 -0
- package/tool-docs/get_current_time.md +8 -0
- package/tool-docs/git.md +54 -0
- package/tool-docs/glob.md +11 -0
- package/tool-docs/grep.md +19 -0
- package/tool-docs/hashline_edit.md +14 -0
- package/tool-docs/insert_after.md +15 -0
- package/tool-docs/lint.md +10 -0
- package/tool-docs/ls.md +12 -0
- package/tool-docs/lsp.md +10 -0
- package/tool-docs/process.md +10 -0
- package/tool-docs/question.md +16 -0
- package/tool-docs/read.md +20 -0
- package/tool-docs/read_image.md +8 -0
- package/tool-docs/tree.md +14 -0
- package/tool-docs/wait_for.md +22 -0
- package/tool-docs/websearch.md +16 -0
- package/tool-docs/write.md +11 -0
- package/tools/bash.mjs +276 -0
- package/tools/edit-batch.mjs +204 -0
- package/tools/edit-diff.mjs +388 -0
- package/tools/exec-run.mjs +43 -0
- package/tools/execute.mjs +243 -0
- package/tools/file.mjs +464 -0
- package/tools/git-checkpoint.mjs +143 -0
- package/tools/git-ext.mjs +173 -0
- package/tools/git.mjs +415 -0
- package/tools/glob-dialect.mjs +130 -0
- package/tools/index.mjs +76 -0
- package/tools/linter.mjs +120 -0
- package/tools/lsp.mjs +335 -0
- package/tools/ops.mjs +293 -0
- package/tools/patch.mjs +290 -0
- package/tools/question.mjs +26 -0
- package/tools/repomap.mjs +314 -0
- package/tools/search.mjs +248 -0
- package/tools/shared.mjs +467 -0
- package/tools/tree.mjs +81 -0
- package/tools/web.mjs +224 -0
- package/tools/write-path.mjs +191 -0
- package/traces/trace-store.mjs +303 -0
- package/undo-stack.mjs +47 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parent-channel.mjs — 子代理上行通道(子 → 父 在飞提问 / 上报)(批 SUBAGENT-UPSTREAM-CHANNEL;
|
|
3
|
+
* 设计权威 = `docs/core/design/AGENT-LOOP-SUBAGENT.md` §6.27;需求 = `docs/core/requirements/AGENT-LOOP.md` §4.12)。
|
|
4
|
+
*
|
|
5
|
+
* 补上子代理通信的第三象限:既有两象限 = 父→子在飞(`subagent action:'send'`)+ 子→父**终态**
|
|
6
|
+
* (报告 → settle → digest);本档 = 子→父**在飞**——子代理运行中向父(spawn 方)发一条决策级
|
|
7
|
+
* 消息,**不中断自身回合**;父在**下一回合边界**合并消费,回复复用既有 `send`(零新下行管子)。
|
|
8
|
+
* F-UC7(2026-09-19 批 · §6.27.12):ask 入队另**唤一次父侧挂起驱动**(`wakeAsyncWaiters`——
|
|
9
|
+
* 复用 W1 `_asyncWaiters` 通道)+ 三驱动第 2 步谓词 `upstreamWaiting` ⇒ 未 drain 的 ask 即刻
|
|
10
|
+
* 开轮注入——默认异步流下答复不再恒迟到(`note` 不唤醒:无时效义务)。
|
|
11
|
+
*
|
|
12
|
+
* 三面(§6.27.2/§6.27.4):
|
|
13
|
+
* - 子侧发声 = 工具 `notify_parent`(**depth>0 专有**——`family-tools.mjs` depth>0 段装配,
|
|
14
|
+
* consult / depth-0 不装配);调用**同步返回**(零 await、零等待态——本档不提供任何拉取 /
|
|
15
|
+
* 轮询 / 等答复动作:D-UC3 非阻塞 = 结构保证)。
|
|
16
|
+
* - 父侧接收 = 队列 `_childUpstream`(数组;条目 `{seq, from, kind, message, ts}`)+ 单调计数
|
|
17
|
+
* `_childUpstreamSeq`——**两字段皆属载体字段集**(AGENT-LOOP.md §2.3,与 `_asyncQueue` /
|
|
18
|
+
* `_asyncAdvisorQueue` 同列):读单点 = `async-settle.mjs` 的 `carrierField`,写单点 = 本档的
|
|
19
|
+
* `upstreamHolder`(父字段优先 / `history` 命中即借用 / 皆无则建在父字段 + 载体别名)。
|
|
20
|
+
* 生命周期与子代理生命周期**解耦**(settle / cancel 不迁移、不清队列——F3)。
|
|
21
|
+
* - 父侧唤醒 = ask 入队尾调 `wakeAsyncWaiters(parent)`(**同步**,零 await;`note` 不唤醒)——
|
|
22
|
+
* 零新字段 / 零新容器 / 零新注册:等待栓数组由挂起驱动注册,非挂起期数组空 ⇒ no-op。
|
|
23
|
+
* - 父侧消费 = `drainChildUpstream(agent)`(`thincoder-core/agent.mjs` 循环头单点,紧邻
|
|
24
|
+
* `consumeInjected?.(agent)`——同址反向):全部 pending **合并一条** user 消息(`pushReal`
|
|
25
|
+
* 非 transient——事务性事件落盘,D-UC5),每条附结束注脚(已 settle / 已 cancel 两形态;
|
|
26
|
+
* 其余态不附——不臆断)。
|
|
27
|
+
*
|
|
28
|
+
* 三闸(可机判——§6.27.2 ③):`kind` 枚举 `ask|note` · 同一子代理「未 drain」的 `ask` ≤
|
|
29
|
+
* `UPSTREAM_ASK_MAX_INFLIGHT`(窗口 = 父队列在场,drain 即关闭;机制面不追踪答复)· `message`
|
|
30
|
+
* ≤ `UPSTREAM_MSG_MAX` 且父队列 ≤ `UPSTREAM_QUEUE_MAX`(超限 = 工具**明确报错**,不静默丢)。
|
|
31
|
+
* 射程纪律(两问自检 + 正负清单)在提示词面(§6.27.8),机制面不新增语义判定。
|
|
32
|
+
*
|
|
33
|
+
* 模块图:静态 import 核单点 `async-settle.mjs`(`carrierField` / `getAsyncPool` /
|
|
34
|
+
* `tombstoneOf` / `wakeAsyncWaiters`——同层既有导出,同 `async-discard.mjs:35` 先例;唤醒走
|
|
35
|
+
* 既有静态边,零新增)+ `../context.mjs`(`pushReal`)
|
|
36
|
+
* / `../agent/helpers.mjs`(`escapeXml`)/ `../log.mjs`(`logEvent`);单向、叶子向、无环。
|
|
37
|
+
* 登记册导出(`agent-tools.mjs`)→ 家族矩阵装配(`agent/family-tools.mjs` depth>0 段)。
|
|
38
|
+
*/
|
|
39
|
+
import { carrierField, getAsyncPool, tombstoneOf, wakeAsyncWaiters } from "./async-settle.mjs"
|
|
40
|
+
import { pushReal } from "../context.mjs"
|
|
41
|
+
import { escapeXml } from "../agent/helpers.mjs"
|
|
42
|
+
import { logEvent } from "../log.mjs"
|
|
43
|
+
|
|
44
|
+
/** message 长度上限(字符——闸三上界)。 */
|
|
45
|
+
export const UPSTREAM_MSG_MAX = 1500
|
|
46
|
+
/** 父队列总长上限(超 ⇒ 子侧工具明确报错——不静默丢)。 */
|
|
47
|
+
export const UPSTREAM_QUEUE_MAX = 20
|
|
48
|
+
/** 同一子代理「未 drain」的 ask 上限(窗口 = 父队列中存在本子代理的 ask 条目;drain 即关闭)。 */
|
|
49
|
+
export const UPSTREAM_ASK_MAX_INFLIGHT = 1
|
|
50
|
+
|
|
51
|
+
/** 工具返回注(异步形——sync: false;答复按普通指令在下回合边界到达)。 */
|
|
52
|
+
const ASYNC_NOTE =
|
|
53
|
+
"delivered to your parent's queue — consumed at the parent's next turn boundary (non-blocking). " +
|
|
54
|
+
"Keep working on the unaffected parts; a reply arrives as an ordinary instruction at your next turn boundary. " +
|
|
55
|
+
"If your run ends first, report the unanswered part as not done."
|
|
56
|
+
|
|
57
|
+
/** 工具返回注(同步形——`sync: true`:父阻塞在本次运行上 ⇒ **不给「答复到达」承诺**(F6))。 */
|
|
58
|
+
const SYNC_NOTE =
|
|
59
|
+
"queued for your parent — but it spawned you synchronously and is blocked on this run, so no reply can reach you " +
|
|
60
|
+
"before you end. It reads this when its call returns and may re-spawn you; report the unanswered part as not done."
|
|
61
|
+
|
|
62
|
+
/** 错误返回形(fail-closed——与 §6.7.2 / §6.25 的 status / observe / send 拒返回同款 JSON 对象形)。 */
|
|
63
|
+
const refused = (error) => JSON.stringify({ status: "error", error })
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 写侧载体别名单点(§6.27.2 ① / §6.27.4):`_childUpstream` / `_childUpstreamSeq` 的写恒经本
|
|
67
|
+
* 函数取容器(读恒经 `carrierField`——同两字段集):
|
|
68
|
+
* ① 父字段在场 ⇒ 父对象(CLI 形主容器——写侧不变);
|
|
69
|
+
* ② 缺 ⇒ `carrierField` 回退 `history` 命中 ⇒ **借用同一容器**(不另建分叉);
|
|
70
|
+
* ③ 两者皆无 ⇒ 建在父字段 + **载体别名**(`parent.history._childUpstream = parent._childUpstream`
|
|
71
|
+
* ——同 `writeTombstone` 借用规则扩张句的既有形态,`agent-tools/async-settle.mjs:73-83`)。
|
|
72
|
+
*/
|
|
73
|
+
function upstreamHolder(parent) {
|
|
74
|
+
if (!Array.isArray(parent._childUpstream)) {
|
|
75
|
+
const existing = carrierField(parent, "_childUpstream")
|
|
76
|
+
if (Array.isArray(existing)) {
|
|
77
|
+
parent._childUpstream = existing // ② 借用(载体命中——同一容器)
|
|
78
|
+
} else {
|
|
79
|
+
parent._childUpstream = [] // ③ 主容器(父字段)
|
|
80
|
+
if (parent.history && typeof parent.history === "object") {
|
|
81
|
+
parent.history._childUpstream = parent._childUpstream // 载体别名(合成 parent 跨调用存活)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parent
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 父侧是否存在「未 drain 的 ask」(唤醒 / 开轮判据单点——三驱动第 2 步共用;载体经
|
|
90
|
+
* `carrierField` 吸收,与写侧 `upstreamHolder` 同口径)。容器缺省 / 非数组 ⇒ `false`
|
|
91
|
+
* (fail-closed——不抛);`note` 不计(无时效义务——F13 语义零变)。
|
|
92
|
+
* @param {object} carrier 父(接收方)形态 / 载体对象。
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
export function upstreamWaiting(carrier) {
|
|
96
|
+
const q = carrierField(carrier, "_childUpstream")
|
|
97
|
+
return Array.isArray(q) && q.some((e) => e.kind === "ask")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 子 → 父入队(单点;**同步**——零等待、零 await,非阻塞结构保证;`ask` 另同步唤一次父侧
|
|
102
|
+
* 挂起驱动〔`wakeAsyncWaiters`——无等待 / 无轮询,返回即达〕)。父侧下一回合边界由
|
|
103
|
+
* `drainChildUpstream` 合并消费;入队点直记 `child:upstream` 日志(消费点不另记)。
|
|
104
|
+
* @param {{parent: object, from: string, kind: "ask"|"note", message: string}} entry
|
|
105
|
+
* @returns {{seq: number, position: number}} position = 队列内 1-based 位次。
|
|
106
|
+
*/
|
|
107
|
+
export function pushChildUpstream({ parent, from, kind, message }) {
|
|
108
|
+
const holder = upstreamHolder(parent)
|
|
109
|
+
const seq = Number(carrierField(parent, "_childUpstreamSeq") ?? 0) + 1
|
|
110
|
+
holder._childUpstream.push({ seq, from, kind, message, ts: Date.now() })
|
|
111
|
+
holder._childUpstreamSeq = seq
|
|
112
|
+
logEvent("child:upstream", { id: from, kind, seq })
|
|
113
|
+
// §6.27.12.4 ① 唤醒(唯一激活点——入队 + 日志之后):未 drain 的 ask 兑现父侧等待栓 ⇒
|
|
114
|
+
// 挂起驱动重入、第 2 步谓词开轮(唤醒 + 谓词两件一组,缺一无效);note 不唤醒(避轮风暴)。
|
|
115
|
+
if (kind === "ask") wakeAsyncWaiters(parent)
|
|
116
|
+
return { seq, position: holder._childUpstream.length }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 结束注脚(drain 时读池状态——§6.27.4 表:两形态判据点 + 一显式不附注脚):
|
|
121
|
+
* - 已 settle = 池内命中条目且 `entry.done === true`(done-in-pool 表示——§6.7.3);
|
|
122
|
+
* - 已 cancel = 池**未命中** 且 `tombstoneOf(...).status === "cancelled"`(出池 + 墓碑);
|
|
123
|
+
* - 其余态(`discarded` / `consumed` / `failed` 墓碑,或池与墓碑皆未命中)⇒ **不附注脚**
|
|
124
|
+
* (消息本体照常注入——不臆断状态)。
|
|
125
|
+
* @returns {string} 注脚(含前导空格)或空串。
|
|
126
|
+
*/
|
|
127
|
+
function endNote(agent, entry) {
|
|
128
|
+
const id = String(entry.from).split("#").pop()
|
|
129
|
+
const inPool = getAsyncPool(agent, "subagent")?.get(String(id))
|
|
130
|
+
if (inPool?.done === true) return ` (${entry.from} has since settled — see its report)`
|
|
131
|
+
if (!inPool && tombstoneOf(agent, id)?.status === "cancelled") return ` (${entry.from} has since been cancelled)`
|
|
132
|
+
return ""
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 父侧消费点(回合边界单点——`agent.mjs` 循环头;空队列 no-op = 零历史变更、零开销):
|
|
137
|
+
* 全部 pending 消息**合并为一条** user 消息注入(`pushReal`——不带 `transient`,事件落盘),
|
|
138
|
+
* 按入队序逐条列示(来源 `role#id` + `kind` + message(`escapeXml`)+ 结束注脚)。
|
|
139
|
+
* @param {object} agent 父(消费方)agent 形态。
|
|
140
|
+
* @returns {number} 本次消费条目数(0 = 空队列 no-op)。
|
|
141
|
+
*/
|
|
142
|
+
export function drainChildUpstream(agent) {
|
|
143
|
+
const queue = carrierField(agent, "_childUpstream")
|
|
144
|
+
if (!Array.isArray(queue) || queue.length === 0) return 0
|
|
145
|
+
const entries = queue.splice(0) // 消费即清(单一消费者 = 回合边界)
|
|
146
|
+
const many = entries.length > 1
|
|
147
|
+
const header = many
|
|
148
|
+
? `[System reminder: ${entries.length} in-flight message(s) from your subagents — they keep working on the unaffected parts. `
|
|
149
|
+
+ "Answer with subagent action:'send' (id + message) if the decision is yours; an unanswered ask means that child skips the part and reports it as not done.]"
|
|
150
|
+
: `[System reminder: in-flight message from your subagent ${entries[0].from} — it keeps working on the unaffected parts. `
|
|
151
|
+
+ "Answer with subagent action:'send' (id + message) if the decision is yours; an unanswered ask means the child skips that part and reports it as not done.]"
|
|
152
|
+
const rows = entries.map((e) => `${many ? "- " : ""}${e.kind} · ${e.from}: ${escapeXml(e.message)}${endNote(agent, e)}`)
|
|
153
|
+
pushReal(agent, { role: "user", content: [header, ...rows].join("\n") })
|
|
154
|
+
return entries.length
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `notify_parent` — 子代理 → 父(spawn 方)单向通道(depth>0 专有)。
|
|
159
|
+
* 只读分类(同 `task` 工具判据「只改 agent 内部状态,不改外部世界」——`readonlyToolNames` 面:
|
|
160
|
+
* ① explore / plan 子代的只读过滤不剔本工具;② 权限门免 ask——子代理无交互 UI,explore / plan
|
|
161
|
+
* 的 childPermission 恒 false 会误拒;③ planMode / digest 面按只读放行)。
|
|
162
|
+
* `execute` 为**同步**函数(A4:工具 execute 无父侧 await——非阻塞结构保证的机判面)。
|
|
163
|
+
*/
|
|
164
|
+
export const parentChannelTool = {
|
|
165
|
+
name: "notify_parent",
|
|
166
|
+
description:
|
|
167
|
+
"A one-way channel to your PARENT (the agent that spawned you) — you are a subagent, so there is no user to ask.\n" +
|
|
168
|
+
"Queue a message on the parent's side; your turn is not interrupted and nothing is consumed from it.\n" +
|
|
169
|
+
"- kind:'ask' — a question whose answer changes your next step and that you cannot answer from the materials you can read\n" +
|
|
170
|
+
" (task book / design doc / repo). The parent replies with subagent action:'send'; you receive it as an ordinary\n" +
|
|
171
|
+
" instruction at your next turn boundary. One ask at a time while the previous one is still waiting in the parent's queue.\n" +
|
|
172
|
+
"- kind:'note' — an FYI that needs no answer (a premise you found broken, a conflict you resolved and want visible early).\n" +
|
|
173
|
+
"- NON-BLOCKING: the call returns immediately. Keep working on the unaffected parts; keep the affected part pending.\n" +
|
|
174
|
+
" There is no fetch and no waiting — if you finish first, report the unanswered part as not done (never idle, never poll).\n" +
|
|
175
|
+
"- SYNCHRONOUS SPAWN: if the parent is blocked on your run (sync spawn), nothing can be sent back to you — the reply path (`send`) reaches only a RUNNING ASYNC child. The message is read when the parent's call returns (it may re-spawn you with an answer); report the unanswered part as not done.\n" +
|
|
176
|
+
"- Out of scope: naming / implementation / wording details, anything a read or a command would answer, trade-offs the\n" +
|
|
177
|
+
" task book already states. When in doubt use the stop-and-report discipline — this is not an escape from your own judgment.\n" +
|
|
178
|
+
"- Availability: subagents only (depth > 0). At depth 0 you talk to the user through your normal reply or the question tool.",
|
|
179
|
+
parameters: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
kind: {
|
|
183
|
+
type: "string",
|
|
184
|
+
enum: ["ask", "note"],
|
|
185
|
+
description: "ask = a question whose answer changes your next step; note = an FYI that needs no answer",
|
|
186
|
+
},
|
|
187
|
+
message: {
|
|
188
|
+
type: "string",
|
|
189
|
+
description: "The message to deliver to your parent (one short paragraph — details belong to your final report)",
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
required: ["kind", "message"],
|
|
193
|
+
},
|
|
194
|
+
readonly: true,
|
|
195
|
+
execute(args, ctx) {
|
|
196
|
+
// depth 门(execute 首行——§6.27.4):本通道 depth>0 专有(depth-0 与用户的通道 = 普通回复 / question)。
|
|
197
|
+
if ((ctx?.depth ?? 0) === 0) {
|
|
198
|
+
return refused("notify_parent is only available inside a subagent (depth > 0) — at depth 0 you talk to the user through your normal reply or the question tool")
|
|
199
|
+
}
|
|
200
|
+
// 上游面(F7):`_upstream` 由 spawn 站点装配(W1–W3);缺失 = 未接线站点 ⇒ 明确报错(不静默成功)。
|
|
201
|
+
const upstream = ctx?.agent?._upstream
|
|
202
|
+
if (!upstream?.parent) {
|
|
203
|
+
return refused("notify_parent: this agent has no parent channel (not spawned through the subagent pipeline)")
|
|
204
|
+
}
|
|
205
|
+
const kind = args?.kind
|
|
206
|
+
if (kind !== "ask" && kind !== "note") {
|
|
207
|
+
return refused('notify_parent requires kind: "ask" (a question whose answer changes your next step) or "note" (an FYI that needs no answer)')
|
|
208
|
+
}
|
|
209
|
+
const message = typeof args?.message === "string" ? args.message.trim() : ""
|
|
210
|
+
if (!message) return refused("notify_parent requires a non-empty message")
|
|
211
|
+
if (message.length > UPSTREAM_MSG_MAX) {
|
|
212
|
+
return refused(`notify_parent message exceeds ${UPSTREAM_MSG_MAX} chars — one short paragraph; details belong to your final report`)
|
|
213
|
+
}
|
|
214
|
+
const { parent, label, sync } = upstream
|
|
215
|
+
const queue = carrierField(parent, "_childUpstream")
|
|
216
|
+
const pending = Array.isArray(queue) ? queue : []
|
|
217
|
+
// 闸二(窗口 = 「未 drain」——§6.27.2 ③):本子代理的 ask 仍在父队列中 ⇒ 拒(不投递)。
|
|
218
|
+
if (kind === "ask") {
|
|
219
|
+
const mine = pending.filter((e) => e.from === label && e.kind === "ask")
|
|
220
|
+
if (mine.length >= UPSTREAM_ASK_MAX_INFLIGHT) {
|
|
221
|
+
return refused(`notify_parent: your earlier ask (#${mine[0].seq}) is still queued for your parent — one ask at a time until the parent picks it up; fold this into your final report if you cannot continue without an answer`)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// 闸三(父队列总长上限——超 ⇒ 报错,不静默丢)。
|
|
225
|
+
if (pending.length >= UPSTREAM_QUEUE_MAX) {
|
|
226
|
+
return refused(`notify_parent: the parent's in-flight queue is full (${UPSTREAM_QUEUE_MAX}) — the parent has not consumed the pending messages yet; fold yours into your final report instead`)
|
|
227
|
+
}
|
|
228
|
+
const { position } = pushChildUpstream({ parent, from: label, kind, message })
|
|
229
|
+
return JSON.stringify({ status: "queued", kind, position, note: sync === true ? SYNC_NOTE : ASYNC_NOTE })
|
|
230
|
+
},
|
|
231
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan tool: enter/exit plan mode.
|
|
3
|
+
* In plan mode only read-only tools are allowed — explore code, design solutions, no code writing.
|
|
4
|
+
* After the user approves the plan, exit plan mode and start implementing.
|
|
5
|
+
*
|
|
6
|
+
* Reminder cadence (kimi-code style): while plan mode is active the agent loop
|
|
7
|
+
* re-injects reminders — sparse every 2 turns, full every 5 turns or when the
|
|
8
|
+
* user sends a new message — so the constraint never fades from context.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const PLAN_FULL_REMINDER =
|
|
12
|
+
"[System reminder: plan mode is ON. Workflow: (1) explore/read codebase with read-only tools, " +
|
|
13
|
+
"(2) design a solution considering trade-offs, (3) present your plan by calling plan with action='exit' " +
|
|
14
|
+
"so the user can approve it. Only read-only tools are allowed — do not write, edit, or run mutation commands. " +
|
|
15
|
+
"Your turn must end with either a clarifying question to the user or a call to plan with action='exit'.]"
|
|
16
|
+
|
|
17
|
+
const PLAN_SPARSE_REMINDER =
|
|
18
|
+
"[System reminder: plan mode still active — read-only tools only (the current plan file exempt). " +
|
|
19
|
+
"Design the solution, then call plan with action='exit' for user approval.]"
|
|
20
|
+
|
|
21
|
+
const PLAN_EXIT_REMINDER =
|
|
22
|
+
"[System reminder: plan mode is now OFF. Start implementing your plan — edit files, run commands. " +
|
|
23
|
+
"No need for a task list (plan already covered that) or further confirmation.]"
|
|
24
|
+
|
|
25
|
+
/** Turns between reminder re-injections while plan mode is active */
|
|
26
|
+
const SPARSE_INTERVAL = 2
|
|
27
|
+
const FULL_INTERVAL = 5
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decide which plan-mode reminder (if any) to inject this turn.
|
|
31
|
+
* @param {object} agent — the agent object (mutated: tracks reminder state)
|
|
32
|
+
* @param {boolean} userMessageSince — whether a user message arrived since the last reminder
|
|
33
|
+
* @returns {string|null} reminder text or null
|
|
34
|
+
*/
|
|
35
|
+
export function planReminderForTurn(agent, userMessageSince) {
|
|
36
|
+
if (!agent.planMode) {
|
|
37
|
+
agent._planTurnsSinceReminder = 0
|
|
38
|
+
agent._planTurnsSinceSparse = 0
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
agent._planTurnsSinceReminder = (agent._planTurnsSinceReminder ?? 0) + 1
|
|
42
|
+
agent._planTurnsSinceSparse = (agent._planTurnsSinceSparse ?? 0) + 1
|
|
43
|
+
if (userMessageSince || agent._planTurnsSinceReminder >= FULL_INTERVAL) {
|
|
44
|
+
agent._planTurnsSinceReminder = 0
|
|
45
|
+
agent._planTurnsSinceSparse = 0
|
|
46
|
+
return PLAN_FULL_REMINDER
|
|
47
|
+
}
|
|
48
|
+
if (agent._planTurnsSinceSparse >= SPARSE_INTERVAL) {
|
|
49
|
+
agent._planTurnsSinceSparse = 0
|
|
50
|
+
return PLAN_SPARSE_REMINDER
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const planTool = {
|
|
56
|
+
name: "plan",
|
|
57
|
+
description:
|
|
58
|
+
"Enter or exit plan mode. In plan mode you are restricted to READ-ONLY tools: read files, search code, run read-only shell commands. Use plan mode before complex multi-step tasks — explore the codebase, design the architecture, present a plan to the user. When the user approves, exit plan mode and implement. For simple single-file edits, skip plan mode and just make the change.",
|
|
59
|
+
parameters: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
action: { type: "string", enum: ["enter", "exit"], description: "Enter or exit plan mode" },
|
|
63
|
+
},
|
|
64
|
+
required: ["action"],
|
|
65
|
+
},
|
|
66
|
+
readonly: true,
|
|
67
|
+
async execute(args, ctx) {
|
|
68
|
+
// §2.5 #85(已裁 · 以 VSC 为准):未知 action 明确报错——旧行为(非 exit 一律进 plan
|
|
69
|
+
// 模式)把拼错的 action 当成 enter,静默改写会话状态。
|
|
70
|
+
if (args.action === "exit") {
|
|
71
|
+
ctx.agent.planMode = false
|
|
72
|
+
ctx.agent._planTurnsSinceReminder = 0
|
|
73
|
+
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
74
|
+
ctx.agent._pendingReminders.push(PLAN_EXIT_REMINDER)
|
|
75
|
+
return "Plan mode exited. You may now edit files and run commands."
|
|
76
|
+
}
|
|
77
|
+
if (args.action !== "enter") {
|
|
78
|
+
return `Error: unknown action "${args.action}". Use "enter" or "exit".`
|
|
79
|
+
}
|
|
80
|
+
ctx.agent.planMode = true
|
|
81
|
+
ctx.agent._planTurnsSinceReminder = 0
|
|
82
|
+
ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
|
|
83
|
+
ctx.agent._pendingReminders.push(PLAN_FULL_REMINDER)
|
|
84
|
+
return "Plan mode activated. You are now restricted to READ-ONLY tools. Explore the codebase, understand the architecture, design a solution. Present your plan to the user for approval before writing any code."
|
|
85
|
+
},
|
|
86
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-tools/read-history.mjs — read_history tool (SESSION.md §6.9 + §6.13 R19 cross-session).
|
|
3
|
+
*
|
|
4
|
+
* Query message history — THIS session by default, any session on disk with `path`
|
|
5
|
+
* (SESSION.md §6.13 R19): an explicit session file path deep-queries that file's
|
|
6
|
+
* history line; "cwd:<dir>" discovers the sessions stored for that directory.
|
|
7
|
+
*
|
|
8
|
+
* Default (no path) — THIS session's full human-readable record (record store when bound
|
|
9
|
+
* — disk-backed, SESSION.md §6.14; agent._fullHistory memory fallback otherwise:
|
|
10
|
+
* NEVER compacted, audit-complete). Use to recall what was said or done earlier:
|
|
11
|
+
* design decisions, tool-call timing, past rulings.
|
|
12
|
+
*
|
|
13
|
+
* Filters AND together: role / keyword (message text) / tool (tool messages by
|
|
14
|
+
* name + assistant messages that declared the call) / since-until (epoch ms
|
|
15
|
+
* window, inclusive-inclusive, matches ONLY messages that carry ts) /
|
|
16
|
+
* limit (default 50, clamped to 200) / direction (oldest/newest — which end of
|
|
17
|
+
* the matched set the limit window is taken from).
|
|
18
|
+
*
|
|
19
|
+
* Returns a JSON array in chronological order. Every message without ts comes
|
|
20
|
+
* back as ts:null and can never match a time window (legacy sessions). Content
|
|
21
|
+
* is truncated to ~500 chars with an explicit marker — full text lives in the
|
|
22
|
+
* session file. assistant tool_calls are summarized to a name list (arguments
|
|
23
|
+
* never expanded).
|
|
24
|
+
*
|
|
25
|
+
* Cross-session (SESSION.md §6.13 D-R19a): path = a session file path (absolute, or
|
|
26
|
+
* relative to the project cwd) → read that file's history line and apply the SAME
|
|
27
|
+
* filter surface; path = "cwd:<dir>" → list every slot stored for that directory
|
|
28
|
+
* (slot number + full file path + title/message count/updatedAt — no dead-slot
|
|
29
|
+
* filtering, v1 decision). Single-file retrieval is guarded by a line-scan cap
|
|
30
|
+
* (READ_HISTORY_SCAN_MAX — an oversized file is refused before it is read whole)
|
|
31
|
+
* plus a message-count cap (READ_HISTORY_MAX_MESSAGES = 50,000 — L24 双保险第二道).
|
|
32
|
+
*
|
|
33
|
+
* readonly: true — planMode pass / no permission ask. Registered depth-0 only:
|
|
34
|
+
* subagents get their own throwaway history, so querying "the session" from a
|
|
35
|
+
* child would be semantically confusing (SESSION.md §6.9 refinement 1 + §6.13 T-R19.4).
|
|
36
|
+
* §6.13 R19 extension mirrored per SESSION.md §6.13 — double-end isomorphic, no
|
|
37
|
+
* cross-end byte test (thincoder-vscode/src/agent-tools/read-history.mjs).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { openSync, readSync, closeSync, readFileSync, existsSync, statSync } from "node:fs"
|
|
41
|
+
import { isAbsolute, resolve } from "node:path"
|
|
42
|
+
import { listSlots, slotPath } from "../session-slots.mjs"
|
|
43
|
+
|
|
44
|
+
const DEFAULT_LIMIT = 50
|
|
45
|
+
const MAX_LIMIT = 200
|
|
46
|
+
const CONTENT_CAP = 500
|
|
47
|
+
const VALID_ROLES = new Set(["user", "assistant", "tool"])
|
|
48
|
+
|
|
49
|
+
/** 单槽检索行扫护栏(SESSION.md §6.13 D-R19a——评审 #3 定稿:超限不再读全文,返回定稿错误文案)。 */
|
|
50
|
+
export const READ_HISTORY_SCAN_MAX = 200_000
|
|
51
|
+
|
|
52
|
+
/** L24 消息数预算(评审 #2 钉死——双保险第二道):行扫按物理 \n 行计——JSON 单行槽
|
|
53
|
+
* 行扫不设防——parse 后 history 数组长度超限即拒(同款定稿文案——双端同常量同文案)。 */
|
|
54
|
+
export const READ_HISTORY_MAX_MESSAGES = 50_000
|
|
55
|
+
|
|
56
|
+
/** 超限错误文案(SESSION.md §6.13——逐字定稿——T-R19.7 断言)。 */
|
|
57
|
+
const TOO_LARGE_ERROR = JSON.stringify({ error: "session too large — refine keyword or since/until" })
|
|
58
|
+
|
|
59
|
+
/** 检索/记忆族消歧总纲(SESSION.md §6.13 D-R19b——逐字定稿——read_history 描述尾段——T-R19.5 锚)。 */
|
|
60
|
+
const SEARCH_FAMILY_GUIDE =
|
|
61
|
+
"检索/记忆族选哪个:查**本会话**说过/裁定过 → read_history(默认);查**别的会话/项目**旧对话 → read_history 带 path/cwd 参数;查**本 run 改过哪些文件** → recent_changes;查**跨会话已存知识/约定**(memory)→ memory search;查**项目设计文档** → doc_search;查**代码实现** → code_search;查 git 历史快照 → checkpoint cat/versions。read_history 只查会话消息——文件级改动用 recent_changes——知识与约定用 memory——互相不替代。"
|
|
62
|
+
|
|
63
|
+
/** Message text for keyword matching + output: strings pass through; multimodal content arrays → text parts joined (never crashes, empty parts skipped, images ignored). */
|
|
64
|
+
function messageText(m) {
|
|
65
|
+
if (typeof m?.content === "string") return m.content
|
|
66
|
+
if (Array.isArray(m?.content)) {
|
|
67
|
+
return m.content
|
|
68
|
+
.map((p) => (p && typeof p === "object" && p.type === "text" ? p.text ?? "" : ""))
|
|
69
|
+
.filter((t) => t.length > 0)
|
|
70
|
+
.join(" ")
|
|
71
|
+
}
|
|
72
|
+
return ""
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Truncate long content (~500 chars) with an explicit marker. The cut never splits a UTF-16
|
|
76
|
+
* surrogate pair (emoji etc.) — a lone high surrogate in tool output would be an eyesore at
|
|
77
|
+
* minimum; the send layer sanitizes it anyway, but clean output costs nothing (setup.mjs
|
|
78
|
+
* safeSliceUTF16 same rule). */
|
|
79
|
+
function truncateContent(text) {
|
|
80
|
+
const t = String(text ?? "")
|
|
81
|
+
if (t.length <= CONTENT_CAP) return t
|
|
82
|
+
let end = CONTENT_CAP
|
|
83
|
+
if (t.charCodeAt(end - 1) >= 0xd800 && t.charCodeAt(end - 1) <= 0xdbff) end-- // high surrogate at the cut → step back
|
|
84
|
+
return t.slice(0, end) + `\n… (truncated: ${t.length - end} chars — full text is in the session file)`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Tool name across both stored shapes ({function:{name}} and flat {name}). */
|
|
88
|
+
function toolCallName(tc) {
|
|
89
|
+
return tc?.function?.name ?? tc?.name ?? ""
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse a ts window boundary (epoch ms number; numeric strings tolerated). Returns the number or an error string. */
|
|
93
|
+
function parseTs(value, label) {
|
|
94
|
+
if (value === undefined || value === null) return null
|
|
95
|
+
const n = typeof value === "number" ? value : Number(value)
|
|
96
|
+
if (!Number.isFinite(n)) return `Error: invalid ${label} "${value}" — must be epoch milliseconds (number)`
|
|
97
|
+
return n
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Map one matched message to its JSON entry shape. */
|
|
101
|
+
function toEntry(m) {
|
|
102
|
+
const entry = {
|
|
103
|
+
ts: typeof m.ts === "number" ? m.ts : null,
|
|
104
|
+
role: m.role ?? null,
|
|
105
|
+
}
|
|
106
|
+
if (m.name !== undefined) entry.name = m.name
|
|
107
|
+
if (m.tool_call_id !== undefined) entry.tool_call_id = m.tool_call_id
|
|
108
|
+
entry.content = truncateContent(messageText(m))
|
|
109
|
+
if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
110
|
+
entry.tool_calls = m.tool_calls.map(toolCallName).filter(Boolean)
|
|
111
|
+
}
|
|
112
|
+
return entry
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** AND-filter one history message (role/keyword/tool/since-until) — shared by the in-memory
|
|
116
|
+
* default and the cross-session file query (SESSION.md §6.13 D-R19a: 同 filter 面应用). */
|
|
117
|
+
function matches(m, { role, kwRe, tool, since, until }) {
|
|
118
|
+
if (!m || typeof m !== "object") return false
|
|
119
|
+
if (role !== undefined && m.role !== role) return false
|
|
120
|
+
if (kwRe) {
|
|
121
|
+
const text = messageText(m)
|
|
122
|
+
if (!kwRe.test(text)) return false
|
|
123
|
+
}
|
|
124
|
+
if (tool) {
|
|
125
|
+
const byName = m.role === "tool" && m.name === tool
|
|
126
|
+
const byDeclaration = m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.some((tc) => toolCallName(tc) === tool)
|
|
127
|
+
if (!byName && !byDeclaration) return false
|
|
128
|
+
}
|
|
129
|
+
const ts = m.ts
|
|
130
|
+
if (since !== null || until !== null) {
|
|
131
|
+
if (typeof ts !== "number") return false // no ts → no time-window match
|
|
132
|
+
if (since !== null && ts < since) return false
|
|
133
|
+
if (until !== null && ts > until) return false
|
|
134
|
+
}
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Direction picks the END of the matched set; output stays chronological either way. */
|
|
139
|
+
function formatMatches(matched, direction, limit) {
|
|
140
|
+
const windowed = direction === "oldest" ? matched.slice(0, limit) : matched.slice(-limit)
|
|
141
|
+
return JSON.stringify(windowed.map(toEntry))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Line-scan guard: stream-count physical newlines, bailing the moment the cap is crossed —
|
|
145
|
+
* an oversized file is refused BEFORE it is read whole ("不再读全文"——SESSION.md §6.13 D-R19a). */
|
|
146
|
+
function exceedsScanMax(file) {
|
|
147
|
+
const CHUNK = 64 * 1024
|
|
148
|
+
let fd = null
|
|
149
|
+
try {
|
|
150
|
+
fd = openSync(file, "r")
|
|
151
|
+
const buf = Buffer.alloc(CHUNK)
|
|
152
|
+
let newlines = 0
|
|
153
|
+
for (;;) {
|
|
154
|
+
const n = readSync(fd, buf, 0, CHUNK, null)
|
|
155
|
+
if (n <= 0) break
|
|
156
|
+
for (let i = 0; i < n; i++) {
|
|
157
|
+
if (buf[i] === 0x0a) newlines++
|
|
158
|
+
}
|
|
159
|
+
if (newlines > READ_HISTORY_SCAN_MAX) return true
|
|
160
|
+
}
|
|
161
|
+
return false
|
|
162
|
+
} catch {
|
|
163
|
+
return false // 行扫失败 → 交由后续读取/解析路径给出真实错误
|
|
164
|
+
} finally {
|
|
165
|
+
if (fd !== null) {
|
|
166
|
+
try { closeSync(fd) } catch { /* ignore */ }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Cross-session deep query: one session file, same filter surface (§6.13 D-R19a). */
|
|
172
|
+
function querySessionFile(pathArg, { role, kwRe, tool, since, until, direction, limit }, baseCwd) {
|
|
173
|
+
const file = isAbsolute(pathArg) ? pathArg : resolve(baseCwd ?? process.cwd(), pathArg)
|
|
174
|
+
if (!existsSync(file)) return `Error: session file not found: ${file}`
|
|
175
|
+
if (exceedsScanMax(file)) return TOO_LARGE_ERROR
|
|
176
|
+
let text
|
|
177
|
+
try {
|
|
178
|
+
text = readFileSync(file, "utf8")
|
|
179
|
+
} catch (e) {
|
|
180
|
+
return `Error: failed to read session file ${file}: ${e.message}`
|
|
181
|
+
}
|
|
182
|
+
let data
|
|
183
|
+
try {
|
|
184
|
+
data = JSON.parse(text)
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return `Error: ${file} is not a valid session file (corrupt JSON: ${e.message})`
|
|
187
|
+
}
|
|
188
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.history)) {
|
|
189
|
+
return `Error: ${file} is not a valid session file (no history array)`
|
|
190
|
+
}
|
|
191
|
+
if (data.history.length > READ_HISTORY_MAX_MESSAGES) {
|
|
192
|
+
// L24 消息数第二道(parse 后——行扫按物理行、单行 JSON 槽行扫不设防——超限同款拒绝)。
|
|
193
|
+
return TOO_LARGE_ERROR
|
|
194
|
+
}
|
|
195
|
+
const matched = data.history.filter((m) => matches(m, { role, kwRe, tool, since, until }))
|
|
196
|
+
return formatMatches(matched, direction, limit)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Discovery surface (path = "cwd:<dir>"): list every slot stored for that directory, one line
|
|
200
|
+
* per slot — slot number + FULL session file path + title/message count/updatedAt(§6.13 D-R19a
|
|
201
|
+
* ——评审 #2:摘要必须含寻址字段——模型第二步深查 = 复制行内文件路径重调 path=)。 */
|
|
202
|
+
function discoverCwd(raw, baseCwd) {
|
|
203
|
+
const dir = resolve(baseCwd ?? process.cwd(), raw)
|
|
204
|
+
let st = null
|
|
205
|
+
try {
|
|
206
|
+
st = statSync(dir)
|
|
207
|
+
} catch { /* fallthrough to the explicit error below */ }
|
|
208
|
+
if (st === null || !st.isDirectory()) {
|
|
209
|
+
return `Error: unknown cwd "${raw}" — no session directory for this cwd (directory not found: ${dir})`
|
|
210
|
+
}
|
|
211
|
+
const slots = listSlots(dir) // 时间序(updatedAt 降序)——含 manifest 记录的全部槽(v1 不做死槽过滤)
|
|
212
|
+
if (slots.length === 0) {
|
|
213
|
+
return `(no session slots found for cwd: ${dir} — no sessions started there yet)`
|
|
214
|
+
}
|
|
215
|
+
const lines = slots.map((s) => {
|
|
216
|
+
const title = s.title ? `"${s.title}"` : "(untitled)"
|
|
217
|
+
return `slot ${s.slot}: ${slotPath(dir, s.slot)} — title: ${title}, messages: ${s.messageCount}, updatedAt: ${s.updatedAt}`
|
|
218
|
+
})
|
|
219
|
+
return `Session slots for cwd: ${dir} (newest first):\n${lines.join("\n")}`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const readHistoryTool = {
|
|
223
|
+
name: "read_history",
|
|
224
|
+
description:
|
|
225
|
+
"Query message history — THIS session by default, any session on disk with `path`. " +
|
|
226
|
+
"Default (no path): THIS session's full record (never compacted, audit-complete) — recall what " +
|
|
227
|
+
"was said or done earlier: design decisions, tool-call timing, past rulings. " +
|
|
228
|
+
"Filters combine with AND: role / keyword (case-insensitive substring of message text) / " +
|
|
229
|
+
"tool (tool result messages by name AND the assistant messages that declared the call — pair with tool_call_id / ts for timing) / " +
|
|
230
|
+
"since-until (epoch ms time window; only messages with ts can match) / limit (default 50, clamped to 200) / direction (which end of the matches to take). " +
|
|
231
|
+
"Returns a JSON array in chronological order: [{ts, role, name?, tool_call_id?, content (≈500 chars, truncated marker), tool_calls (names only)}]. " +
|
|
232
|
+
"Messages without ts return ts:null. Content is truncated — the full text is in the session file. " +
|
|
233
|
+
"Cross-session (path, optional): a session file path deep-queries THAT session's history with the same filters " +
|
|
234
|
+
"(relative paths resolve against the project cwd); \"cwd:<dir>\" lists every session slot stored for that directory — " +
|
|
235
|
+
"one line per slot: slot number + full session file path + title + message count + updatedAt; copy a listed file path into path= to deep-query it. " +
|
|
236
|
+
"A session file over 50,000 messages or 200,000 lines is refused (\"session too large\") instead of being read whole.\n" +
|
|
237
|
+
SEARCH_FAMILY_GUIDE,
|
|
238
|
+
parameters: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
role: { type: "string", enum: ["user", "assistant", "tool"], description: "Only messages with this role." },
|
|
242
|
+
keyword: { type: "string", description: "Case-insensitive substring of the message text (multimodal messages match on their text parts)." },
|
|
243
|
+
tool: { type: "string", description: "Only messages for this tool: role=tool messages with name=tool, plus assistant messages that declared a call to it." },
|
|
244
|
+
since: { type: "integer", description: "Earliest ts to match, epoch ms, INCLUSIVE. Messages without ts never match a time window." },
|
|
245
|
+
until: { type: "integer", description: "Latest ts to match, epoch ms, INCLUSIVE. since > until yields an empty result." },
|
|
246
|
+
limit: { type: "integer", description: "Maximum messages to return (default 50; larger values are clamped to 200)." },
|
|
247
|
+
direction: { type: "string", enum: ["oldest", "newest"], description: "Take the limit window from the oldest or newest end of the matched set (default newest)." },
|
|
248
|
+
path: { type: "string", description: "Optional — query another session instead of this one: a session file path (as listed by a \"cwd:<dir>\" call) deep-queries that session; \"cwd:<dir>\" lists that directory's session slots (slot number + full file path + title + message count + updatedAt)." },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
readonly: true,
|
|
252
|
+
execute(args, ctx) {
|
|
253
|
+
const a = args ?? {}
|
|
254
|
+
if (a.path !== undefined && (typeof a.path !== "string" || a.path.trim().length === 0)) {
|
|
255
|
+
return `Error: invalid path "${a.path}" — must be a session file path or "cwd:<dir>"`
|
|
256
|
+
}
|
|
257
|
+
const role = a.role
|
|
258
|
+
if (role !== undefined && (typeof role !== "string" || !VALID_ROLES.has(role))) {
|
|
259
|
+
return `Error: invalid role "${role}" — valid roles: user, assistant, tool`
|
|
260
|
+
}
|
|
261
|
+
const direction = a.direction ?? "newest"
|
|
262
|
+
if (direction !== "oldest" && direction !== "newest") {
|
|
263
|
+
return `Error: invalid direction "${direction}" — valid values: oldest, newest`
|
|
264
|
+
}
|
|
265
|
+
const since = parseTs(a.since, "since")
|
|
266
|
+
if (typeof since === "string") return since
|
|
267
|
+
const until = parseTs(a.until, "until")
|
|
268
|
+
if (typeof until === "string") return until
|
|
269
|
+
let limit = DEFAULT_LIMIT
|
|
270
|
+
if (a.limit !== undefined) {
|
|
271
|
+
limit = Math.floor(Number(a.limit))
|
|
272
|
+
if (!Number.isFinite(limit)) return `Error: invalid limit "${a.limit}" — must be a number`
|
|
273
|
+
limit = Math.min(Math.max(1, limit), MAX_LIMIT)
|
|
274
|
+
}
|
|
275
|
+
const keyword = typeof a.keyword === "string" && a.keyword.length > 0 ? a.keyword : null
|
|
276
|
+
// Case-insensitive substring WITHOUT copying the full message text: the human line is
|
|
277
|
+
// never compacted (绑定态存储行 = slimForDisplay 产物——匹配基准 delta 见 SESSION.md
|
|
278
|
+
// §6.14 / T-RS8b) — single tool results can be hundreds of KB to MBs. Lowercase the
|
|
279
|
+
// needle once and run a regex-i test over the haystack (escaping regex metachars so the
|
|
280
|
+
// keyword stays a literal substring).
|
|
281
|
+
const kwRe = keyword ? new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") : null
|
|
282
|
+
const tool = typeof a.tool === "string" && a.tool.length > 0 ? a.tool : null
|
|
283
|
+
const baseCwd = ctx.agent?.cwd ?? process.cwd()
|
|
284
|
+
|
|
285
|
+
const pathArg = typeof a.path === "string" && a.path.trim().length > 0 ? a.path.trim() : null
|
|
286
|
+
if (pathArg !== null) {
|
|
287
|
+
return pathArg.startsWith("cwd:")
|
|
288
|
+
? discoverCwd(pathArg.slice("cwd:".length), baseCwd)
|
|
289
|
+
: querySessionFile(pathArg, { role, kwRe, tool, since, until, direction, limit }, baseCwd)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 本会话(无 path):绑定记录存储 → 方向流式迭代(磁盘为准——全量可见、内存窗口外
|
|
293
|
+
// 可命中;§6.14);未绑定(测试 / 模式 F)→ 内存 _fullHistory 既有过滤路径(回退保留)。
|
|
294
|
+
// 方向语义不变:newest 自尾向前取满 limit → 反转回时间序(输出恒时间序)。
|
|
295
|
+
const store = ctx.agent?._recordStore
|
|
296
|
+
if (store?.iterate) {
|
|
297
|
+
const taken = []
|
|
298
|
+
for (const m of store.iterate(direction)) {
|
|
299
|
+
if (!matches(m, { role, kwRe, tool, since, until })) continue
|
|
300
|
+
taken.push(m)
|
|
301
|
+
if (taken.length >= limit) break
|
|
302
|
+
}
|
|
303
|
+
return formatMatches(direction === "newest" ? taken.reverse() : taken, "oldest", limit)
|
|
304
|
+
}
|
|
305
|
+
const history = Array.isArray(ctx.agent?._fullHistory) ? ctx.agent._fullHistory : []
|
|
306
|
+
const matched = history.filter((m) => matches(m, { role, kwRe, tool, since, until }))
|
|
307
|
+
return formatMatches(matched, direction, limit)
|
|
308
|
+
},
|
|
309
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recent_changes tool: list files touched by this agent run (write/edit/insert_after/delete).
|
|
3
|
+
* More precise than git status — only looks at this session's changes, independent of git tracking.
|
|
4
|
+
* Helps the model recall what it already modified during long tasks.
|
|
5
|
+
*/
|
|
6
|
+
export const recentChangesTool = {
|
|
7
|
+
name: "recent_changes",
|
|
8
|
+
description:
|
|
9
|
+
"Show files modified in this agent run (write/edit/insert_after/delete). " +
|
|
10
|
+
"Use when you need to remember which files you've already touched — during long multi-file tasks, " +
|
|
11
|
+
"it's easy to lose track. This is scoped to the current run, unlike git status which shows all uncommitted changes. " +
|
|
12
|
+
"For session-level history (what was said in a session), use read_history.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {},
|
|
16
|
+
},
|
|
17
|
+
readonly: true,
|
|
18
|
+
execute(args, ctx) {
|
|
19
|
+
const files = ctx.agent._touchedFiles ?? []
|
|
20
|
+
if (files.length === 0) return "(no files modified in this run yet)"
|
|
21
|
+
const deduped = [...new Set(files)]
|
|
22
|
+
return `Touched ${deduped.length} file(s) this run:\n${deduped.join("\n")}`
|
|
23
|
+
},
|
|
24
|
+
}
|