nine-hundred 0.0.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.
Files changed (107) hide show
  1. package/README.md +43 -0
  2. package/dist/agent/config/index.js +85 -0
  3. package/dist/agent/context/compress.js +145 -0
  4. package/dist/agent/context/index.js +68 -0
  5. package/dist/agent/context/token.js +30 -0
  6. package/dist/agent/hooks/config.js +41 -0
  7. package/dist/agent/hooks/core.js +177 -0
  8. package/dist/agent/hooks/events/index.js +9 -0
  9. package/dist/agent/hooks/events/post-tool-use-hook.js +7 -0
  10. package/dist/agent/hooks/events/pre-tool-use-hook.js +8 -0
  11. package/dist/agent/hooks/events/session-start-hook.js +8 -0
  12. package/dist/agent/hooks/events/stop-hook.js +10 -0
  13. package/dist/agent/hooks/events/user-prompt-submit-hook.js +7 -0
  14. package/dist/agent/hooks/index.js +7 -0
  15. package/dist/agent/hooks/types.js +32 -0
  16. package/dist/agent/index.js +465 -0
  17. package/dist/agent/llm/index.js +35 -0
  18. package/dist/agent/mcp/client.js +87 -0
  19. package/dist/agent/mcp/config.js +51 -0
  20. package/dist/agent/mcp/index.js +90 -0
  21. package/dist/agent/mcp/schema.js +56 -0
  22. package/dist/agent/permission/exec.js +71 -0
  23. package/dist/agent/permission/network.js +37 -0
  24. package/dist/agent/permission/read.js +27 -0
  25. package/dist/agent/permission/util/command-changes-directory.js +29 -0
  26. package/dist/agent/permission/util/dangerous-path.json +116 -0
  27. package/dist/agent/permission/util/detect-dangerous-operation.js +230 -0
  28. package/dist/agent/permission/util/detect-language-interpreter.js +66 -0
  29. package/dist/agent/permission/util/detect-safe-command.js +61 -0
  30. package/dist/agent/permission/util/index.js +16 -0
  31. package/dist/agent/permission/util/is-dangerous-path.js +98 -0
  32. package/dist/agent/permission/util/is-inside-cwd.js +83 -0
  33. package/dist/agent/permission/util/is-safe-domains.js +47 -0
  34. package/dist/agent/permission/write.js +31 -0
  35. package/dist/agent/prompt/index.js +42 -0
  36. package/dist/agent/skills/planner/SKILL.md +90 -0
  37. package/dist/agent/skills/programmer-resume/SKILL.md +113 -0
  38. package/dist/agent/skills/skill-creator/LICENSE.txt +202 -0
  39. package/dist/agent/skills/skill-creator/SKILL.md +495 -0
  40. package/dist/agent/skills/skill-creator/agents/analyzer.md +274 -0
  41. package/dist/agent/skills/skill-creator/agents/comparator.md +202 -0
  42. package/dist/agent/skills/skill-creator/agents/grader.md +223 -0
  43. package/dist/agent/skills/skill-creator/assets/eval_review.html +146 -0
  44. package/dist/agent/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  45. package/dist/agent/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  46. package/dist/agent/skills/skill-creator/references/schemas.md +430 -0
  47. package/dist/agent/skills/skill-creator/scripts/__init__.py +0 -0
  48. package/dist/agent/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  49. package/dist/agent/skills/skill-creator/scripts/generate_report.py +326 -0
  50. package/dist/agent/skills/skill-creator/scripts/improve_description.py +247 -0
  51. package/dist/agent/skills/skill-creator/scripts/package_skill.py +136 -0
  52. package/dist/agent/skills/skill-creator/scripts/quick_validate.py +103 -0
  53. package/dist/agent/skills/skill-creator/scripts/run_eval.py +310 -0
  54. package/dist/agent/skills/skill-creator/scripts/run_loop.py +328 -0
  55. package/dist/agent/skills/skill-creator/scripts/utils.py +47 -0
  56. package/dist/agent/skills.js +129 -0
  57. package/dist/agent/tools/agent_tool/agent_tool.test.js +64 -0
  58. package/dist/agent/tools/agent_tool/index.js +33 -0
  59. package/dist/agent/tools/exec_tool/exec_tool.test.js +48 -0
  60. package/dist/agent/tools/exec_tool/index.js +44 -0
  61. package/dist/agent/tools/load_skill_tool/index.js +5 -0
  62. package/dist/agent/tools/load_skill_tool/load_skill_tool.test.js +122 -0
  63. package/dist/agent/tools/memory_create_tool/index.js +8 -0
  64. package/dist/agent/tools/memory_create_tool/memory_create_tool.test.js +54 -0
  65. package/dist/agent/tools/memory_delete_tool/index.js +10 -0
  66. package/dist/agent/tools/memory_delete_tool/memory_delete_tool.test.js +39 -0
  67. package/dist/agent/tools/memory_retrieve_tool/index.js +61 -0
  68. package/dist/agent/tools/memory_retrieve_tool/memory_retrieve_tool.test.js +102 -0
  69. package/dist/agent/tools/profile_update_tool/index.js +30 -0
  70. package/dist/agent/tools/profile_update_tool/profile_update_tool.test.js +49 -0
  71. package/dist/agent/tools/read_file_tool/index.js +24 -0
  72. package/dist/agent/tools/read_file_tool/read_file_tool.test.js +43 -0
  73. package/dist/agent/tools/run_js_tool/index.js +48 -0
  74. package/dist/agent/tools/run_js_tool/run_js_tool.test.js +67 -0
  75. package/dist/agent/tools/run_py_tool/index.js +48 -0
  76. package/dist/agent/tools/run_py_tool/run_py_tool.test.js +67 -0
  77. package/dist/agent/tools/tool_logger.js +16 -0
  78. package/dist/agent/tools/tool_logger.test.js +22 -0
  79. package/dist/agent/tools/web_fetch_url/index.js +76 -0
  80. package/dist/agent/tools/web_fetch_url/web_fetch_url.test.js +102 -0
  81. package/dist/agent/tools/web_search_tool/index.js +26 -0
  82. package/dist/agent/tools/web_search_tool/web_search_tool.test.js +61 -0
  83. package/dist/agent/tools/write_file_tool/index.js +22 -0
  84. package/dist/agent/tools/write_file_tool/write_file_tool.test.js +46 -0
  85. package/dist/agent/tools.js +218 -0
  86. package/dist/cli/command/compact/index.js +14 -0
  87. package/dist/cli/command/index.js +62 -0
  88. package/dist/cli/command/invalid/index.js +4 -0
  89. package/dist/cli/command/new/chat-session.js +10 -0
  90. package/dist/cli/command/new/index.js +8 -0
  91. package/dist/cli/command/rewind/index.js +19 -0
  92. package/dist/cli/command/rewind/rewind-command.test.js +22 -0
  93. package/dist/cli/command/session/format.js +32 -0
  94. package/dist/cli/command/session/index.js +32 -0
  95. package/dist/cli/command/session/session-command.test.js +49 -0
  96. package/dist/cli/command/unknown/index.js +4 -0
  97. package/dist/cli/index.js +144 -0
  98. package/dist/db/checkpointer.js +15 -0
  99. package/dist/db/index.js +2 -0
  100. package/dist/db/path.js +8 -0
  101. package/dist/db/sessions.js +81 -0
  102. package/dist/db/tables/memory.js +12 -0
  103. package/dist/db/tables/memory_fts.js +29 -0
  104. package/dist/index.js +87 -0
  105. package/dist/install.js +154 -0
  106. package/package.json +51 -0
  107. package/pnpm-workspace.yaml +3 -0
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ // 支持的 hook 事件名。
3
+ // PreToolUse / PostToolUse 跑在 tool 调用前后;SessionStart / Stop 跟会话生命周期相关;
4
+ // UserPromptSubmit 跑在用户消息进入 agent 之前。
5
+ export const HOOK_EVENTS = [
6
+ 'PreToolUse',
7
+ 'PostToolUse',
8
+ 'SessionStart',
9
+ 'UserPromptSubmit',
10
+ 'Stop',
11
+ ];
12
+ // 单条 hook 配置。matcher 只在 PreToolUse/PostToolUse 有意义:
13
+ // '*' 或缺省 → 匹配所有 tool;
14
+ // 其它字符串 → 对 tool.name 做 substring 匹配(区分大小写)。
15
+ // timeoutMs 可覆盖默认 hook 超时。
16
+ export const HookEntrySchema = z.object({
17
+ matcher: z.string().optional(),
18
+ command: z.string().min(1),
19
+ timeoutMs: z.number().int().positive().optional(),
20
+ });
21
+ // hooks.json 顶层结构。每个 event 是该事件下要跑的 hook 数组(按数组顺序执行)。
22
+ export const HookConfigSchema = z.object({
23
+ hooks: z
24
+ .object({
25
+ PreToolUse: z.array(HookEntrySchema).optional(),
26
+ PostToolUse: z.array(HookEntrySchema).optional(),
27
+ SessionStart: z.array(HookEntrySchema).optional(),
28
+ UserPromptSubmit: z.array(HookEntrySchema).optional(),
29
+ Stop: z.array(HookEntrySchema).optional(),
30
+ })
31
+ .strict(),
32
+ });
@@ -0,0 +1,465 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { AIMessage, AIMessageChunk, HumanMessage, SystemMessage, ToolMessage, } from '@langchain/core/messages';
3
+ import { Annotation, Command, END, interrupt, messagesStateReducer, START, StateGraph, } from '@langchain/langgraph';
4
+ import { buildLlmInputMessages, getMessagesToCompress, KEEP_RECENT, summarizeMessages, } from './context/compress.js';
5
+ import { systemPrompt } from './prompt/index.js';
6
+ import { checkpointer } from '../db/index.js';
7
+ import { getModel } from './llm/index.js';
8
+ import { getToolsForRunMode, maybePersistedOutput, registerSubagentRunner, } from './tools.js';
9
+ import { logToolCall } from './tools/tool_logger.js';
10
+ import { checkExecPermission } from './permission/exec.js';
11
+ import { checkNetworkPermission } from './permission/network.js';
12
+ import { checkReadPermission } from './permission/read.js';
13
+ import { checkWritePermission } from './permission/write.js';
14
+ import { runPostToolUseHooks, runPreToolUseHooks, runSessionStartHook, runStopHook, } from './hooks/index.js';
15
+ const systemMessage = new SystemMessage(systemPrompt);
16
+ function createAgentRuntimeConfig(options) {
17
+ const mode = options.mode ?? 'main';
18
+ return {
19
+ configurable: {
20
+ thread_id: options.threadId,
21
+ agent_mode: mode,
22
+ disable_context_compression: options.disableContextCompression ?? mode === 'subagent',
23
+ },
24
+ signal: options.signal,
25
+ };
26
+ }
27
+ function resolveAgentRuntime(config) {
28
+ const mode = config.configurable?.agent_mode === 'subagent' ? 'subagent' : 'main';
29
+ return {
30
+ mode,
31
+ tools: getToolsForRunMode(mode),
32
+ systemMessage,
33
+ disableContextCompression: config.configurable?.disable_context_compression === true,
34
+ };
35
+ }
36
+ // messages 是持久化的真历史;llmInputMessages 是每轮 preprocess 重建的「模型这一轮看的视图」。
37
+ // 视图 reducer 用覆盖语义:写入即整体替换,便于 preprocess 自由重组(压缩、翻译、删条等)。
38
+ // 下方三个 summary / summarizedIndex / compressionCount 是上下文压缩专用 channel:
39
+ // 标准 Annotation,覆盖式 reducer,由 SqliteSaver 自动持久化,跨进程重启可读;
40
+ // default 兜底旧 checkpoint 缺字段场景,加载时拿 null/0/0,不抛错。
41
+ const StateAnnotation = Annotation.Root({
42
+ messages: Annotation({
43
+ reducer: messagesStateReducer,
44
+ default: () => [],
45
+ }),
46
+ llmInputMessages: Annotation({
47
+ reducer: (_, update) => messagesStateReducer([], update),
48
+ default: () => [],
49
+ }),
50
+ summary: Annotation({
51
+ reducer: (_, update) => update,
52
+ default: () => null,
53
+ }),
54
+ summarizedIndex: Annotation({
55
+ reducer: (_, update) => update,
56
+ default: () => 0,
57
+ }),
58
+ compressionCount: Annotation({
59
+ reducer: (_, update) => update,
60
+ default: () => 0,
61
+ }),
62
+ hookStopMessage: Annotation({
63
+ reducer: (_, update) => update,
64
+ default: () => null,
65
+ }),
66
+ });
67
+ // 已执行过的 tool_call_id 不再重跑——给未来在工具循环里加 preprocess 或断点恢复留幂等保护。
68
+ export async function toolNode(state, config) {
69
+ const runtime = resolveAgentRuntime(config);
70
+ const messages = state.messages;
71
+ const repliedIds = new Set(messages.filter((m) => m instanceof ToolMessage).map((m) => m.tool_call_id));
72
+ let aiMessage;
73
+ for (let i = messages.length - 1; i >= 0; i--) {
74
+ if (messages[i] instanceof AIMessage || messages[i] instanceof AIMessageChunk) {
75
+ aiMessage = messages[i];
76
+ break;
77
+ }
78
+ }
79
+ if (!aiMessage) {
80
+ throw new Error('toolNode requires an AIMessage in state.');
81
+ }
82
+ const toolCalls = aiMessage.tool_calls?.filter((c) => c.id == null || !repliedIds.has(c.id)) ?? [];
83
+ const outputs = [];
84
+ for (let callIndex = 0; callIndex < toolCalls.length; callIndex++) {
85
+ const call = toolCalls[callIndex];
86
+ const tool = runtime.tools.find((t) => t.name === call.name);
87
+ if (!tool) {
88
+ outputs.push(new ToolMessage({
89
+ content: `Error: Tool "${call.name}" not found.`,
90
+ tool_call_id: call.id ?? '',
91
+ name: call.name,
92
+ }));
93
+ continue;
94
+ }
95
+ // read / write 工具的 filepath 三级决策:allow 跳过 interrupt、block
96
+ // 返回 ToolMessage 给 LLM、ask 落回原有 interrupt 流程;exec 工具额外
97
+ // 阻断 cwd 切到子目录或命令中含 cd / pushd 的调用;network 工具对
98
+ // 安全域名 allow、对陌生 URL ask。
99
+ // 未匹配到 case 的 permission 级别(目前是 db 以及将来新加的级别)
100
+ // 直接 allow —— 既然没专门的检查逻辑,就视为可信,让工具跑。
101
+ const toolArgs = (call.args ?? {});
102
+ let filepathDecision;
103
+ switch (tool.permission_level) {
104
+ case 'read':
105
+ filepathDecision = await checkReadPermission(toolArgs);
106
+ break;
107
+ case 'write':
108
+ filepathDecision = await checkWritePermission(toolArgs);
109
+ break;
110
+ case 'exec':
111
+ filepathDecision = await checkExecPermission(toolArgs);
112
+ break;
113
+ case 'network':
114
+ filepathDecision = await checkNetworkPermission(toolArgs);
115
+ break;
116
+ case 'mcp':
117
+ filepathDecision = { action: 'ask' };
118
+ break;
119
+ default:
120
+ filepathDecision = { action: 'allow' };
121
+ }
122
+ if (filepathDecision.action === 'block') {
123
+ outputs.push(new ToolMessage({
124
+ content: filepathDecision.message,
125
+ tool_call_id: call.id ?? '',
126
+ name: call.name,
127
+ }));
128
+ continue;
129
+ }
130
+ const approved = filepathDecision.action === 'allow'
131
+ ? true
132
+ : interrupt({
133
+ toolName: call.name,
134
+ args: call.args,
135
+ description: tool.description,
136
+ }).approved;
137
+ if (!approved) {
138
+ outputs.push(new ToolMessage({
139
+ content: '用户拒绝了该操作',
140
+ tool_call_id: call.id ?? '',
141
+ name: call.name,
142
+ }));
143
+ continue;
144
+ }
145
+ // PreToolUse hook: 在硬编码安全层之上多加一道用户配置的拦截。
146
+ // 硬编码 block 已经在上面的 switch 里短路,所以这里只会看到 allow / ask(已确认)。
147
+ const threadId = config.configurable?.thread_id ?? 'default-session';
148
+ const preHook = await runPreToolUseHooks({
149
+ event: 'PreToolUse',
150
+ threadId,
151
+ tool: { name: call.name, permissionLevel: tool.permission_level },
152
+ args: toolArgs,
153
+ });
154
+ if (preHook.block) {
155
+ const blockMessage = preHook.block.message;
156
+ outputs.push(new ToolMessage({
157
+ content: blockMessage,
158
+ tool_call_id: call.id ?? '',
159
+ name: call.name,
160
+ }));
161
+ for (const skippedCall of toolCalls.slice(callIndex + 1)) {
162
+ outputs.push(new ToolMessage({
163
+ content: `Skipped because a PreToolUse hook blocked execution: ${blockMessage}`,
164
+ tool_call_id: skippedCall.id ?? '',
165
+ name: skippedCall.name,
166
+ }));
167
+ }
168
+ return new Command({
169
+ update: { messages: outputs, hookStopMessage: blockMessage },
170
+ goto: END,
171
+ });
172
+ }
173
+ try {
174
+ logToolCall(call.name, JSON.stringify(call.args));
175
+ const output = await tool.invoke({ ...call, type: 'tool_call' }, config);
176
+ const content = typeof output === 'string' ? output : JSON.stringify(output);
177
+ const toolContent = await maybePersistedOutput(content, call.id ?? '');
178
+ outputs.push(new ToolMessage({
179
+ content: toolContent,
180
+ tool_call_id: call.id ?? '',
181
+ name: call.name,
182
+ }));
183
+ // PostToolUse hook: 工具成功返回后再观察一次。exit 1 此时无意义(tool 已跑),
184
+ // runner 内部已经 warn-log,这里不再处理;只收 exit 2 注入的上下文。
185
+ const postHook = await runPostToolUseHooks({
186
+ event: 'PostToolUse',
187
+ threadId,
188
+ tool: { name: call.name },
189
+ args: toolArgs,
190
+ result: toolContent,
191
+ isError: false,
192
+ });
193
+ if (postHook.additionalContext) {
194
+ outputs.push(new HumanMessage(`[hook PostToolUse output]\n${postHook.additionalContext}`));
195
+ }
196
+ }
197
+ catch (e) {
198
+ outputs.push(new ToolMessage({
199
+ content: `Error: ${e.message}\n Please fix your mistakes.`,
200
+ tool_call_id: call.id ?? '',
201
+ name: call.name,
202
+ }));
203
+ }
204
+ // PreToolUse 的 exit 2 注入:tool 还没跑、context 怎么放都别扭,
205
+ // 选最朴素的方案——和 PostToolUse 一样在 ToolMessage 之后再追加一条 HumanMessage。
206
+ if (preHook.additionalContext) {
207
+ outputs.push(new HumanMessage(`[hook PreToolUse output]\n${preHook.additionalContext}`));
208
+ }
209
+ }
210
+ return { messages: outputs };
211
+ }
212
+ // preprocess 基于真历史 messages 生成本轮 llmInputMessages 视图。
213
+ // 默认使用摘要+最近消息;subagent 显式关闭压缩视图时直接使用完整独立历史。
214
+ async function preprocess(state, config) {
215
+ const runtime = resolveAgentRuntime(config);
216
+ const llmInputMessages = runtime.disableContextCompression
217
+ ? state.messages
218
+ : buildLlmInputMessages({
219
+ messages: state.messages,
220
+ summary: state.summary,
221
+ });
222
+ return { llmInputMessages, hookStopMessage: null };
223
+ }
224
+ async function callModel(state, config) {
225
+ const runtime = resolveAgentRuntime(config);
226
+ // preprocess 已经全权负责了 llmInputMessages 的构建(全部历史或摘要+最近 6 条),
227
+ // 这里直接使用它,不再回退到 state.messages,避免异常时跳过摘要导致 token 爆掉。
228
+ const input = state.llmInputMessages;
229
+ const response = await getModel()
230
+ .bindTools(runtime.tools)
231
+ .invoke([runtime.systemMessage, ...input]);
232
+ return { messages: [response] };
233
+ }
234
+ function shouldContinue(state) {
235
+ const last = state.messages.at(-1);
236
+ if (last?.tool_calls && last.tool_calls.length > 0) {
237
+ return 'tools';
238
+ }
239
+ return END;
240
+ }
241
+ export const agent = new StateGraph(StateAnnotation)
242
+ .addNode('preprocess', preprocess)
243
+ .addNode('model_request', callModel)
244
+ .addNode('tools', toolNode)
245
+ .addEdge(START, 'preprocess')
246
+ .addEdge('preprocess', 'model_request')
247
+ .addConditionalEdges('model_request', shouldContinue, ['tools', END])
248
+ .addEdge('tools', 'preprocess')
249
+ .compile({ checkpointer });
250
+ /**
251
+ * 对指定会话的历史消息执行压缩摘要。
252
+ * 压缩结果写入内存缓存,供下一轮 preprocess 构建 llmInputMessages 时使用。
253
+ * @param threadId 会话 ID
254
+ * @returns 若产生了新压缩返回 CompressionResult;否则返回 null(无新消息可压或压缩失败)
255
+ */
256
+ export async function compressContext(threadId) {
257
+ const config = { configurable: { thread_id: threadId } };
258
+ try {
259
+ const snapshot = await agent.getState(config);
260
+ const state = snapshot.values;
261
+ const toCompress = getMessagesToCompress({
262
+ messages: state.messages,
263
+ summarizedIndex: state.summarizedIndex,
264
+ });
265
+ if (toCompress.length === 0)
266
+ return null;
267
+ const newSummary = await summarizeMessages(toCompress);
268
+ const summary = state.summary
269
+ ? `${state.summary}\n\n--- 后续摘要 ---\n\n${newSummary}`
270
+ : newSummary;
271
+ const compressionCount = state.compressionCount + 1;
272
+ const summarizedIndex = state.messages.length - KEEP_RECENT;
273
+ await agent.updateState(config, { summary, compressionCount, summarizedIndex });
274
+ return { compressed: true, compressionCount };
275
+ }
276
+ catch {
277
+ return null;
278
+ }
279
+ }
280
+ export async function runAgentStream(userMessage, onContent, threadId = 'default-session', options = {}) {
281
+ return runAgentWithPrompt(userMessage, onContent, {
282
+ threadId,
283
+ signal: options.signal,
284
+ onConfirm: options.onConfirm,
285
+ mode: options.mode,
286
+ disableContextCompression: options.disableContextCompression,
287
+ });
288
+ }
289
+ export async function runAgentWithPrompt(userMessage, onContent, options) {
290
+ const config = createAgentRuntimeConfig(options);
291
+ const threadId = options.threadId;
292
+ const thinkFilter = createThinkFilter();
293
+ let fullResponse = '';
294
+ let lastUsage = null;
295
+ let isResume = false;
296
+ let resumeValue;
297
+ // 跟踪本轮是否被用户 ESC 中断——Stop hook 的 reason 用。
298
+ let stopReason = 'completed';
299
+ // SessionStart hook:只在非 resume(也就是 runAgentWithPrompt 第一次被调、不是 interrupt 恢复)
300
+ // 时跑一次;resume 路径代表"这条 thread 还在继续",不应再触发 SessionStart。
301
+ // additionalContext 拼到 userMessage 顶部,作为额外的 HumanMessage 上下文。
302
+ const startInput = { event: 'SessionStart', threadId, cwd: process.cwd() };
303
+ const sessionStart = await runSessionStartHook(startInput);
304
+ const effectiveUserMessage = sessionStart.additionalContext
305
+ ? `${sessionStart.additionalContext}\n\n${userMessage}`
306
+ : userMessage;
307
+ try {
308
+ while (true) {
309
+ const input = isResume
310
+ ? new Command({ resume: resumeValue })
311
+ : { messages: [new HumanMessage(effectiveUserMessage)] };
312
+ const stream = await agent.stream(input, { ...config, streamMode: 'messages' });
313
+ for await (const chunk of stream) {
314
+ const message = chunk[0];
315
+ const metadata = chunk[1];
316
+ if (metadata?.langgraph_node !== 'model_request')
317
+ continue;
318
+ // usage_metadata 通常出现在末尾的 content 为空的 chunk 上,必须在下面的过滤之前抓。
319
+ // 工具循环会有多次 model_request,取最后一个 pass 的 usage 反映「本轮结束后的上下文体量」。
320
+ const usage = message.usage_metadata;
321
+ if (usage)
322
+ lastUsage = usage;
323
+ // AIMessageChunk 的 content 在 message.content 属性上,不在 kwargs.content
324
+ const content = message.content ?? message.kwargs?.content ?? '';
325
+ const toolCallChunks = message.tool_call_chunks ?? [];
326
+ if (!content || toolCallChunks.length > 0)
327
+ continue;
328
+ const visible = thinkFilter(content);
329
+ if (!visible)
330
+ continue;
331
+ onContent(visible);
332
+ fullResponse += visible;
333
+ }
334
+ // stream 结束后检查是否被 interrupt,或是否被 hook 主动停止。
335
+ const snapshot = await agent.getState(config);
336
+ const interruptInfo = snapshot.tasks?.[0]?.interrupts?.[0];
337
+ if (!interruptInfo) {
338
+ const hookStopMessage = snapshot.values.hookStopMessage;
339
+ if (hookStopMessage) {
340
+ onContent(hookStopMessage);
341
+ fullResponse += hookStopMessage;
342
+ }
343
+ break;
344
+ }
345
+ const approved = (await options.onConfirm?.(interruptInfo.value)) ?? true;
346
+ resumeValue = { approved };
347
+ isResume = true;
348
+ }
349
+ }
350
+ catch (err) {
351
+ // ESC 中断走的也是 throw——区分一下,让 Stop hook 拿到正确的 reason。
352
+ if (options.signal?.aborted) {
353
+ stopReason = 'aborted';
354
+ }
355
+ else {
356
+ throw err;
357
+ }
358
+ }
359
+ finally {
360
+ // Stop hook:本轮会话结束(包括 ESC 中断)就跑一次。CLI 侧的 /new / exit
361
+ // 不走 runAgentStream,由 cli/index.ts 单独调 runStopHook 传不同的 reason。
362
+ await runStopHook({ event: 'Stop', threadId, reason: stopReason });
363
+ }
364
+ return { response: fullResponse, usage: lastUsage };
365
+ }
366
+ function createSubagentThreadId(parentThreadId) {
367
+ const id = crypto.randomUUID();
368
+ return parentThreadId ? `${parentThreadId}-subagent-${id}` : `subagent-session-${id}`;
369
+ }
370
+ registerSubagentRunner(async ({ prompt, parentThreadId }) => {
371
+ const threadId = createSubagentThreadId(parentThreadId);
372
+ const result = await runAgentWithPrompt(prompt, () => { }, {
373
+ threadId,
374
+ mode: 'subagent',
375
+ disableContextCompression: true,
376
+ });
377
+ return result.response;
378
+ });
379
+ // 流式过滤 <think>...</think> 块。
380
+ //
381
+ // 难点:标签可能跨 chunk 切开(如 chunk A 结尾 "<thi"、chunk B 开头 "nk>"),
382
+ // 简单的 chunk.replace 会漏;而等全部 token 收齐再过滤又破坏流式体验。
383
+ // 解决方案:两状态机 + 尾部前缀缓冲。
384
+ //
385
+ // - mode: 当前光标在 think 块内还是块外
386
+ // - buffer: 上一 chunk 尾部"可能是 <think> / </think> 前缀"的未决字符,
387
+ // 不输出也不丢弃,与下一 chunk 拼接后再判断
388
+ // - swallowLeadingWs: 刚关闭 think 块时置位,吃掉紧随的换行/空白
389
+ // (模型常输出 "</think>\n\nHi",不吃会留下空行)
390
+ function createThinkFilter() {
391
+ const OPEN = '<think>';
392
+ const CLOSE = '</think>';
393
+ let mode = 'outside';
394
+ let buffer = '';
395
+ let swallowLeadingWs = false;
396
+ // s 是 tag 的真前缀(短于 tag 且能匹配开头),用于判断尾部是否未决
397
+ const isPrefixOf = (s, tag) => s.length < tag.length && tag.startsWith(s);
398
+ return (chunk) => {
399
+ let input = buffer + chunk;
400
+ buffer = '';
401
+ let out = '';
402
+ while (input.length > 0) {
403
+ if (mode === 'outside') {
404
+ const openIdx = input.indexOf(OPEN);
405
+ if (openIdx !== -1) {
406
+ // 完整 <think> 在 input 中:输出标签前的正文,进入 inside
407
+ let head = input.slice(0, openIdx);
408
+ if (swallowLeadingWs) {
409
+ head = head.replace(/^\s+/, '');
410
+ if (head.length > 0)
411
+ swallowLeadingWs = false;
412
+ }
413
+ out += head;
414
+ input = input.slice(openIdx + OPEN.length);
415
+ mode = 'inside';
416
+ continue;
417
+ }
418
+ // 没有完整 <think>,但尾部可能是它的前缀(如 "<", "<t", "<thi")。
419
+ // 用 lastIndexOf('<') 找到最后一个候选起点;如果从那里到末尾正好
420
+ // 是 OPEN 的真前缀,就把这段存入 buffer,等下一 chunk 再裁决。
421
+ const lt = input.lastIndexOf('<');
422
+ if (lt !== -1 && isPrefixOf(input.slice(lt), OPEN)) {
423
+ let head = input.slice(0, lt);
424
+ if (swallowLeadingWs) {
425
+ head = head.replace(/^\s+/, '');
426
+ if (head.length > 0)
427
+ swallowLeadingWs = false;
428
+ }
429
+ out += head;
430
+ buffer = input.slice(lt);
431
+ input = '';
432
+ }
433
+ else {
434
+ // 整段都不可能是标签前缀,直接透传
435
+ if (swallowLeadingWs) {
436
+ input = input.replace(/^\s+/, '');
437
+ if (input.length > 0)
438
+ swallowLeadingWs = false;
439
+ }
440
+ out += input;
441
+ input = '';
442
+ }
443
+ }
444
+ else {
445
+ const closeIdx = input.indexOf(CLOSE);
446
+ if (closeIdx !== -1) {
447
+ // 完整 </think>:丢弃从开始到标签结束的所有内容(think 块内容不输出),
448
+ // 切回 outside 并置位 swallowLeadingWs,吃掉接下来的换行/空格
449
+ input = input.slice(closeIdx + CLOSE.length);
450
+ mode = 'outside';
451
+ swallowLeadingWs = true;
452
+ continue;
453
+ }
454
+ // inside 模式下整段都不输出,但仍要把"可能是 </think> 前缀"的尾部
455
+ // 留到 buffer —— 否则下一 chunk 来时,缺了前缀就匹配不到完整 CLOSE
456
+ const lt = input.lastIndexOf('<');
457
+ if (lt !== -1 && isPrefixOf(input.slice(lt), CLOSE)) {
458
+ buffer = input.slice(lt);
459
+ }
460
+ input = '';
461
+ }
462
+ }
463
+ return out;
464
+ };
465
+ }
@@ -0,0 +1,35 @@
1
+ import { ChatOpenAI } from '@langchain/openai';
2
+ import { getModelConfig } from '../config/index.js';
3
+ let modelInstance;
4
+ export function getModel() {
5
+ if (!modelInstance) {
6
+ const modelConfig = getModelConfig();
7
+ modelInstance = new ChatOpenAI({
8
+ model: modelConfig.model,
9
+ apiKey: modelConfig.apiKey,
10
+ configuration: {
11
+ baseURL: modelConfig.baseURL,
12
+ },
13
+ streaming: true,
14
+ modelKwargs: {
15
+ thinking: { type: 'disabled' },
16
+ },
17
+ });
18
+ }
19
+ return modelInstance;
20
+ }
21
+ let compressModelInstance;
22
+ export function getCompressModel() {
23
+ if (!compressModelInstance) {
24
+ const modelConfig = getModelConfig();
25
+ compressModelInstance = new ChatOpenAI({
26
+ model: modelConfig.model,
27
+ apiKey: modelConfig.apiKey,
28
+ configuration: {
29
+ baseURL: modelConfig.baseURL,
30
+ },
31
+ streaming: false,
32
+ });
33
+ }
34
+ return compressModelInstance;
35
+ }
@@ -0,0 +1,87 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { isHttpConfig, isStdioConfig } from './config.js';
6
+ export class McpServerClient {
7
+ serverName;
8
+ params;
9
+ client;
10
+ transport;
11
+ tools = [];
12
+ connected = false;
13
+ constructor(serverName, params) {
14
+ this.serverName = serverName;
15
+ this.params = params;
16
+ this.client = new Client({ name: `900-mcp-${serverName}`, version: '1.0.0' });
17
+ this.transport = this.createTransport(params);
18
+ this.transport.onerror = (err) => {
19
+ console.warn(`[MCP ${serverName}] transport error: ${err.message}`);
20
+ };
21
+ this.transport.onclose = () => {
22
+ if (this.connected) {
23
+ console.warn(`[MCP ${serverName}] connection closed.`);
24
+ this.connected = false;
25
+ this.tools = [];
26
+ }
27
+ };
28
+ }
29
+ createTransport(params) {
30
+ if (isHttpConfig(params)) {
31
+ return new StreamableHTTPClientTransport(new URL(params.url), {
32
+ requestInit: params.headers
33
+ ? { headers: params.headers }
34
+ : undefined,
35
+ });
36
+ }
37
+ if (isStdioConfig(params)) {
38
+ return new StdioClientTransport({
39
+ command: params.command,
40
+ args: params.args,
41
+ env: params.env,
42
+ stderr: 'pipe',
43
+ });
44
+ }
45
+ throw new Error('Unsupported MCP server configuration');
46
+ }
47
+ async connect() {
48
+ await this.client.connect(this.transport);
49
+ const result = await this.client.listTools();
50
+ this.tools = result.tools.map((t) => ({
51
+ name: t.name,
52
+ description: t.description ?? `MCP tool from server "${this.serverName}"`,
53
+ inputSchema: t.inputSchema,
54
+ }));
55
+ this.connected = true;
56
+ }
57
+ async disconnect() {
58
+ try {
59
+ await this.transport.close();
60
+ }
61
+ catch {
62
+ // ignore
63
+ }
64
+ this.connected = false;
65
+ this.tools = [];
66
+ }
67
+ getTools() {
68
+ return this.tools;
69
+ }
70
+ async callTool(name, args) {
71
+ if (!this.connected) {
72
+ throw new Error(`MCP server "${this.serverName}" is not connected.`);
73
+ }
74
+ const result = await this.client.callTool({ name, arguments: args }, CallToolResultSchema);
75
+ const contents = result.content;
76
+ const texts = [];
77
+ for (const item of contents) {
78
+ if (item.type === 'text' && typeof item.text === 'string') {
79
+ texts.push(item.text);
80
+ }
81
+ else {
82
+ texts.push(`[${item.type}]: ${JSON.stringify(item)}`);
83
+ }
84
+ }
85
+ return texts.join('\n\n');
86
+ }
87
+ }
@@ -0,0 +1,51 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { z } from 'zod';
3
+ import { CONFIG_FILE } from '../config/index.js';
4
+ const McpServerConfigSchema = z
5
+ .object({
6
+ command: z.string().optional(),
7
+ args: z.array(z.string()).optional(),
8
+ env: z.record(z.string(), z.string()).optional(),
9
+ url: z.string().url().optional(),
10
+ headers: z.record(z.string(), z.string()).optional(),
11
+ })
12
+ .refine((data) => {
13
+ const hasCommand = data.command !== undefined;
14
+ const hasUrl = data.url !== undefined;
15
+ return (hasCommand || hasUrl) && !(hasCommand && hasUrl);
16
+ }, {
17
+ message: 'MCP server config must have either "command" (stdio) or "url" (HTTP), not both or neither',
18
+ });
19
+ const McpConfigSchema = z.object({
20
+ mcpServers: z.record(z.string(), McpServerConfigSchema),
21
+ });
22
+ export function isStdioConfig(config) {
23
+ return 'command' in config && config.command !== undefined;
24
+ }
25
+ export function isHttpConfig(config) {
26
+ return 'url' in config && config.url !== undefined;
27
+ }
28
+ export async function readMcpConfig() {
29
+ let raw;
30
+ try {
31
+ raw = await readFile(CONFIG_FILE, 'utf8');
32
+ }
33
+ catch (err) {
34
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
35
+ return { mcpServers: {} };
36
+ }
37
+ throw err;
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ }
43
+ catch (err) {
44
+ throw new Error(`Invalid JSON in ${CONFIG_FILE}: ${err.message}`);
45
+ }
46
+ const mcpServers = parsed?.mcpServers;
47
+ if (!mcpServers) {
48
+ return { mcpServers: {} };
49
+ }
50
+ return McpConfigSchema.parse({ mcpServers });
51
+ }