minimal-agent 0.18.13 → 0.18.14

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 (174) hide show
  1. package/package.json +1 -3
  2. package/plugins/workflow-runner/src/runtime/context.js +1 -1
  3. package/plugins/workflow-runner/src/runtime/context.ts +1 -1
  4. package/plugins/workflow-runner/test/embeddedExecution.test.ts +2 -5
  5. package/plugins/workflow-runner/test/execLifetime.test.ts +15 -2
  6. package/skills/ssh-remote-dev/SKILL.md +61 -0
  7. package/skills/ssh-remote-dev/agents/openai.yaml +4 -0
  8. package/src/advisor/README.md +62 -24
  9. package/src/advisor/advisorNudge.js +199 -0
  10. package/src/advisor/advisorNudge.ts +232 -0
  11. package/src/advisor/deliveryReview.js +35 -12
  12. package/src/advisor/deliveryReview.ts +35 -11
  13. package/src/advisor/presentation.js +7 -10
  14. package/src/advisor/presentation.ts +10 -13
  15. package/src/advisor/reviewContext.js +22 -58
  16. package/src/advisor/reviewContext.ts +24 -67
  17. package/src/cli/configMenu.js +17 -128
  18. package/src/cli/configMenu.tsx +17 -193
  19. package/src/cli/print.js +2 -0
  20. package/src/cli/print.ts +2 -0
  21. package/src/cli/streamJson.js +2 -0
  22. package/src/cli/streamJson.ts +2 -0
  23. package/src/cli/wizards/advisorWizard.js +9 -7
  24. package/src/cli/wizards/advisorWizard.tsx +9 -7
  25. package/src/cli/wizards/contextAdvisorWizard.js +9 -7
  26. package/src/cli/wizards/contextAdvisorWizard.tsx +9 -7
  27. package/src/cli/wizards/providerPresets.js +27 -1
  28. package/src/cli/wizards/providerPresets.ts +44 -2
  29. package/src/cli/wizards/providerWizard.js +51 -10
  30. package/src/cli/wizards/providerWizard.tsx +59 -9
  31. package/src/config/configDefaults.js +1 -1
  32. package/src/config/configDefaults.ts +1 -1
  33. package/src/config/configDiagnostics.js +1 -21
  34. package/src/config/configDiagnostics.ts +3 -23
  35. package/src/config/configFile.js +11 -4
  36. package/src/config/configFile.ts +27 -7
  37. package/src/config/configManagement.js +1 -0
  38. package/src/config/configManagement.ts +2 -0
  39. package/src/config/configService.js +12 -8
  40. package/src/config/configService.ts +16 -11
  41. package/src/config/numericLimits.js +0 -4
  42. package/src/config/numericLimits.ts +0 -4
  43. package/src/config/reasoningEffort.js +16 -0
  44. package/src/config/reasoningEffort.ts +18 -0
  45. package/src/context/budgetPlan.js +1 -1
  46. package/src/context/budgetPlan.ts +1 -1
  47. package/src/context/optimizeConversation.js +1 -1
  48. package/src/context/optimizeConversation.ts +1 -1
  49. package/src/harness/stallGuard.js +8 -13
  50. package/src/harness/stallGuard.ts +8 -12
  51. package/src/llm/client.js +64 -17
  52. package/src/llm/client.ts +62 -7
  53. package/src/llm/providers/openaiFormat.js +14 -3
  54. package/src/llm/providers/openaiFormat.ts +17 -3
  55. package/src/llm/providers/responsesFormat.js +275 -0
  56. package/src/llm/providers/responsesFormat.ts +306 -0
  57. package/src/loop/README.md +18 -3
  58. package/src/loop/liveDiagramGate.js +63 -0
  59. package/src/loop/liveDiagramGate.ts +61 -0
  60. package/src/loop/messages.js +12 -0
  61. package/src/loop/messages.ts +13 -0
  62. package/src/loop/options.ts +7 -2
  63. package/src/loop/toolBatch.js +0 -1
  64. package/src/loop/toolBatch.ts +0 -1
  65. package/src/loop/toolExecutor.js +11 -46
  66. package/src/loop/toolExecutor.ts +12 -55
  67. package/src/loop/toolPresentation.js +2 -13
  68. package/src/loop/toolPresentation.ts +2 -13
  69. package/src/loop/toolSemantics.js +3 -4
  70. package/src/loop/toolSemantics.ts +4 -6
  71. package/src/loop.js +222 -214
  72. package/src/loop.ts +226 -232
  73. package/src/memory/dependencies.js +11 -3
  74. package/src/memory/dependencies.ts +11 -3
  75. package/src/memory/episodicMemory.js +59 -18
  76. package/src/memory/episodicMemory.ts +58 -18
  77. package/src/memory/experienceStore.js +4 -3
  78. package/src/memory/experienceStore.ts +4 -3
  79. package/src/memory/factCandidates.js +53 -1
  80. package/src/memory/factCandidates.ts +54 -1
  81. package/src/memory/factLearning.js +2 -2
  82. package/src/memory/factLearning.ts +2 -2
  83. package/src/memory/jobPolicy.js +24 -5
  84. package/src/memory/jobPolicy.ts +25 -5
  85. package/src/memory/learning.js +2 -2
  86. package/src/memory/learning.ts +2 -2
  87. package/src/memory/learningModel.js +7 -0
  88. package/src/memory/learningModel.ts +7 -0
  89. package/src/memory/managementService.js +8 -7
  90. package/src/memory/managementService.ts +16 -8
  91. package/src/memory/memoryService.js +104 -33
  92. package/src/memory/memoryService.ts +132 -28
  93. package/src/memory/projectKnowledge.js +39 -3
  94. package/src/memory/projectKnowledge.ts +45 -3
  95. package/src/memory/recallQuery.js +56 -0
  96. package/src/memory/recallQuery.ts +58 -0
  97. package/src/memory/retrieval.js +39 -10
  98. package/src/memory/retrieval.ts +46 -11
  99. package/src/memory/turnJournal.js +75 -10
  100. package/src/memory/turnJournal.ts +66 -10
  101. package/src/memory/types.ts +7 -1
  102. package/src/prompts/delegation.js +2 -1
  103. package/src/prompts/delegation.ts +3 -1
  104. package/src/prompts/policyRegistry.js +9 -12
  105. package/src/prompts/policyRegistry.ts +9 -12
  106. package/src/prompts/promptAssembly.js +6 -5
  107. package/src/prompts/promptAssembly.ts +8 -6
  108. package/src/prompts/visualPresentation.js +39 -10
  109. package/src/prompts/visualPresentation.ts +38 -9
  110. package/src/runtime/answerPreparation.js +1 -1
  111. package/src/runtime/answerPreparation.ts +1 -1
  112. package/src/runtime/fileLock.js +19 -3
  113. package/src/runtime/fileLock.ts +19 -3
  114. package/src/runtime/invocationPolicy.js +1 -1
  115. package/src/runtime/invocationPolicy.ts +1 -1
  116. package/src/runtime/outputContract.js +0 -4
  117. package/src/runtime/outputContract.ts +0 -7
  118. package/src/session/runAgentTurn.js +3 -0
  119. package/src/session/runAgentTurn.ts +3 -0
  120. package/src/team/onlineToolContract.js +0 -1
  121. package/src/team/onlineToolContract.ts +0 -1
  122. package/src/tools/advisor/advisor.js +207 -365
  123. package/src/tools/advisor/advisor.ts +211 -383
  124. package/src/tools/agent/agentTypes.js +0 -1
  125. package/src/tools/agent/agentTypes.ts +0 -1
  126. package/src/tools/agent/coordinator.js +4 -1
  127. package/src/tools/agent/coordinator.ts +4 -1
  128. package/src/tools/bash/semantics.js +4 -5
  129. package/src/tools/bash/semantics.ts +4 -5
  130. package/src/tools/capabilities.js +2 -3
  131. package/src/tools/capabilities.ts +2 -4
  132. package/src/tools/index.js +0 -16
  133. package/src/tools/index.ts +0 -16
  134. package/src/tools/memory/maintenance.js +8 -9
  135. package/src/tools/memory/maintenance.ts +13 -11
  136. package/src/tools/memory/memory.js +5 -1
  137. package/src/tools/memory/memory.ts +5 -0
  138. package/src/tools/optimizeConversation/README.md +4 -6
  139. package/src/tools/toolPromptGuides.js +2 -9
  140. package/src/tools/toolPromptGuides.ts +2 -9
  141. package/src/types.ts +28 -105
  142. package/src/ui/App.js +2 -0
  143. package/src/ui/App.tsx +1 -0
  144. package/src/ui/MessageList.js +8 -44
  145. package/src/ui/MessageList.tsx +3 -81
  146. package/src/ui/hooks/useChat.js +2 -0
  147. package/src/ui/hooks/useChat.ts +2 -0
  148. package/src/ui/toolProgress.js +1 -1
  149. package/src/ui/toolProgress.ts +1 -1
  150. package/src/web/protocol.ts +10 -3
  151. package/src/advisor/advisorCoordinator.js +0 -734
  152. package/src/advisor/advisorCoordinator.ts +0 -825
  153. package/src/cli/wizards/sshHostWizard.js +0 -156
  154. package/src/cli/wizards/sshHostWizard.tsx +0 -214
  155. package/src/context/sshHosts.js +0 -290
  156. package/src/context/sshHosts.ts +0 -312
  157. package/src/loop/publication.js +0 -94
  158. package/src/loop/publication.ts +0 -124
  159. package/src/runtime/finalAnswer.js +0 -9
  160. package/src/runtime/finalAnswer.ts +0 -13
  161. package/src/tools/advisor/advisorDecision.js +0 -79
  162. package/src/tools/advisor/advisorDecision.ts +0 -92
  163. package/src/tools/ssh/ssh.js +0 -152
  164. package/src/tools/ssh/ssh.ts +0 -167
  165. package/src/tools/ssh/sshAvailability.js +0 -35
  166. package/src/tools/ssh/sshAvailability.ts +0 -39
  167. package/src/tools/ssh/sshClient.js +0 -177
  168. package/src/tools/ssh/sshClient.ts +0 -189
  169. package/src/tools/ssh/sshDownload.js +0 -77
  170. package/src/tools/ssh/sshDownload.ts +0 -92
  171. package/src/tools/ssh/sshHostsTool.js +0 -36
  172. package/src/tools/ssh/sshHostsTool.ts +0 -48
  173. package/src/tools/ssh/sshUpload.js +0 -72
  174. package/src/tools/ssh/sshUpload.ts +0 -85
@@ -1,92 +0,0 @@
1
- import { z } from 'zod';
2
-
3
- import { configService } from '../../config/configService.js';
4
- import type { Tool, ToolCallContext, ToolResult } from '../../types.js';
5
- import { toToolParameters } from '../../utils/zodToJson.js';
6
-
7
- const inputSchema = z
8
- .object({
9
- consultationId: z
10
- .string()
11
- .min(1)
12
- .describe('Advisor 工具正文返回的完整 consultationId,必须原样使用,不缩写或猜测'),
13
- decision: z.enum(['accept', 'partial', 'reject']),
14
- rationale: z
15
- .string()
16
- .min(8)
17
- .max(20_000)
18
- .describe('逐条说明主 Agent 基于一手证据、成果检查和用户验收条件作出的判断;顾问结论不能作为自身的证据'),
19
- acceptedPoints: z.array(z.string().min(1).max(4_000)).max(20).optional()
20
- .describe('一层字符串数组,例如 ["补充互操作成本"];不要嵌套数组或 XML item 标签'),
21
- rejectedPoints: z.array(z.string().min(1).max(4_000)).max(20).optional()
22
- .describe('一层字符串数组,例如 ["不采纳必须学的措辞:证据不足"]'),
23
- verificationPlan: z.array(z.string().min(1).max(4_000)).max(20).optional()
24
- .describe('一层字符串数组,每项一个待验证步骤;没有则省略'),
25
- evidenceRefs: z.array(z.string().min(1).max(300)).max(40).optional()
26
- .describe('已核对的一手工具结果 tool_call_id,必须真实存在。rationale 说明每条建议对应哪个引用;没有证据则省略并列 verificationPlan,记录为待验证而非通过'),
27
- })
28
- .superRefine((value, ctx) => {
29
- if (
30
- value.decision === 'partial' &&
31
- (!value.acceptedPoints?.length || !value.rejectedPoints?.length)
32
- ) {
33
- ctx.addIssue({
34
- code: z.ZodIssueCode.custom,
35
- path: ['decision'],
36
- message: 'partial 必须同时列出 acceptedPoints 和 rejectedPoints',
37
- });
38
- }
39
- if (value.decision === 'reject' && !value.rejectedPoints?.length) {
40
- ctx.addIssue({
41
- code: z.ZodIssueCode.custom,
42
- path: ['rejectedPoints'],
43
- message: 'reject 必须列出被拒绝的顾问意见',
44
- });
45
- }
46
- });
47
-
48
- type AdvisorDecisionInput = z.infer<typeof inputSchema>;
49
-
50
- async function callWithContext(
51
- input: AdvisorDecisionInput,
52
- _signal: AbortSignal | undefined,
53
- context: ToolCallContext,
54
- ): Promise<ToolResult> {
55
- if (!context.advisorRuntime) {
56
- return { ok: false, error: '当前运行没有待处理的 Advisor 协调器状态。' };
57
- }
58
- return context.advisorRuntime.recordDecision(input);
59
- }
60
-
61
- export const advisorDecisionTool: Tool<AdvisorDecisionInput> = {
62
- name: 'AdvisorDecision',
63
- description:
64
- "Records the main Agent's evidence-based decision after a successful Advisor consultation. " +
65
- 'Optional audit record; this is not an execution or delivery gate. A recording error does not block the task. ' +
66
- 'The Advisor is a second opinion, not an authority: choose accept, partial, or reject based on primary evidence. ' +
67
- 'For partial/reject, explicitly identify rejected points and explain primary evidence and acceptance constraints. ' +
68
- 'Link evidenceRefs (existing tool_call_id) to points in rationale. Missing evidence is recorded as pending verification, not verified; continue the verificationPlan without another approval. A linked reference alone does not prove correctness.',
69
- inputSchema,
70
- parameters: toToolParameters(inputSchema),
71
- isReadOnly: true,
72
- isConcurrencySafe: false,
73
- interruptBehavior: () => 'cancel',
74
- resolveAvailability() {
75
- const avail = configService.advisorAvailabilitySync();
76
- if (!avail.configured) return { available: false, signature: 'unconfigured' };
77
- if (!avail.enabled || avail.mode === 'manual') {
78
- return {
79
- available: false,
80
- signature: avail.enabled ? 'manual-only' : 'disabled',
81
- };
82
- }
83
- return { available: true, signature: avail.modelId! };
84
- },
85
- async call(): Promise<ToolResult> {
86
- return {
87
- ok: false,
88
- error: 'AdvisorDecision 只能在主 Agent 会话的 Advisor 咨询后调用。',
89
- };
90
- },
91
- callWithContext,
92
- };
@@ -1,152 +0,0 @@
1
- /**
2
- * ============================================================
3
- * src/tools/ssh/ssh.ts —— SSH 工具:在已配置的远程主机上执行命令
4
- * ------------------------------------------------------------
5
- * 主机凭据按工作区(workspaceId)隔离存储(见 context/sshHosts.ts),模型
6
- * 只看到 alias,真实 host/密码不进上下文。危险命令复用本地 Bash 同一套
7
- * permanentDeleteReason() 判据(remoteShell 来自主机配置,不许硬编码),
8
- * 但拒绝文案是远程专属的——本地的 Trash 是纯本地能力,够不着远程路径。
9
- * ============================================================
10
- */
11
- import { randomUUID } from 'node:crypto';
12
- import { z } from 'zod';
13
- import { resolveSshHost } from '../../context/sshHosts.js';
14
- import { CONFIG_LIMITS } from '../../config/numericLimits.js';
15
- import { permanentDeleteReason } from '../shared/permanentDeleteGuard.js';
16
- import { toToolParameters } from '../../utils/zodToJson.js';
17
- import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
18
- import { resolveSshToolAvailability } from './sshAvailability.js';
19
- import { DEFAULT_SSH_TIMEOUT_MS, defaultSshConnect, SshExecTimeoutError } from './sshClient.js';
20
- const BACKGROUND_CONFIRM_TIMEOUT_MS = 8_000;
21
- const inputSchema = z.object({
22
- host: z.string().optional().describe('已配置的 SSH 主机别名;不填则使用该工作区的默认主机(不确定有哪些主机时先调用 SSHHosts)'),
23
- command: z.string().min(1).describe('要在远程主机上执行的命令'),
24
- timeoutMs: z.number().int().min(CONFIG_LIMITS.sshTimeoutMs.min).max(CONFIG_LIMITS.sshTimeoutMs.max).optional()
25
- .describe('本次执行超时(毫秒),默认 30000'),
26
- background: z.boolean().optional()
27
- .describe('true 时把命令包装为后台任务(setsid+nohup+日志重定向)立即返回 PID,仅支持 remoteShell=bash 的主机;用于启动长期运行的服务/进程'),
28
- });
29
- function shQuote(value) {
30
- return `'${value.replace(/'/g, `'\\''`)}'`;
31
- }
32
- async function callWith(input, signal, context, resolveHost, connectImpl) {
33
- const workspaceId = context?.workspaceId;
34
- if (!workspaceId) {
35
- return { ok: false, error: '当前运行环境没有工作区身份,无法解析 SSH 主机配置(SSH 主机按工作区隔离存储)。', recovery: 'none' };
36
- }
37
- const resolved = await resolveHost(workspaceId, input.host);
38
- if (!resolved.ok)
39
- return { ok: false, error: resolved.error, recovery: 'change-input' };
40
- const host = resolved.host;
41
- if (input.background && host.remoteShell !== 'bash') {
42
- return {
43
- ok: false,
44
- error: `主机 "${host.alias}" 的 remoteShell 是 "${host.remoteShell}",background 模式目前只支持 bash。请自行在命令里处理后台化。`,
45
- recovery: 'change-input',
46
- };
47
- }
48
- const timeoutMs = input.timeoutMs ?? host.timeoutMs ?? DEFAULT_SSH_TIMEOUT_MS;
49
- let connection;
50
- try {
51
- connection = await connectImpl(host, timeoutMs);
52
- }
53
- catch (error) {
54
- return { ok: false, error: `连接主机 "${host.alias}" 失败:${error instanceof Error ? error.message : String(error)}`, recovery: 'retry' };
55
- }
56
- try {
57
- if (input.background) {
58
- const logPath = `/tmp/.minimal-agent-ssh-bg-${randomUUID().slice(0, 8)}.log`;
59
- const wrapped = `setsid nohup sh -c ${shQuote(input.command)} </dev/null >${logPath} 2>&1 & echo $!`;
60
- try {
61
- const result = await connection.exec(wrapped, BACKGROUND_CONFIRM_TIMEOUT_MS);
62
- const pid = result.stdout.trim().match(/^\d+$/m)?.[0];
63
- return {
64
- ok: true,
65
- content: `已在 "${host.alias}" 后台启动命令。${pid ? `PID: ${pid}。` : ''}日志:${logPath}\n` +
66
- `验证请开新的 SSH 调用(不要在同一次调用里等待),示例:\n` +
67
- ` ssh(host="${host.alias}", command="tail -n 50 ${logPath}")\n` +
68
- ` ssh(host="${host.alias}", command="ps aux | grep '[标记词]' | awk '{print $2}'")(该主机可能没有 pkill,杀进程用 kill <PID>)`,
69
- sensitivity: 'local',
70
- };
71
- }
72
- catch (error) {
73
- if (error instanceof SshExecTimeoutError) {
74
- // 通道因后台进程存在而不主动关闭是预期行为(Jetson 类设备上反复验证过)——
75
- // 命令已经真的在后台跑起来了,按失败处理会诱使模型重复提交,产生重复进程。
76
- return {
77
- ok: true,
78
- content: `已向 "${host.alias}" 提交后台命令;SSH 通道未在 ${BACKGROUND_CONFIRM_TIMEOUT_MS}ms 内关闭(后台进程存在时这是预期行为,不代表失败)。` +
79
- `未能读取到确认输出(PID/日志句柄)。日志:${logPath}\n` +
80
- `请开新的 SSH 调用验证:ssh(host="${host.alias}", command="cat ${logPath}")`,
81
- sensitivity: 'local',
82
- };
83
- }
84
- throw error;
85
- }
86
- }
87
- const reason = permanentDeleteReason(input.command, host.remoteShell);
88
- if (reason) {
89
- return {
90
- ok: false,
91
- error: '该命令可能绕过删除防护、在远程主机上不可恢复(远程没有本地回收站)。建议改用 mv 把目标移到远程临时目录确认无误后再删除,或先用只读命令确认影响范围并询问用户。',
92
- recovery: 'change-input',
93
- };
94
- }
95
- const result = await connection.exec(input.command, timeoutMs);
96
- const parts = [];
97
- if (result.stdout)
98
- parts.push(`<stdout>\n${result.stdout}\n</stdout>`);
99
- if (result.stderr)
100
- parts.push(`<stderr>\n${result.stderr}\n</stderr>`);
101
- parts.push(`[exit code: ${result.code ?? (result.signal ? `killed(${result.signal})` : 'unknown')}]`);
102
- let content = parts.join('\n\n');
103
- if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
104
- content = content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
105
- `\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
106
- }
107
- if (result.code !== 0) {
108
- return { ok: false, error: `远程命令失败 (exit ${result.code}):\n${content}`, recovery: 'retry', sensitivity: 'local' };
109
- }
110
- return { ok: true, content, sensitivity: 'local' };
111
- }
112
- catch (error) {
113
- if (signal?.aborted)
114
- return { ok: false, error: 'SSH 执行已取消', recovery: 'none' };
115
- if (error instanceof SshExecTimeoutError) {
116
- const partialParts = [];
117
- if (error.partialStdout)
118
- partialParts.push(`<stdout(partial)>\n${error.partialStdout}\n</stdout(partial)>`);
119
- if (error.partialStderr)
120
- partialParts.push(`<stderr(partial)>\n${error.partialStderr}\n</stderr(partial)>`);
121
- let partial = partialParts.length > 0
122
- ? `\n\n超时前已产生的部分输出(命令可能仍在远程后台继续运行):\n${partialParts.join('\n')}`
123
- : '';
124
- // partialStdout/partialStderr 各自最多 2×DEFAULT_MAX_RESULT_SIZE_CHARS(见 sshClient.ts 的
125
- // MAX_EXEC_BUFFER_CHARS),两路拼起来可达其 4 倍——这里同样按展示层阈值截断,不能指望有
126
- // 下游机制兜底(webbrowser.ts 已有先例:maxResultSizeChars 只是声明,全仓没人统一消费它)。
127
- if (partial.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
128
- partial = partial.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
129
- `\n\n... (部分输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
130
- }
131
- return { ok: false, error: `${error.message}${partial}`, recovery: 'retry' };
132
- }
133
- return { ok: false, error: `SSH 执行失败:${error instanceof Error ? error.message : String(error)}`, recovery: 'retry' };
134
- }
135
- finally {
136
- connection.end();
137
- }
138
- }
139
- export function createSshTool(resolveHost = resolveSshHost, connectImpl = defaultSshConnect) {
140
- return {
141
- name: 'SSH',
142
- description: '在已配置的远程 SSH 主机上执行一条命令,返回 stdout/stderr/exit code。主机由用户在 /config → 远程主机(SSH) 中配置,按当前工作区隔离;不确定有哪些主机时先调用 SSHHosts。每次调用都是一条独立的新连接:cd、环境变量(export/conda activate 等)在内的所有 shell 状态不会跨调用保留,每次都从远程默认工作目录/默认环境重新开始;需要连续在同一目录/环境下操作时,把多条命令用 && 拼进同一次调用。',
143
- inputSchema,
144
- parameters: toToolParameters(inputSchema),
145
- isReadOnly: false,
146
- maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
147
- resolveAvailability: resolveSshToolAvailability,
148
- call: (input, signal) => callWith(input, signal, undefined, resolveHost, connectImpl),
149
- callWithContext: (input, signal, context) => callWith(input, signal, context, resolveHost, connectImpl),
150
- };
151
- }
152
- export const sshTool = createSshTool();
@@ -1,167 +0,0 @@
1
- /**
2
- * ============================================================
3
- * src/tools/ssh/ssh.ts —— SSH 工具:在已配置的远程主机上执行命令
4
- * ------------------------------------------------------------
5
- * 主机凭据按工作区(workspaceId)隔离存储(见 context/sshHosts.ts),模型
6
- * 只看到 alias,真实 host/密码不进上下文。危险命令复用本地 Bash 同一套
7
- * permanentDeleteReason() 判据(remoteShell 来自主机配置,不许硬编码),
8
- * 但拒绝文案是远程专属的——本地的 Trash 是纯本地能力,够不着远程路径。
9
- * ============================================================
10
- */
11
-
12
- import { randomUUID } from 'node:crypto';
13
- import { z } from 'zod';
14
-
15
- import { resolveSshHost } from '../../context/sshHosts.js';
16
- import type { Tool, ToolCallContext, ToolResult } from '../../types.js';
17
- import { CONFIG_LIMITS } from '../../config/numericLimits.js';
18
- import { permanentDeleteReason } from '../shared/permanentDeleteGuard.js';
19
- import { toToolParameters } from '../../utils/zodToJson.js';
20
- import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
21
- import { resolveSshToolAvailability } from './sshAvailability.js';
22
- import { DEFAULT_SSH_TIMEOUT_MS, defaultSshConnect, SshExecTimeoutError, type SshConnectImpl } from './sshClient.js';
23
-
24
- const BACKGROUND_CONFIRM_TIMEOUT_MS = 8_000;
25
-
26
- const inputSchema = z.object({
27
- host: z.string().optional().describe('已配置的 SSH 主机别名;不填则使用该工作区的默认主机(不确定有哪些主机时先调用 SSHHosts)'),
28
- command: z.string().min(1).describe('要在远程主机上执行的命令'),
29
- timeoutMs: z.number().int().min(CONFIG_LIMITS.sshTimeoutMs.min).max(CONFIG_LIMITS.sshTimeoutMs.max).optional()
30
- .describe('本次执行超时(毫秒),默认 30000'),
31
- background: z.boolean().optional()
32
- .describe('true 时把命令包装为后台任务(setsid+nohup+日志重定向)立即返回 PID,仅支持 remoteShell=bash 的主机;用于启动长期运行的服务/进程'),
33
- });
34
-
35
- type SshInput = z.infer<typeof inputSchema>;
36
-
37
- function shQuote(value: string): string {
38
- return `'${value.replace(/'/g, `'\\''`)}'`;
39
- }
40
-
41
- async function callWith(
42
- input: SshInput,
43
- signal: AbortSignal | undefined,
44
- context: ToolCallContext | undefined,
45
- resolveHost: typeof resolveSshHost,
46
- connectImpl: SshConnectImpl,
47
- ): Promise<ToolResult> {
48
- const workspaceId = context?.workspaceId;
49
- if (!workspaceId) {
50
- return { ok: false, error: '当前运行环境没有工作区身份,无法解析 SSH 主机配置(SSH 主机按工作区隔离存储)。', recovery: 'none' };
51
- }
52
- const resolved = await resolveHost(workspaceId, input.host);
53
- if (!resolved.ok) return { ok: false, error: resolved.error, recovery: 'change-input' };
54
- const host = resolved.host;
55
-
56
- if (input.background && host.remoteShell !== 'bash') {
57
- return {
58
- ok: false,
59
- error: `主机 "${host.alias}" 的 remoteShell 是 "${host.remoteShell}",background 模式目前只支持 bash。请自行在命令里处理后台化。`,
60
- recovery: 'change-input',
61
- };
62
- }
63
-
64
- const timeoutMs = input.timeoutMs ?? host.timeoutMs ?? DEFAULT_SSH_TIMEOUT_MS;
65
- let connection;
66
- try {
67
- connection = await connectImpl(host, timeoutMs);
68
- } catch (error) {
69
- return { ok: false, error: `连接主机 "${host.alias}" 失败:${error instanceof Error ? error.message : String(error)}`, recovery: 'retry' };
70
- }
71
-
72
- try {
73
- if (input.background) {
74
- const logPath = `/tmp/.minimal-agent-ssh-bg-${randomUUID().slice(0, 8)}.log`;
75
- const wrapped = `setsid nohup sh -c ${shQuote(input.command)} </dev/null >${logPath} 2>&1 & echo $!`;
76
- try {
77
- const result = await connection.exec(wrapped, BACKGROUND_CONFIRM_TIMEOUT_MS);
78
- const pid = result.stdout.trim().match(/^\d+$/m)?.[0];
79
- return {
80
- ok: true,
81
- content: `已在 "${host.alias}" 后台启动命令。${pid ? `PID: ${pid}。` : ''}日志:${logPath}\n` +
82
- `验证请开新的 SSH 调用(不要在同一次调用里等待),示例:\n` +
83
- ` ssh(host="${host.alias}", command="tail -n 50 ${logPath}")\n` +
84
- ` ssh(host="${host.alias}", command="ps aux | grep '[标记词]' | awk '{print $2}'")(该主机可能没有 pkill,杀进程用 kill <PID>)`,
85
- sensitivity: 'local',
86
- };
87
- } catch (error) {
88
- if (error instanceof SshExecTimeoutError) {
89
- // 通道因后台进程存在而不主动关闭是预期行为(Jetson 类设备上反复验证过)——
90
- // 命令已经真的在后台跑起来了,按失败处理会诱使模型重复提交,产生重复进程。
91
- return {
92
- ok: true,
93
- content: `已向 "${host.alias}" 提交后台命令;SSH 通道未在 ${BACKGROUND_CONFIRM_TIMEOUT_MS}ms 内关闭(后台进程存在时这是预期行为,不代表失败)。` +
94
- `未能读取到确认输出(PID/日志句柄)。日志:${logPath}\n` +
95
- `请开新的 SSH 调用验证:ssh(host="${host.alias}", command="cat ${logPath}")`,
96
- sensitivity: 'local',
97
- };
98
- }
99
- throw error;
100
- }
101
- }
102
-
103
- const reason = permanentDeleteReason(input.command, host.remoteShell);
104
- if (reason) {
105
- return {
106
- ok: false,
107
- error: '该命令可能绕过删除防护、在远程主机上不可恢复(远程没有本地回收站)。建议改用 mv 把目标移到远程临时目录确认无误后再删除,或先用只读命令确认影响范围并询问用户。',
108
- recovery: 'change-input',
109
- };
110
- }
111
-
112
- const result = await connection.exec(input.command, timeoutMs);
113
- const parts: string[] = [];
114
- if (result.stdout) parts.push(`<stdout>\n${result.stdout}\n</stdout>`);
115
- if (result.stderr) parts.push(`<stderr>\n${result.stderr}\n</stderr>`);
116
- parts.push(`[exit code: ${result.code ?? (result.signal ? `killed(${result.signal})` : 'unknown')}]`);
117
- let content = parts.join('\n\n');
118
- if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
119
- content = content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
120
- `\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
121
- }
122
- if (result.code !== 0) {
123
- return { ok: false, error: `远程命令失败 (exit ${result.code}):\n${content}`, recovery: 'retry', sensitivity: 'local' };
124
- }
125
- return { ok: true, content, sensitivity: 'local' };
126
- } catch (error) {
127
- if (signal?.aborted) return { ok: false, error: 'SSH 执行已取消', recovery: 'none' };
128
- if (error instanceof SshExecTimeoutError) {
129
- const partialParts: string[] = [];
130
- if (error.partialStdout) partialParts.push(`<stdout(partial)>\n${error.partialStdout}\n</stdout(partial)>`);
131
- if (error.partialStderr) partialParts.push(`<stderr(partial)>\n${error.partialStderr}\n</stderr(partial)>`);
132
- let partial = partialParts.length > 0
133
- ? `\n\n超时前已产生的部分输出(命令可能仍在远程后台继续运行):\n${partialParts.join('\n')}`
134
- : '';
135
- // partialStdout/partialStderr 各自最多 2×DEFAULT_MAX_RESULT_SIZE_CHARS(见 sshClient.ts 的
136
- // MAX_EXEC_BUFFER_CHARS),两路拼起来可达其 4 倍——这里同样按展示层阈值截断,不能指望有
137
- // 下游机制兜底(webbrowser.ts 已有先例:maxResultSizeChars 只是声明,全仓没人统一消费它)。
138
- if (partial.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
139
- partial = partial.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
140
- `\n\n... (部分输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
141
- }
142
- return { ok: false, error: `${error.message}${partial}`, recovery: 'retry' };
143
- }
144
- return { ok: false, error: `SSH 执行失败:${error instanceof Error ? error.message : String(error)}`, recovery: 'retry' };
145
- } finally {
146
- connection.end();
147
- }
148
- }
149
-
150
- export function createSshTool(
151
- resolveHost: typeof resolveSshHost = resolveSshHost,
152
- connectImpl: SshConnectImpl = defaultSshConnect,
153
- ): Tool<SshInput> {
154
- return {
155
- name: 'SSH',
156
- description: '在已配置的远程 SSH 主机上执行一条命令,返回 stdout/stderr/exit code。主机由用户在 /config → 远程主机(SSH) 中配置,按当前工作区隔离;不确定有哪些主机时先调用 SSHHosts。每次调用都是一条独立的新连接:cd、环境变量(export/conda activate 等)在内的所有 shell 状态不会跨调用保留,每次都从远程默认工作目录/默认环境重新开始;需要连续在同一目录/环境下操作时,把多条命令用 && 拼进同一次调用。',
157
- inputSchema,
158
- parameters: toToolParameters(inputSchema),
159
- isReadOnly: false,
160
- maxResultSizeChars: DEFAULT_MAX_RESULT_SIZE_CHARS,
161
- resolveAvailability: resolveSshToolAvailability,
162
- call: (input, signal) => callWith(input, signal, undefined, resolveHost, connectImpl),
163
- callWithContext: (input, signal, context) => callWith(input, signal, context, resolveHost, connectImpl),
164
- };
165
- }
166
-
167
- export const sshTool = createSshTool();
@@ -1,35 +0,0 @@
1
- /**
2
- * ============================================================
3
- * src/tools/ssh/sshAvailability.ts —— SSH 四件套共用的 resolveAvailability
4
- * ------------------------------------------------------------
5
- * 仿 tools/lsp/config.ts::lspAvailabilitySignature 的做法:同步探测
6
- * ssh-hosts.json 的存在性/mtime/size 算出 signature,配置一变工具集
7
- * 签名跟着变,不需要重启进程或重开会话就能让新增主机对模型可见
8
- * (见 tools/effectiveToolSet.ts 的 signature 参与缓存 key 这条链路)。
9
- *
10
- * 取舍同 advisor.ts 的既有先例——"少一个工具比多一个工具严重得多":
11
- * 拿不到 workspaceId、或读取过程出现非预期错误(损坏 JSON、权限问题等)
12
- * 一律 fail-open 判可用;只有明确读到"从未配置过任何主机"(文件不存在)
13
- * 才判不可用。真正的凭据解析仍然走 resolveSshHost() 的完整校验,这里
14
- * 只是减少零主机场景下的工具列表噪音,不是安全边界。
15
- * ============================================================
16
- */
17
- import { existsSync, readFileSync, statSync } from 'node:fs';
18
- import { sshHostsFilePath } from '../../context/sshHosts.js';
19
- export function resolveSshToolAvailability(runtime) {
20
- const workspaceId = runtime.workspaceId;
21
- if (!workspaceId)
22
- return { available: true, signature: 'no-workspace' };
23
- const path = sshHostsFilePath(workspaceId);
24
- if (!existsSync(path))
25
- return { available: false, signature: `${workspaceId}:empty` };
26
- try {
27
- const stat = statSync(path);
28
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
29
- const hasHosts = Array.isArray(parsed.hosts) && parsed.hosts.length > 0;
30
- return { available: hasHosts, signature: `${workspaceId}:${stat.mtimeMs}:${stat.size}:${hasHosts}` };
31
- }
32
- catch {
33
- return { available: true, signature: `${workspaceId}:unreadable` };
34
- }
35
- }
@@ -1,39 +0,0 @@
1
- /**
2
- * ============================================================
3
- * src/tools/ssh/sshAvailability.ts —— SSH 四件套共用的 resolveAvailability
4
- * ------------------------------------------------------------
5
- * 仿 tools/lsp/config.ts::lspAvailabilitySignature 的做法:同步探测
6
- * ssh-hosts.json 的存在性/mtime/size 算出 signature,配置一变工具集
7
- * 签名跟着变,不需要重启进程或重开会话就能让新增主机对模型可见
8
- * (见 tools/effectiveToolSet.ts 的 signature 参与缓存 key 这条链路)。
9
- *
10
- * 取舍同 advisor.ts 的既有先例——"少一个工具比多一个工具严重得多":
11
- * 拿不到 workspaceId、或读取过程出现非预期错误(损坏 JSON、权限问题等)
12
- * 一律 fail-open 判可用;只有明确读到"从未配置过任何主机"(文件不存在)
13
- * 才判不可用。真正的凭据解析仍然走 resolveSshHost() 的完整校验,这里
14
- * 只是减少零主机场景下的工具列表噪音,不是安全边界。
15
- * ============================================================
16
- */
17
-
18
- import { existsSync, readFileSync, statSync } from 'node:fs';
19
-
20
- import { sshHostsFilePath } from '../../context/sshHosts.js';
21
- import type { AgentRuntimeContext } from '../../runtime/agentRuntimeContext.js';
22
- import type { ToolAvailability } from '../../types.js';
23
-
24
- export function resolveSshToolAvailability(runtime: AgentRuntimeContext): ToolAvailability {
25
- const workspaceId = runtime.workspaceId;
26
- if (!workspaceId) return { available: true, signature: 'no-workspace' };
27
-
28
- const path = sshHostsFilePath(workspaceId);
29
- if (!existsSync(path)) return { available: false, signature: `${workspaceId}:empty` };
30
-
31
- try {
32
- const stat = statSync(path);
33
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as { hosts?: unknown[] };
34
- const hasHosts = Array.isArray(parsed.hosts) && parsed.hosts.length > 0;
35
- return { available: hasHosts, signature: `${workspaceId}:${stat.mtimeMs}:${stat.size}:${hasHosts}` };
36
- } catch {
37
- return { available: true, signature: `${workspaceId}:unreadable` };
38
- }
39
- }
@@ -1,177 +0,0 @@
1
- /**
2
- * ============================================================
3
- * src/tools/ssh/sshClient.ts —— ssh2 的薄封装(Promise 化 exec / sftp)
4
- * ------------------------------------------------------------
5
- * 只做协议层:连接、执行命令、传文件。主机解析(别名→凭据)、危险命令拦截、
6
- * 后台模式语义全部在调用方(ssh.ts / sshUpload.ts / sshDownload.ts)处理,
7
- * 这里不掺业务判断,方便测试用假实现替换(见 createSshTool 系列的 DI 参数)。
8
- * ============================================================
9
- */
10
- import { readFile } from 'node:fs/promises';
11
- import { posix } from 'node:path';
12
- import { Client } from 'ssh2';
13
- import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
14
- /** 三个 SSH 工具(exec/upload/download)共用的默认超时——唯一真值。 */
15
- export const DEFAULT_SSH_TIMEOUT_MS = 30_000;
16
- /**
17
- * exec() 累积 stdout/stderr 时的内存安全上限(字符,每路各自)——远程命令狂输出
18
- * (如误连到日志尾/`cat` 大文件)时防止字符串无限增长撑爆内存。跟 Bash 工具的
19
- * `MAX_STREAM_BUFFER_CHARS` 同一思路:取展示层截断阈值(`ssh.ts` 里做)的 2 倍,
20
- * 正常输出不受影响。这里只做内存兜底,不做背压/归档——那是业务逻辑,本文件按头注
21
- * 原则不掺(真要背压得像 Bash 那样接 `ToolOutputStore`,成本对协议薄封装层不划算)。
22
- */
23
- const MAX_EXEC_BUFFER_CHARS = DEFAULT_MAX_RESULT_SIZE_CHARS * 2;
24
- function appendCapped(current, chunk, capChars) {
25
- if (current.length >= capChars)
26
- return current;
27
- const combined = current + chunk;
28
- return combined.length > capChars ? combined.slice(0, capChars) : combined;
29
- }
30
- /** 专门区分「执行超时」与其它失败(认证/网络错误)——background 模式要能识别前者。 */
31
- export class SshExecTimeoutError extends Error {
32
- /** 超时发生前已经收到的部分输出(exec 用)——调用方可选择性地把它带给模型,而不是整段丢弃。 */
33
- partialStdout;
34
- partialStderr;
35
- constructor(timeoutMs, partial) {
36
- super(`执行超时(${timeoutMs}ms)`);
37
- this.name = 'SshExecTimeoutError';
38
- this.partialStdout = partial?.stdout;
39
- this.partialStderr = partial?.stderr;
40
- }
41
- }
42
- async function buildConnectConfig(host, connectTimeoutMs) {
43
- const base = {
44
- host: host.host,
45
- port: host.port,
46
- username: host.username,
47
- readyTimeout: connectTimeoutMs,
48
- };
49
- if (host.authMethod === 'password')
50
- return { ...base, password: host.password };
51
- const privateKey = await readFile(host.privateKeyPath);
52
- return { ...base, privateKey, ...(host.passphrase ? { passphrase: host.passphrase } : {}) };
53
- }
54
- /**
55
- * 逐级 mkdir 出 remotePath 的父目录(SFTP 没有 `mkdir -p`,ssh2 的 `sftp.mkdir` 一次只建一层)。
56
- * 每一级都直接 mkdir、忽略错误(多数情况下是"已存在")——不去解析具体 SFTP 错误码区分
57
- * "已存在" vs "无权限":无权限时 mkdir 静默失败,紧随其后的 fastPut 会用清楚得多的错误
58
- * 报出真实原因,没必要在这里重复判断一遍。
59
- */
60
- function sftpMkdirParents(sftp, remotePath) {
61
- const dir = posix.dirname(remotePath);
62
- if (dir === '.' || dir === '/' || dir === '')
63
- return Promise.resolve();
64
- const absolute = dir.startsWith('/');
65
- const segments = dir.split('/').filter(Boolean);
66
- let current = '';
67
- let chain = Promise.resolve();
68
- for (const seg of segments) {
69
- current += `/${seg}`;
70
- const path = absolute ? current : current.slice(1);
71
- chain = chain.then(() => new Promise((resolve) => sftp.mkdir(path, () => resolve())));
72
- }
73
- return chain;
74
- }
75
- /** 导出仅供单测直接驱动(绕开真实 ssh2.Client 网络连接,喂假 exec/sftp 回调验证超时与 mkdir-parents 行为)。 */
76
- export function wrapConnection(conn) {
77
- return {
78
- exec(command, timeoutMs) {
79
- return new Promise((resolvePromise, rejectPromise) => {
80
- let stdout = '';
81
- let stderr = '';
82
- const timer = setTimeout(() => {
83
- conn.end();
84
- rejectPromise(new SshExecTimeoutError(timeoutMs, { stdout, stderr }));
85
- }, timeoutMs);
86
- conn.exec(command, (execError, stream) => {
87
- if (execError) {
88
- clearTimeout(timer);
89
- rejectPromise(execError);
90
- return;
91
- }
92
- stream
93
- .on('close', (code, signal) => {
94
- clearTimeout(timer);
95
- resolvePromise({ stdout, stderr, code, signal: signal ?? null });
96
- })
97
- .on('data', (data) => { stdout = appendCapped(stdout, data.toString('utf8'), MAX_EXEC_BUFFER_CHARS); })
98
- .stderr.on('data', (data) => { stderr = appendCapped(stderr, data.toString('utf8'), MAX_EXEC_BUFFER_CHARS); });
99
- });
100
- });
101
- },
102
- put(localPath, remotePath, timeoutMs) {
103
- return new Promise((resolvePromise, rejectPromise) => {
104
- const timer = setTimeout(() => {
105
- conn.end();
106
- rejectPromise(new SshExecTimeoutError(timeoutMs));
107
- }, timeoutMs);
108
- conn.sftp((sftpError, sftp) => {
109
- if (sftpError) {
110
- clearTimeout(timer);
111
- rejectPromise(sftpError);
112
- return;
113
- }
114
- sftpMkdirParents(sftp, remotePath)
115
- .then(() => {
116
- sftp.fastPut(localPath, remotePath, (putError) => {
117
- if (putError) {
118
- clearTimeout(timer);
119
- rejectPromise(putError);
120
- return;
121
- }
122
- sftp.stat(remotePath, (statError, stats) => {
123
- clearTimeout(timer);
124
- resolvePromise(statError ? 0 : stats.size);
125
- });
126
- });
127
- })
128
- // sftp.mkdir 的回调本身吞错误恒 resolve(见 sftpMkdirParents 头注),这里防的是
129
- // 更早一层:sftp 连接在 mkdir 期间掉线导致同步抛出——没有这个 catch,链路会挂成
130
- // 未处理 rejection,外层 Promise 只能干等到 timer 触发,模型看到的是「执行超时」
131
- // 而不是真实的连接错误。
132
- .catch((mkdirError) => { clearTimeout(timer); rejectPromise(mkdirError); });
133
- });
134
- });
135
- },
136
- get(remotePath, localPath, timeoutMs) {
137
- return new Promise((resolvePromise, rejectPromise) => {
138
- const timer = setTimeout(() => {
139
- conn.end();
140
- rejectPromise(new SshExecTimeoutError(timeoutMs));
141
- }, timeoutMs);
142
- conn.sftp((sftpError, sftp) => {
143
- if (sftpError) {
144
- clearTimeout(timer);
145
- rejectPromise(sftpError);
146
- return;
147
- }
148
- sftp.fastGet(remotePath, localPath, (getError) => {
149
- if (getError) {
150
- clearTimeout(timer);
151
- rejectPromise(getError);
152
- return;
153
- }
154
- sftp.stat(remotePath, (statError, stats) => {
155
- clearTimeout(timer);
156
- resolvePromise(statError ? 0 : stats.size);
157
- });
158
- });
159
- });
160
- });
161
- },
162
- end() {
163
- conn.end();
164
- },
165
- };
166
- }
167
- /** 生产环境的真实连接实现——测试通过 DI 参数注入假实现,不经过这里。 */
168
- export const defaultSshConnect = async (host, connectTimeoutMs) => {
169
- const config = await buildConnectConfig(host, connectTimeoutMs);
170
- return new Promise((resolvePromise, rejectPromise) => {
171
- const conn = new Client();
172
- conn
173
- .on('ready', () => resolvePromise(wrapConnection(conn)))
174
- .on('error', (error) => rejectPromise(error))
175
- .connect(config);
176
- });
177
- };