ソースを参照

feat: 脚本调用工具 run_script(模型写 Python 脚本,网关在沙箱中批量调用 MCP 工具;写工具执行前统一审批一次、破坏性工具不可用;审计一条汇总、明细写入工作区;会话中显示脚本进度)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 4 日 前
親
コミット
fbcf388620

+ 10 - 0
apps/admin/src/views/SettingsView.vue

@@ -154,6 +154,16 @@ const LABELS: Record<(typeof PATH_KEYS)[number], string> = {
           <div class="hint">外部工具(MCP 服务)的调用超过时限仍未返回时转为后台:AI 先结束这一轮,服务端继续等待,完成后把结果发给发起方。已达同时进行的上限时,按原来的方式等待(最长 10 分钟)。</div>
         </section>
 
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">脚本调用工具</h2>
+          <div class="grid grid-cols-3 gap-3">
+            <div><label class="label" for="sc-calls">每个脚本最多调用工具次数</label><input id="sc-calls" v-model.number="draft.scripts.maxCalls" type="number" min="1" class="field"></div>
+            <div><label class="label" for="sc-conc">同时进行的调用数</label><input id="sc-conc" v-model.number="draft.scripts.concurrency" type="number" min="1" max="16" class="field"></div>
+            <div><label class="label" for="sc-min">每个脚本最长运行(分钟)</label><input id="sc-min" v-model.number="draft.scripts.maxMinutes" type="number" min="1" class="field"></div>
+          </div>
+          <div class="hint">AI 可以写 Python 脚本批量调用 MCP 工具(run_script),在脚本中汇总后只把结果交回,适合需要调用几百次工具的任务。脚本在沙箱中运行,只能读写会话工作区,不能联网;会修改数据的工具执行前统一审批,破坏性工具不能在脚本中调用。</div>
+        </section>
+
         <section class="flex flex-col gap-3">
           <h2 class="section-title m-0">定时任务</h2>
           <div class="grid grid-cols-3 gap-3">

+ 1 - 0
apps/server/src/api-reference/reference.ts

@@ -424,6 +424,7 @@ export const API_EVENTS: ApiEventDef[] = [
   { type: 'file.changed', payload: '{ fileId, path, kind: "created" | "modified", size, mime }', desc: '工作区中新建或修改了文件(Agent 产出)', persisted: true },
   { type: 'interaction.required', payload: 'InteractionRequest', desc: '需要用户审批或回答问题', persisted: true },
   { type: 'interaction.resolved', payload: '{ interactionId, resolution, answers?, reason?, content? }', desc: 'resolution:allowed / denied / answered / timeout / cancelled(表单提交为 answered,带 content)。多个页面同时打开时用于同步关闭卡片。外部服务在后台长任务中发来的询问不属于某一轮(runId 为 null),不改变 Run 状态', persisted: true },
+  { type: 'script.progress', payload: '{ scriptId, title, status, calls, failures, done, total, message, elapsedMs }', desc: 'AI 用脚本批量调用工具(run_script)时的进度,每秒最多一次;status 为 running / completed / failed / cancelled。done、total、message 是脚本自己报告的进度(没有时为 null)。只推送、不保存,刷新后从下一次进度开始显示', persisted: false },
   { type: 'usage', payload: 'Usage', desc: '本次 Run 的 token 用量', persisted: true },
   { type: 'run.completed', payload: '{ durationMs }', desc: 'Run 正常结束', persisted: true },
   { type: 'run.failed', payload: '{ code, message }', desc: 'Run 失败', persisted: true },

+ 342 - 0
apps/server/src/mcp-gateway/script.ts

@@ -0,0 +1,342 @@
+// 脚本调用工具(docs/design/17-run-script.md):模型写一段 Python 脚本,由网关在沙箱中执行;脚本通过 agent_tools 模块调用
+// MCP 工具(每次调用都经过网关:工具策略、结果处理与普通调用相同,只是不经过模型),循环、汇总、写文件都在脚本中完成,
+// 只有脚本打印的内容交回模型。脚本与网关之间用两条管道(fd 3 发请求、fd 4 收回复)传 JSON 行
+import { spawn } from 'node:child_process'
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import type { ScriptProgressPayload } from '@agent-server/shared'
+import { z } from 'zod'
+import { config } from '../config'
+import { sandboxArgs } from '../services/office'
+import type { ToolResult } from './builtin'
+import type { GatewayRunContext } from './tokens'
+
+export const RUN_SCRIPT = 'run_script'
+
+/** 交给模型的输出上限;完整输出保存为文件 */
+const MAX_OUTPUT = 20_000
+const MAX_STDERR = 4000
+/** 每次调用返回给脚本的文本上限 */
+const MAX_CALL_TEXT = 1_000_000
+const PROGRESS_INTERVAL_MS = 1000
+
+export const runScriptSchema = z.object({
+  code: z.string().min(1).max(200_000).describe('Python 脚本(完整代码)'),
+  tools: z.array(z.string()).max(100).default([]).describe('脚本中会调用的、会修改数据的工具(工具全名)。只读工具不用列出;没有列出的写工具在脚本中调用会失败'),
+  description: z.string().max(200).default('').describe('一句话说明脚本做什么(显示在进度中)'),
+})
+
+export const RUN_SCRIPT_DESCRIPTION = `执行一段 Python 脚本,在脚本中批量调用上面列出的 MCP 工具(工具全名,形如 服务名__工具名),汇总后只返回脚本打印的内容。
+适合需要调用很多次工具(几十到上千次)、或要对大量结果做筛选统计的任务:比逐次调用快得多,也不占对话的上下文。
+脚本中:
+  from agent_tools import call, call_many, call_raw, progress, ToolError
+  call(tool, args)            调用一次;返回结构化结果(dict)或文本(str);工具报错时抛出 ToolError
+  call_many(tool, args_list)  批量调用同一个工具(平台并发执行),按顺序返回结果列表;return_errors=True 时出错的位置返回 ToolError 而不抛出
+  call_raw(tool, args)        返回 {"text", "structured", "is_error"},不抛出工具错误
+  progress(done, total, message)  报告进度,会显示在对话中
+运行环境:与 python 命令相同的 Python 运行环境(pandas、numpy 等可用),当前目录为会话工作区,可以读写工作区中的文件(把大结果写成文件,只打印摘要);不能联网,不能访问工作区以外的文件。
+规则:只读工具可以直接调用;会修改数据的工具必须在 tools 参数中列出,执行前统一审批一次;破坏性工具(每次都要确认的)不能在脚本中调用,请直接调用。每个脚本调用工具的次数、并发数、运行时间有上限,运行较久时会转为后台执行,完成后结果会发给你。`
+
+/** 脚本中可以调用的工具 */
+export interface ScriptTool {
+  readOnly: boolean
+  /** 调用工具:返回给脚本的文本、结构化结果、是否出错 */
+  invoke: (args: Record<string, unknown>, signal: AbortSignal) => Promise<{ text: string; structured: unknown; isError: boolean }>
+}
+
+/** 脚本中 import 的辅助模块 */
+const HELPER = `"""AgentServer 提供的脚本辅助模块:调用 MCP 工具、报告进度(docs/design/17-run-script.md)"""
+import itertools as _it
+import json as _json
+import os as _os
+import threading as _th
+
+__all__ = ["call", "call_many", "call_raw", "progress", "ToolError"]
+
+_out = _os.fdopen(3, "w", encoding="utf-8")
+_in = _os.fdopen(4, "r", encoding="utf-8")
+_lock = _th.Lock()
+_ids = _it.count(1)
+_BATCH = 50
+
+
+class ToolError(Exception):
+    """工具返回错误,或平台拒绝了这次调用"""
+
+    def __init__(self, tool, message):
+        super().__init__(f"{tool}: {message}")
+        self.tool = tool
+        self.message = message
+
+
+def _send(msg):
+    _out.write(_json.dumps(msg, ensure_ascii=False) + "\\n")
+    _out.flush()
+
+
+def _request(msgs):
+    with _lock:
+        for m in msgs:
+            _send(m)
+        pending = {m["id"] for m in msgs}
+        got = {}
+        while pending:
+            line = _in.readline()
+            if not line:
+                raise RuntimeError("与平台的连接已断开")
+            r = _json.loads(line)
+            got[r["id"]] = r
+            pending.discard(r["id"])
+        return got
+
+
+def _raw(r):
+    return {"text": r.get("text", ""), "structured": r.get("structured"), "is_error": bool(r.get("error") or r.get("isError"))}
+
+
+def _value(tool, r):
+    if r.get("error"):
+        raise ToolError(tool, r["error"])
+    if r.get("isError"):
+        raise ToolError(tool, r.get("text", ""))
+    return r["structured"] if r.get("structured") is not None else r.get("text", "")
+
+
+def call_raw(tool, args=None):
+    i = next(_ids)
+    r = _request([{"id": i, "type": "call", "tool": tool, "args": args or {}}])[i]
+    if r.get("error"):
+        raise ToolError(tool, r["error"])
+    return _raw(r)
+
+
+def call(tool, args=None):
+    i = next(_ids)
+    return _value(tool, _request([{"id": i, "type": "call", "tool": tool, "args": args or {}}])[i])
+
+
+def call_many(tool, args_list, return_errors=False):
+    out = []
+    items = list(args_list)
+    for start in range(0, len(items), _BATCH):
+        msgs = [{"id": next(_ids), "type": "call", "tool": tool, "args": a or {}} for a in items[start:start + _BATCH]]
+        got = _request(msgs)
+        for m in msgs:
+            try:
+                out.append(_value(tool, got[m["id"]]))
+            except ToolError as e:
+                if not return_errors:
+                    raise
+                out.append(e)
+    return out
+
+
+def progress(done, total=None, message=None):
+    with _lock:
+        _send({"type": "progress", "done": done, "total": total, "message": None if message is None else str(message)[:200]})
+`
+
+/** 辅助模块所在目录(启动时写入数据目录,沙箱中只读) */
+export function scriptHelperDir(): string {
+  const dir = path.join(config.dataDir, 'data', 'script-runtime')
+  const file = path.join(dir, 'agent_tools.py')
+  if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== HELPER) {
+    fs.mkdirSync(dir, { recursive: true })
+    fs.writeFileSync(file, HELPER)
+  }
+  return dir
+}
+
+export interface ScriptRunOptions {
+  ctx: GatewayRunContext
+  code: string
+  title: string
+  tools: Map<string, ScriptTool>
+  /** 只读模式:工作区只读 */
+  readOnly: boolean
+  signal: AbortSignal
+  limits: { maxCalls: number; concurrency: number; maxMinutes: number }
+  pythonRuntime: string
+}
+
+export interface ScriptSummary {
+  id: string
+  exitCode: number | null
+  calls: number
+  failures: number
+  byTool: Record<string, number>
+  ms: number
+  logDir: string
+}
+
+/** 在沙箱中执行脚本,返回交给模型的结果 */
+export async function runScript(o: ScriptRunOptions): Promise<{ result: ToolResult; summary: ScriptSummary }> {
+  const id = `scr_${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`
+  const ws = fs.realpathSync(o.ctx.workspaceDir)
+  const logDir = path.join(ws, '.agent', 'scripts', id)
+  fs.mkdirSync(logDir, { recursive: true })
+  const scriptFile = path.join(logDir, 'script.py')
+  fs.writeFileSync(scriptFile, o.code)
+  const callLog = fs.createWriteStream(path.join(logDir, 'calls.jsonl'))
+  const rel = (p: string) => path.relative(ws, p)
+
+  const started = Date.now()
+  const stats = { calls: 0, failures: 0, byTool: {} as Record<string, number> }
+  const reported = { done: null as number | null, total: null as number | null, message: null as string | null }
+  let lastProgress = 0
+  const emit = (status: ScriptProgressPayload['status'], force = false) => {
+    if (!o.ctx.progress) return
+    const now = Date.now()
+    if (!force && now - lastProgress < PROGRESS_INTERVAL_MS) return
+    lastProgress = now
+    try {
+      o.ctx.progress({ scriptId: id, title: o.title, status, calls: stats.calls, failures: stats.failures, ...reported, elapsedMs: now - started })
+    } catch {
+      // 会话已删除等:进度只是展示
+    }
+  }
+  const progressTimer = setInterval(() => emit('running'), PROGRESS_INTERVAL_MS)
+
+  const runtime = fs.realpathSync(o.pythonRuntime)
+  const helper = scriptHelperDir()
+  const sandbox = sandboxArgs(ws, [runtime, helper])
+  // 只读模式:工作区只读(脚本仍可以打印结果)
+  const args = o.readOnly ? sandbox.map((a, i) => (a === '--bind' && sandbox[i + 1] === ws ? '--ro-bind' : a)) : sandbox
+  const child = spawn('bwrap', [
+    ...args,
+    '--setenv', 'PYTHONPATH', helper,
+    '--setenv', 'PYTHONUNBUFFERED', '1',
+    '--setenv', 'PYTHONDONTWRITEBYTECODE', '1',
+    '--setenv', 'MPLBACKEND', 'Agg',
+    '--setenv', 'MPLCONFIGDIR', '/tmp',
+    '--chdir', ws,
+    path.join(runtime, 'bin/python'), scriptFile,
+  ], { stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] })
+  const toScript = child.stdio[4] as NodeJS.WritableStream
+  const fromScript = child.stdio[3] as NodeJS.ReadableStream
+
+  let stdout = ''
+  let stdoutBytes = 0
+  const stdoutFile = fs.createWriteStream(path.join(logDir, 'output.txt'))
+  child.stdout!.on('data', (d: Buffer) => {
+    stdoutFile.write(d)
+    stdoutBytes += d.length
+    if (stdout.length < MAX_OUTPUT * 2) stdout += d.toString('utf8')
+  })
+  let stderr = ''
+  child.stderr!.on('data', (d: Buffer) => {
+    stderr = (stderr + d.toString('utf8')).slice(-MAX_STDERR * 4)
+  })
+
+  // ---- 脚本的工具调用:限制总次数与并发 ----
+  let active = 0
+  const queue: (() => void)[] = []
+  const acquire = () => new Promise<void>(resolve => {
+    if (active < o.limits.concurrency) {
+      active++
+      resolve()
+    } else queue.push(resolve)
+  })
+  const release = () => {
+    const next = queue.shift()
+    if (next) next()
+    else active--
+  }
+  const controller = new AbortController()
+  const reply = (msg: Record<string, unknown>) => {
+    if (!toScript.writable) return
+    try {
+      toScript.write(`${JSON.stringify(msg)}\n`)
+    } catch {
+      // 脚本已退出
+    }
+  }
+  const handleCall = async (m: { id: number; tool?: unknown; args?: unknown }) => {
+    const tool = typeof m.tool === 'string' ? m.tool : ''
+    const t = o.tools.get(tool)
+    if (!t) return reply({ id: m.id, error: `工具不可用:${tool}(只读工具可以直接调用;会修改数据的工具需要在 tools 参数中列出;破坏性工具不能在脚本中调用)` })
+    if (stats.calls >= o.limits.maxCalls) return reply({ id: m.id, error: `已达到每个脚本调用工具的上限(${o.limits.maxCalls} 次)` })
+    stats.calls++
+    stats.byTool[tool] = (stats.byTool[tool] ?? 0) + 1
+    await acquire()
+    const t0 = Date.now()
+    try {
+      const args = m.args && typeof m.args === 'object' && !Array.isArray(m.args) ? m.args as Record<string, unknown> : {}
+      const r = await t.invoke(args, controller.signal)
+      if (r.isError) stats.failures++
+      callLog.write(`${JSON.stringify({ at: new Date(t0).toISOString(), tool, args, ms: Date.now() - t0, isError: r.isError, text: r.text.slice(0, 500) })}\n`)
+      reply({ id: m.id, text: r.text.slice(0, MAX_CALL_TEXT), structured: r.structured ?? null, isError: r.isError })
+    } catch (err) {
+      stats.failures++
+      callLog.write(`${JSON.stringify({ at: new Date(t0).toISOString(), tool, ms: Date.now() - t0, error: (err as Error).message })}\n`)
+      reply({ id: m.id, error: (err as Error).message })
+    } finally {
+      release()
+      emit('running')
+    }
+  }
+  let buf = ''
+  fromScript.on('data', (d: Buffer) => {
+    buf += d.toString('utf8')
+    let nl: number
+    while ((nl = buf.indexOf('\n')) >= 0) {
+      const line = buf.slice(0, nl)
+      buf = buf.slice(nl + 1)
+      let m: { type?: string; id?: number; tool?: unknown; args?: unknown; done?: unknown; total?: unknown; message?: unknown }
+      try {
+        m = JSON.parse(line)
+      } catch {
+        continue
+      }
+      if (m.type === 'call' && typeof m.id === 'number') void handleCall(m as { id: number })
+      else if (m.type === 'progress') {
+        reported.done = typeof m.done === 'number' ? m.done : null
+        reported.total = typeof m.total === 'number' ? m.total : null
+        reported.message = typeof m.message === 'string' ? m.message : null
+        emit('running')
+      }
+    }
+  })
+
+  // ---- 结束:正常退出、超时、中断 ----
+  let stopReason: string | null = null
+  const kill = (reason: string) => {
+    stopReason ??= reason
+    controller.abort()
+    child.kill('SIGKILL')
+  }
+  const timer = setTimeout(() => kill(`超过 ${o.limits.maxMinutes} 分钟,已停止`), o.limits.maxMinutes * 60_000)
+  const onAbort = () => kill('已取消')
+  o.signal.addEventListener('abort', onAbort, { once: true })
+  emit('running', true)
+
+  const exitCode = await new Promise<number | null>(resolve => {
+    child.on('error', err => {
+      stderr += `\n${err.message}`
+      resolve(null)
+    })
+    child.on('close', code => resolve(code))
+  })
+  clearTimeout(timer)
+  clearInterval(progressTimer)
+  o.signal.removeEventListener('abort', onAbort)
+  controller.abort()
+  await Promise.all([new Promise(r => callLog.end(r)), new Promise(r => stdoutFile.end(r))])
+
+  const ms = Date.now() - started
+  const ok = exitCode === 0 && !stopReason
+  emit(stopReason === '已取消' ? 'cancelled' : ok ? 'completed' : 'failed', true)
+
+  const outFile = path.join(logDir, 'output.txt')
+  const shown = stdout.length > MAX_OUTPUT
+    ? `${stdout.slice(0, MAX_OUTPUT)}\n…(输出共 ${stdoutBytes} 字节,以上为前一部分;完整输出:${rel(outFile)})`
+    : stdout.trim() || '(脚本没有输出)'
+  const head = `脚本${ok ? '执行完成' : stopReason ? `被中止:${stopReason}` : `出错退出(退出码 ${exitCode})`}。调用工具 ${stats.calls} 次${stats.failures ? `(失败 ${stats.failures} 次)` : ''},用时 ${(ms / 1000).toFixed(1)} 秒。调用明细:${rel(path.join(logDir, 'calls.jsonl'))}`
+  const parts = [head, '', '输出:', shown]
+  if (!ok && stderr.trim()) parts.push('', '错误信息:', stderr.trim().slice(-MAX_STDERR))
+  return {
+    result: { content: [{ type: 'text', text: parts.join('\n') }], ...(ok ? {} : { isError: true }) },
+    summary: { id, exitCode, calls: stats.calls, failures: stats.failures, byTool: stats.byTool, ms, logDir: rel(logDir) },
+  }
+}

+ 92 - 16
apps/server/src/mcp-gateway/server.ts

@@ -13,6 +13,7 @@ import { toolState, type EffectiveHints, type McpServerService } from '../servic
 import type { BackgroundCallService } from './background'
 import type { McpContentService } from '../services/mcp-content'
 import type { ElicitationService } from './elicitation'
+import { RUN_SCRIPT, RUN_SCRIPT_DESCRIPTION, runScript, runScriptSchema, type ScriptTool } from './script'
 import type { McpTaskRunner } from './tasks'
 import { BUILTIN_TOOLS, fail, type ToolContent } from './builtin'
 import { postProcess, truncateJson, validateStructured } from './results'
@@ -87,21 +88,36 @@ async function externalTools(ctx: GatewayRunContext, deps: GatewayDeps): Promise
   return out
 }
 
+type Raw = { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }
+
+/** 调用第三方工具;偶发的网络错误时,只读或幂等的工具重新连接后重试一次,其他工具不重试,避免重复执行 */
+function callExternal(ctx: GatewayRunContext, deps: GatewayDeps, t: ExternalTool, args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number): Promise<Raw> {
+  const call = () => deps.upstream.callTool(t.row, t.tool.name, args, signal, timeoutMs, { sessionId: ctx.sessionId }) as Promise<Raw>
+  return call().catch(async err => {
+    if (!t.hints.idempotent || !isTransient(err) || signal.aborted) throw err
+    await deps.upstream.reset(t.row, ctx.sessionId)
+    return call()
+  })
+}
+
 function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
   const server = new Server({ name: 'agent-server-gateway', version: '0.1.0' }, { capabilities: { tools: {} } })
 
-  server.setRequestHandler(ListToolsRequestSchema, async () => ({
-    tools: [
+  server.setRequestHandler(ListToolsRequestSchema, async () => {
+    const external = await externalTools(ctx, deps)
+    return { tools: [
       ...builtinTools(ctx, deps),
-      ...(await externalTools(ctx, deps)).map(({ row, tool, exposed, hints }) => ({
+      // 有第三方工具时,提供脚本批量调用(docs/design/17-run-script.md)
+      ...(external.length ? [{ name: RUN_SCRIPT, description: RUN_SCRIPT_DESCRIPTION, inputSchema: z.toJSONSchema(runScriptSchema) as Tool['inputSchema'] }] : []),
+      ...external.map(({ row, tool, exposed, hints }) => ({
         name: exposed,
         ...(tool.title ? { title: tool.title } : {}),
         description: `[${row.title}] ${tool.description.slice(0, 2000)}${schemaHint(tool.outputSchema)}`,
         inputSchema: (tool.inputSchema?.type === 'object' ? tool.inputSchema : { type: 'object', properties: {} }) as Tool['inputSchema'],
         annotations: { readOnlyHint: hints.readOnly, destructiveHint: hints.destructive, idempotentHint: hints.idempotent, openWorldHint: hints.openWorld },
       })),
-    ],
-  }))
+    ] }
+  })
 
   server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
     const name = req.params.name
@@ -110,6 +126,8 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
     const audit = (summary: string, detail: Record<string, unknown> = {}) =>
       ctx.audit('gateway_call', summary, { args, ms: Date.now() - started, engine: ctx.provider, ...detail })
 
+    if (name === RUN_SCRIPT) return runScriptTool(ctx, deps, args, extra.signal)
+
     const builtin = ownTools(ctx, deps).find(t => t.name === name)
     if (builtin) {
       const parsed = builtin.schema.safeParse(args)
@@ -139,17 +157,7 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
       if (outcome.resolution !== 'allowed') return fail(`用户拒绝了这次调用${outcome.resolution === 'denied' && outcome.reason ? `:${outcome.reason}` : ''}`)
       if (outcome.scope === 'session') sessionAllowed.set(ctx.sessionId, new Set([...(sessionAllowed.get(ctx.sessionId) ?? []), name]))
     }
-    type Raw = { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }
-    const owner = { sessionId: ctx.sessionId }
-    const invoke = (signal: AbortSignal, timeoutMs: number) => {
-      const call = () => deps.upstream.callTool(row, tool.name, args, signal, timeoutMs, owner) as Promise<Raw>
-      // 偶发的网络错误:只读或幂等的工具重新连接后重试一次;其他工具不重试,避免重复执行
-      return call().catch(async err => {
-        if (!hints.idempotent || !isTransient(err) || signal.aborted) throw err
-        await deps.upstream.reset(row, ctx.sessionId)
-        return call()
-      })
-    }
+    const invoke = (signal: AbortSignal, timeoutMs: number) => callExternal(ctx, deps, found, args, signal, timeoutMs)
     const structured: { value?: unknown; error?: string } = {}
     const toResult = (r: Raw) => {
       if (r.structuredContent !== undefined && !r.isError) {
@@ -191,6 +199,74 @@ function buildServer(ctx: GatewayRunContext, deps: GatewayDeps): Server {
   return server
 }
 
+/**
+ * 脚本调用工具(docs/design/17-run-script.md):只读工具可以直接调用;脚本声明的写工具执行前统一审批一次(跟随权限模式与工具策略);
+ * 破坏性工具(总是审批)、只能以长任务方式调用的工具不能在脚本中调用。运行较久时与其他长调用一样转为后台
+ */
+async function runScriptTool(ctx: GatewayRunContext, deps: GatewayDeps, rawArgs: Record<string, unknown>, runSignal: AbortSignal) {
+  const parsed = runScriptSchema.safeParse(rawArgs)
+  if (!parsed.success) return fail(`参数不正确:${parsed.error.issues.map(i => `${i.path.join('.')} ${i.message}`).join(';')}`)
+  const { code, description } = parsed.data
+  const external = await externalTools(ctx, deps)
+  const byName = new Map(external.map(t => [t.exposed, t]))
+  const writes = [...new Set(parsed.data.tools)].filter(n => !byName.get(n)?.hints.readOnly)
+  const unknown = writes.filter(n => !byName.has(n))
+  if (unknown.length) return fail(`这些工具不存在或当前不可用:${unknown.join('、')}`)
+  const blocked = writes.filter(n => byName.get(n)!.policy === 'confirm' || byName.get(n)!.tool.taskSupport === 'required')
+  if (blocked.length) return fail(`这些工具不能在脚本中调用(破坏性工具每次都要确认,或只能以长任务方式调用),请直接调用:${blocked.join('、')}`)
+  // 写工具:需要审批的统一审批一次
+  const needApproval = writes.filter(n => byName.get(n)!.policy === 'ask' && ctx.permissionMode === 'ask' && !sessionAllowed.get(ctx.sessionId)?.has(n))
+  if (needApproval.length) {
+    const outcome = await ctx.interact({
+      kind: 'approval', tool: `mcp__${GATEWAY_SERVER}__${RUN_SCRIPT}`, title: `脚本将调用:${needApproval.join('、')}`,
+      input: { description, tools: parsed.data.tools, code },
+      detail: { command: code.slice(0, 4000), reason: `脚本中可能多次调用这些会修改数据的工具(每个脚本最多 ${ctx.settings.scripts.maxCalls} 次)。${description}` },
+      options: ['allow_once', 'deny'],
+    })
+    ctx.audit('approval', `脚本调用工具 ${needApproval.join('、')}:${outcome.resolution}`, { description })
+    if (outcome.resolution !== 'allowed') return fail(`用户拒绝了这个脚本${outcome.resolution === 'denied' && outcome.reason ? `:${outcome.reason}` : ''}`)
+  }
+
+  const tools = new Map<string, ScriptTool>()
+  for (const t of external) {
+    if (!t.hints.readOnly && !writes.includes(t.exposed)) continue
+    if (t.tool.taskSupport === 'required') continue
+    tools.set(t.exposed, {
+      readOnly: t.hints.readOnly,
+      invoke: async (args, signal) => {
+        const raw = await callExternal(ctx, deps, t, args, signal, CALL_TIMEOUT_MS)
+        const r = postProcess(raw, ctx.workspaceDir, t.exposed)
+        return {
+          text: r.content.map(c => (c.type === 'text' ? c.text : `[${c.type}]`)).join('\n'),
+          structured: raw.isError ? null : raw.structuredContent ?? null,
+          isError: !!raw.isError,
+        }
+      },
+    })
+  }
+
+  const title = description || '脚本'
+  const run = async (signal: AbortSignal) => {
+    const { result, summary } = await runScript({
+      ctx, code, title, tools, readOnly: ctx.permissionMode === 'readonly', signal,
+      limits: ctx.settings.scripts, pythonRuntime: ctx.settings.paths.pythonRuntime,
+    })
+    ctx.audit('gateway_call', `${RUN_SCRIPT}:${title}(调用工具 ${summary.calls} 次${summary.failures ? `,失败 ${summary.failures} 次` : ''}${summary.exitCode === 0 ? '' : ',未正常结束'})`, {
+      scriptId: summary.id, byTool: summary.byTool, ms: summary.ms, exitCode: summary.exitCode, logDir: summary.logDir, writes, code: code.slice(0, 20_000), engine: ctx.provider,
+    })
+    return result
+  }
+  try {
+    if (!deps.background) return await run(runSignal)
+    return await deps.background.call({
+      ctx, server: { id: GATEWAY_SERVER, name: GATEWAY_SERVER, title: '脚本' }, tool: RUN_SCRIPT, args: { description: title, tools: parsed.data.tools },
+      always: false, runSignal, foregroundTimeoutMs: ctx.settings.scripts.maxMinutes * 60_000, invoke: run, toResult: r => r,
+    })
+  } catch (err) {
+    return fail(`脚本执行失败:${(err as Error).message}`)
+  }
+}
+
 /** 工具说明后附上返回结构的简述,让模型事先知道会得到什么(不把 outputSchema 原样转给引擎,见 docs/design/16-mcp-support.md §2.2) */
 function schemaHint(schema: Record<string, unknown> | undefined): string {
   if (!schema) return ''

+ 3 - 1
apps/server/src/mcp-gateway/tokens.ts

@@ -2,7 +2,7 @@
 // Codex 的会话在 app-server 中保持加载,MCP 连接沿用第一次对话时的请求头(thread/resume 不会更新),
 // 所以 Codex 每个会话使用固定的令牌字符串:每次 Run 开始时重新绑定到当前 Run,结束后失效,下次 Run 再绑定
 import crypto from 'node:crypto'
-import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
+import type { AppSettings, KnowledgeRootDto, PermissionMode, ScriptProgressPayload } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
 import type { InteractionOutcome, NewApproval, NewForm } from '../services/interactions'
 import type { BuiltinTool } from './builtin'
@@ -21,6 +21,8 @@ export interface GatewayRunContext {
   audit: (kind: 'gateway_call' | 'network_denied' | 'file_access_denied' | 'approval', summary: string, detail?: unknown) => void
   /** 发起审批或表单询问(与引擎侧的审批相同:显示交互卡片并等待回复) */
   interact: (req: NewApproval | NewForm) => Promise<InteractionOutcome>
+  /** 推送脚本的执行进度到会话(只推送、不保存,docs/design/17-run-script.md) */
+  progress?: (p: ScriptProgressPayload) => void
   /** 这个会话额外提供的工具(工作组的协作工具,按角色固定,见 teams/tools.ts) */
   extraTools?: BuiltinTool[]
 }

+ 1 - 0
apps/server/src/services/runs.ts

@@ -215,6 +215,7 @@ export class RunManager {
         mcpServerIds: profile.mcpServerIds,
         audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${sessionRow.provider}`, sessionId, kind, summary, detail }),
         interact,
+        progress: p => deps.events.append(sessionId, runId, 'script.progress', p),
         extraTools: this.gatewayToolProviders.flatMap(f => f(sessionRow)),
       }, { stablePerSession: session.provider === 'codex' })
 

+ 6 - 0
apps/server/src/services/settings.ts

@@ -32,6 +32,7 @@ export function defaultSettings(): AppSettings {
     },
     retention: { workspaceDays: 30 },
     backgroundCalls: { autoAfterSec: 180, maxHours: 24, maxPerTarget: 3 },
+    scripts: { maxCalls: 2000, concurrency: 4, maxMinutes: 30 },
     schedules: { timezone: 'Asia/Shanghai', minIntervalHours: 1, maxPerTarget: 5, maxMinutesPerRun: 60, maxCostUsdPerRun: 2 },
     teamLimits: { maxTasksPerJob: 20, maxLeadRunsPerJob: 30, maxCostUsdPerJob: null, maxMinutesPerJob: 120, maxConcurrentRuns: 4, sessionMaxContextTokens: 100_000, sessionIdleHours: 24 },
     providers: {
@@ -85,6 +86,11 @@ const settingsSchema: z.ZodType<AppSettings> = z.object({
   }),
   retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }),
   teamLimits: teamLimitsSchema,
+  scripts: z.object({
+    maxCalls: z.number().int().min(1).max(100_000),
+    concurrency: z.number().int().min(1).max(16),
+    maxMinutes: z.number().min(1).max(24 * 60),
+  }),
   backgroundCalls: z.object({
     autoAfterSec: z.number().int().min(1).max(3600),
     maxHours: z.number().min(0.1).max(24 * 7),

+ 1 - 1
apps/server/test/api-reference.test.ts

@@ -49,7 +49,7 @@ describe('API 协议文档', () => {
     const all: Record<keyof EventPayloads, true> = {
       'user.message': true, 'run.queued': true, 'run.started': true, 'message.delta': true, 'message.completed': true,
       'reasoning.delta': true, 'reasoning.completed': true, 'tool.started': true, 'tool.completed': true, 'file.changed': true,
-      'interaction.required': true, 'interaction.resolved': true, usage: true, 'run.completed': true, 'run.failed': true, 'run.interrupted': true,
+      'interaction.required': true, 'interaction.resolved': true, 'script.progress': true, usage: true, 'run.completed': true, 'run.failed': true, 'run.interrupted': true,
     }
     expect(API_EVENTS.map(e => e.type).sort()).toEqual(Object.keys(all).sort())
   })

+ 4 - 3
apps/server/test/mcp-resources.test.ts

@@ -90,9 +90,10 @@ afterAll(async () => {
 
 describe('资源(模型按需读取)', () => {
   it('刷新时记录服务的能力;勾选了提供资源的服务时网关提供两个只读工具;读取的内容按大小内联或存为文件', async () => {
-    const before = await gateway(runCtx([serverId]))
-    expect((await before.listTools()).tools.map(t => t.name)).not.toContain('mcp_resources') // 还没连接过,不知道服务提供资源
-    await before.close()
+    // 第一次列出工具时连接服务、记录能力,资源工具在同一次列出中就会出现
+    const first = await gateway(runCtx([serverId]))
+    expect((await first.listTools()).tools.map(t => t.name)).toContain('mcp_resources')
+    await first.close()
     expect((await app.mcpServers.refresh(serverId)).features).toEqual({ resources: true, prompts: true })
 
     const ctx = runCtx([serverId])

+ 198 - 0
apps/server/test/mcp-script.test.ts

@@ -0,0 +1,198 @@
+// 脚本调用工具(docs/design/17-run-script.md):模型写 Python 脚本,由网关在沙箱中执行并批量调用 MCP 工具
+import fs from 'node:fs'
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+import path from 'node:path'
+import pino from 'pino'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { Client } from '@modelcontextprotocol/sdk/client/index.js'
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import type { ScriptProgressPayload } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { config } from '../src/config'
+import { buildServer } from '../src/http/server'
+import { BWRAP } from '../src/mcp-gateway/upstream'
+import type { GatewayRunContext } from '../src/mcp-gateway/tokens'
+import { resolveAccess } from '../src/services/access-policy'
+import type { InteractionOutcome, NewApproval } from '../src/services/interactions'
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let serverId: string
+const flags: string[] = []
+/** 测试的数据目录是临时目录,使用本机的 Python 运行环境(没有时跳过) */
+const PY_RUNTIME = '/srv/agent-server/runtime/py'
+const HAS_PY = fs.existsSync(path.join(PY_RUNTIME, 'bin/python'))
+
+function startUpstream(): Promise<string> {
+  upstream = http.createServer(async (req, res) => {
+    const mcp = new McpServer({ name: 'dev', version: '1.0.0' })
+    mcp.registerTool('get_item', {
+      description: '读取一条数据', inputSchema: { i: z.number().int() }, outputSchema: { i: z.number(), value: z.number() }, annotations: { readOnlyHint: true },
+    }, async ({ i }) => (i < 0
+      ? { isError: true, content: [{ type: 'text', text: `编号 ${i} 不存在` }] }
+      : { content: [{ type: 'text', text: JSON.stringify({ i, value: i * 2 }) }], structuredContent: { i, value: i * 2 } }))
+    mcp.registerTool('set_flag', { description: '设置标记', inputSchema: { name: z.string() } }, async ({ name }) => {
+      flags.push(name)
+      return { content: [{ type: 'text', text: `ok:${name}` }] }
+    })
+    mcp.registerTool('wipe', { description: '清空', inputSchema: {}, annotations: { destructiveHint: true } }, async () => ({ content: [{ type: 'text', text: 'wiped' }] }))
+    const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
+    res.on('close', () => { void t.close(); void mcp.close() })
+    await mcp.connect(t)
+    let body = ''
+    req.on('data', c => (body += c))
+    req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
+  })
+  return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
+}
+
+const approvals: NewApproval[] = []
+let answer: InteractionOutcome = { resolution: 'allowed', scope: 'once' }
+const progress: ScriptProgressPayload[] = []
+const audits: { summary: string; detail: Record<string, unknown> }[] = []
+function runCtx(over: Partial<GatewayRunContext> = {}): GatewayRunContext {
+  const ws = path.join(config.dataDir, 'workspaces', `ses_script_${Math.random().toString(36).slice(2)}`)
+  fs.mkdirSync(ws, { recursive: true })
+  const settings = structuredClone(app.settings.get())
+  settings.paths.pythonRuntime = PY_RUNTIME
+  return {
+    sessionId: `ses_${Math.random().toString(36).slice(2)}`, runId: 'r', provider: 'claude', workspaceDir: ws, permissionMode: 'ask', settings,
+    access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: [] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
+    knowledgeRoots: [], mcpServerIds: [serverId], audit: (_k, summary, detail) => void audits.push({ summary, detail: detail as Record<string, unknown> }),
+    interact: async req => { approvals.push(req as NewApproval); return answer },
+    progress: p => void progress.push(p),
+    ...over,
+  }
+}
+async function gateway(ctx: GatewayRunContext) {
+  const client = new Client({ name: 'test', version: '1' })
+  await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { Authorization: `Bearer ${app.gatewayTokens.issue(ctx)}` } } }))
+  return client
+}
+async function run(ctx: GatewayRunContext, code: string, tools: string[] = [], description = '测试脚本') {
+  const c = await gateway(ctx)
+  try {
+    const r = await c.callTool({ name: 'run_script', arguments: { code, tools, description } }, undefined, { timeout: 120_000 }) as { content: { text: string }[]; isError?: boolean }
+    return { text: r.content[0]!.text, isError: !!r.isError }
+  } finally {
+    await c.close()
+  }
+}
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: {} })
+  server = await buildServer(app, { logger: log })
+  base = await server.listen({ port: 0, host: '127.0.0.1' })
+  const url = await startUpstream()
+  serverId = app.mcpServers.create({ name: 'dev', title: '设备服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [], allowNetwork: false, readPaths: [], writePaths: [] }).id
+  await app.mcpServers.refresh(serverId)
+})
+afterAll(async () => {
+  app.mcpTasks.stop()
+  app.backgroundCalls.stop()
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.close()
+})
+
+describe.skipIf(!BWRAP || !HAS_PY)('脚本调用工具', () => {
+  it('有第三方工具时才提供 run_script', async () => {
+    const none = await gateway(runCtx({ mcpServerIds: [] }))
+    expect((await none.listTools()).tools.map(t => t.name)).not.toContain('run_script')
+    await none.close()
+    const c = await gateway(runCtx())
+    const tool = (await c.listTools()).tools.find(t => t.name === 'run_script')!
+    expect(tool.description).toContain('from agent_tools import')
+    await c.close()
+  })
+
+  it('批量调用只读工具、在脚本中汇总并写文件,只把打印的内容交回;记录明细、审计与进度', async () => {
+    const ctx = runCtx()
+    progress.length = 0
+    audits.length = 0
+    const r = await run(ctx, `
+from agent_tools import call, call_many, progress, ToolError
+import pandas as pd
+rows = call_many("dev__get_item", [{"i": i} for i in range(300)])
+progress(300, 300, "汇总")
+df = pd.DataFrame(rows)
+import os
+os.makedirs("outputs", exist_ok=True)
+df.to_csv("outputs/items.csv", index=False)
+try:
+    call("dev__get_item", {"i": -1})
+except ToolError as e:
+    print("出错:", e.message)
+print("合计", int(df.value.sum()), "条数", len(df))
+`, [], '读取 300 条并汇总')
+    expect(r.isError).toBe(false)
+    expect(r.text).toContain('合计 89700 条数 300')
+    expect(r.text).toContain('出错: 编号 -1 不存在')
+    expect(r.text).toMatch(/调用工具 301 次(失败 1 次)/)
+    expect(fs.readFileSync(path.join(ctx.workspaceDir, 'outputs/items.csv'), 'utf8').split('\n')[0]).toBe('i,value')
+    expect(approvals).toHaveLength(0)
+
+    const logDir = /调用明细:(\S+)\/calls\.jsonl/.exec(r.text)![1]!
+    expect(fs.readFileSync(path.join(ctx.workspaceDir, logDir, 'calls.jsonl'), 'utf8').trim().split('\n')).toHaveLength(301)
+    expect(fs.readFileSync(path.join(ctx.workspaceDir, logDir, 'script.py'), 'utf8')).toContain('call_many')
+
+    expect(progress.at(-1)).toMatchObject({ title: '读取 300 条并汇总', status: 'completed', calls: 301, failures: 1, done: 300, total: 300, message: '汇总' })
+    const a = audits.find(x => x.summary.startsWith('run_script'))!
+    expect(a.summary).toBe('run_script:读取 300 条并汇总(调用工具 301 次,失败 1 次)')
+    expect(a.detail).toMatchObject({ byTool: { dev__get_item: 301 }, exitCode: 0 })
+  })
+
+  it('写工具:没有列出的不能调用;列出的执行前审批一次;拒绝则不执行;破坏性工具不能在脚本中调用', async () => {
+    const code = 'from agent_tools import call\nfor n in ["a", "b", "c"]:\n    print(call("dev__set_flag", {"name": n}))'
+    const undeclared = await run(runCtx(), code)
+    expect(undeclared.text).toMatch(/工具不可用:dev__set_flag/)
+    expect(undeclared.isError).toBe(true)
+
+    approvals.length = 0
+    flags.length = 0
+    answer = { resolution: 'allowed', scope: 'once' }
+    const ok = await run(runCtx(), code, ['dev__set_flag', 'dev__get_item'])
+    expect(ok.text).toContain('ok:c')
+    expect(flags).toEqual(['a', 'b', 'c'])
+    expect(approvals).toHaveLength(1)
+    expect(approvals[0]).toMatchObject({ tool: 'mcp__gateway__run_script', title: '脚本将调用:dev__set_flag', options: ['allow_once', 'deny'] })
+
+    answer = { resolution: 'denied', reason: '不要改' }
+    const denied = await run(runCtx(), code, ['dev__set_flag'])
+    expect(denied.text).toMatch(/拒绝了这个脚本:不要改/)
+    expect(flags).toHaveLength(3)
+
+    // 自动模式不审批
+    approvals.length = 0
+    await run(runCtx({ permissionMode: 'auto' }), code, ['dev__set_flag'])
+    expect(approvals).toHaveLength(0)
+
+    const wipe = await run(runCtx(), 'print(1)', ['dev__wipe'])
+    expect(wipe.text).toMatch(/不能在脚本中调用.*dev__wipe/)
+  })
+
+  it('次数上限;脚本出错时返回错误信息;只读模式下工作区只读', async () => {
+    const limited = runCtx()
+    limited.settings.scripts.maxCalls = 5
+    const r = await run(limited, 'from agent_tools import call, ToolError\nn = 0\ntry:\n    for i in range(10):\n        call("dev__get_item", {"i": i}); n += 1\nexcept ToolError as e:\n    print(n, e.message)')
+    expect(r.text).toMatch(/5 已达到每个脚本调用工具的上限(5 次)/)
+
+    const bad = await run(runCtx(), 'print("开始")\nraise ValueError("数据不对")')
+    expect(bad.isError).toBe(true)
+    expect(bad.text).toMatch(/出错退出(退出码 1)/)
+    expect(bad.text).toContain('开始')
+    expect(bad.text).toContain('ValueError: 数据不对')
+
+    const ro = await run(runCtx({ permissionMode: 'readonly' }), 'from agent_tools import call\nprint(call("dev__get_item", {"i": 2})["value"])\nopen("x.txt", "w").write("x")')
+    expect(ro.isError).toBe(true)
+    expect(ro.text).toContain('4')
+    expect(ro.text).toMatch(/Read-only file system/)
+  })
+})

+ 3 - 1
docs/README.md

@@ -28,7 +28,8 @@ docs/
 │  ├─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(一期至三期已实施)
 │  ├─ 14-schedules.md         定时任务:在对话中创建,到点自动在会话或工作组中执行
 │  ├─ 15-background-calls.md  后台调用:外部工具的长调用由服务端等待,完成后通知 AI 继续
-│  └─ 16-mcp-support.md       MCP 支持完善:工具提示、结构化输出、长任务、询问用户、OAuth、资源与提示模板
+│  ├─ 16-mcp-support.md       MCP 支持完善:工具提示、结构化输出、长任务、询问用户、OAuth、资源与提示模板
+│  └─ 17-run-script.md        脚本调用工具:模型写 Python 脚本,由网关在沙箱中执行并批量调用 MCP 工具
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```
@@ -62,6 +63,7 @@ docs/
 | [0023](adr/0023-bot-teams-collaboration-model.md) | Bot 工作组:协调者模式,每个机器人一个独立会话 | Accepted |
 | [0024](adr/0024-bot-api-separate-keys.md) | Bot API 使用独立路径和独立的 Bot Key | Accepted |
 | [0025](adr/0025-mcp-spec-features.md) | 按 MCP 规范补齐工具以外的能力(长任务、询问用户、OAuth、资源等) | Accepted |
+| [0026](adr/0026-run-script-in-gateway.md) | 脚本批量调用工具由网关在沙箱中执行 | Accepted |
 
 ## 调研
 

+ 18 - 0
docs/adr/0026-run-script-in-gateway.md

@@ -0,0 +1,18 @@
+# ADR-0026 脚本批量调用工具由网关执行
+
+- 状态:Accepted · 日期:2026-10-29 · 补充 ADR-0021、ADR-0025 · 设计见 [17-run-script](../design/17-run-script.md)
+
+## 背景
+MCP 工具比较底层时,一个任务可能要调用几百次。逐次调用慢、费 token、上下文会爆;模型在自己的沙箱中写的脚本调用不到 MCP 工具。业务方希望由模型灵活决定处理方式,而不是事先为每种用法在服务端做汇总工具。
+
+## 决策
+- **网关执行脚本**:新增 `run_script` 工具,网关在 bubblewrap 沙箱中运行模型写的 Python 脚本,脚本经管道请求网关调用工具。
+  - 不采用"打开引擎沙箱访问网关的网络、把令牌交给脚本":两个引擎的沙箱规则不同,并且脚本会绕过网关的审批与审计。
+- **权限**:只读工具直接调用;写工具须预先声明,执行前统一审批一次;破坏性工具不能在脚本中调用。
+- **限额**:调用次数、并发、运行时间可在系统设置中调整;长时间运行的脚本复用后台调用转到后台。
+- **记录**:每个脚本一条审计汇总,调用明细写入会话工作区的文件;进度以只推送的事件显示在对话中。
+
+## 后果
+- 底层工具也能用于大批量任务,MCP 服务不必为每种分析方式提供汇总工具(接入指南 §3.7)。
+- 写工具在脚本中可能被调用很多次,只在开始时审批一次;审批卡片显示脚本内容和限额,由用户判断。
+- 脚本运行在服务账号下的沙箱中,只能访问会话工作区与 Python 运行环境。

+ 1 - 1
docs/design/09-admin-ui.md

@@ -48,7 +48,7 @@
 | 📖 API 协议 | 右侧:调试身份 + 目录(随滚动高亮) | 对外接口(/v1)的完整协议:由 `apps/server/src/api-reference/reference.ts` 生成(测试保证与实际路由一致),跟随亮/暗主题。每个接口可以**调试**:填写参数与请求体后以"后台管理员"或某个平台的身份调用(平台身份由服务端签发 10 分钟的浏览器令牌,API Key 不离开服务端,记入审计),写操作发送前确认,发送消息会提示消耗订阅额度;事件流实时显示(最多 2 分钟 / 200 条,可停止);响应中的 ID 自动预填到其他接口的路径参数。下载 PDF 由服务端无头 Chrome 生成(A4,服务器没有 Chrome 时改用浏览器打印) |
 | 🔌 连接器 | (暂缓) | — |
 | 📜 审计 | 筛选条件 | 命令执行、连接器调用、审批、后台操作 |
-| ⚙ 设置 | 分组:路径 / 安全 / 网络 / 限制 / 引擎 | **路径**:工作区根目录、技能目录、Python 环境、matplotlibrc;**安全**:全局禁止路径(内置项锁定)、新建 Profile 的默认文件访问模式;**网络**:域名白名单、私网拦截;**限制**:并发、交互超时、上传大小、保留期;**引擎**:CLI 路径、默认模型、Codex 配置覆盖项。所有路径输入都做实时校验 |
+| ⚙ 设置 | 分组:路径 / 安全 / 网络 / 限制 / 引擎 | **路径**:工作区根目录、技能目录、Python 环境、matplotlibrc;**安全**:全局禁止路径(内置项锁定)、新建 Profile 的默认文件访问模式;**网络**:域名白名单、私网拦截;**限制**:并发、交互超时、上传大小、保留期;**后台调用 / 脚本调用工具 / 定时任务 / 工作组限额**:各项上限(脚本:调用次数、并发、运行时长,见 17-run-script);**引擎**:CLI 路径、默认模型、Codex 配置覆盖项。所有路径输入都做实时校验 |
 
 ## 3. Playground
 

+ 64 - 0
docs/design/17-run-script.md

@@ -0,0 +1,64 @@
+# 17 脚本调用工具(run_script)
+
+> 状态:**已实施(2026-10-29)**。决策见 ADR-0026。
+
+## 1. 问题
+
+MCP 服务的工具往往比较底层,一个分析任务可能要调用几百次(例如查询 500 台设备的状态再汇总)。模型逐次调用时:
+
+- 每次调用都要一轮模型推理,慢,token 消耗随次数急剧增长;
+- 几百个结果堆在上下文里,会触发压缩,或者模型改为抽样,结果不完整。
+
+模型自己的沙箱里写脚本调用不到 MCP 工具(工具只经网关提供给引擎)。而事先为每种分析方式都在服务端做好汇总工具,又不够灵活。
+
+## 2. 方案
+
+网关新增工具 `run_script(code, tools, description)`:模型写一段 Python 脚本,由**网关**在沙箱中执行。脚本通过 `agent_tools` 模块调用 MCP 工具:每次调用都经过网关(工具策略、结果处理与普通调用相同),只是不经过模型。循环、筛选、汇总、写文件都在脚本中完成,只有脚本打印的内容交回模型。
+
+```python
+from agent_tools import call, call_many, call_raw, progress, ToolError
+rows = call_many("fleet__get_device_status", [{"sn": sn} for sn in sns])   # 平台并发执行
+progress(len(rows), len(sns), "汇总")
+```
+
+| 函数 | 说明 |
+|---|---|
+| `call(tool, args)` | 返回结构化结果(dict)或文本;工具报错时抛出 `ToolError` |
+| `call_many(tool, args_list, return_errors=False)` | 批量调用同一个工具,按顺序返回;每批 50 个请求,平台按并发上限执行 |
+| `call_raw(tool, args)` | 返回 `{text, structured, is_error}`,不抛出工具错误 |
+| `progress(done, total, message)` | 报告进度,显示在对话中 |
+
+- 只有 Profile 勾选了第三方服务(有可用的第三方工具)时才提供 `run_script`。
+- 工具说明(`RUN_SCRIPT_DESCRIPTION`)中写明了用法和规则,模型据此决定何时使用。
+
+## 3. 执行
+
+- **沙箱**:bubblewrap,与文档转换相同的配置(`services/office.ts` 的 `sandboxArgs`):系统目录只读、Python 运行环境只读、会话工作区可读写(只读模式下只读)、不能联网、`/tmp` 独立。当前目录为工作区。
+- **通信**:脚本经 fd 3 发请求、fd 4 收回复,每行一个 JSON;标准输出留给脚本自己打印。辅助模块在启动时写入 `data/script-runtime/agent_tools.py`,沙箱中只读。
+- **限额**(系统设置 `scripts`):每个脚本最多调用 2000 次工具、同时 4 次、最长 30 分钟。超时或取消时结束整个进程树。
+- **转后台**:与其他长调用相同(docs/design/15-background-calls.md),超过 `backgroundCalls.autoAfterSec` 转为后台,完成后把结果作为新消息发给发起方;可以在后台调用栏取消。服务重启后中断,与"保持连接等待"的后台调用一样按失败通知。
+- **返回模型**:一行概况(是否正常结束、调用次数与失败次数、用时、调用明细路径)+ 脚本打印的内容(超过 2 万字截断,完整输出存为文件);出错时附上错误信息的最后一部分。
+
+## 4. 权限
+
+| 工具 | 在脚本中 |
+|---|---|
+| 只读工具 | 直接调用 |
+| 写工具(跟随权限模式、直接调用) | 必须在 `tools` 参数中列出;"需审批"模式下,执行前**统一审批一次**(卡片列出这些工具和脚本内容,只有"允许一次"和"拒绝");已"本会话允许"的不再询问;自动模式不询问 |
+| 破坏性工具(总是审批) | 不能在脚本中调用,请模型直接调用、逐次确认 |
+| 只能以长任务方式调用的工具 | 不能在脚本中调用 |
+| 关闭的工具、只读模式下的写工具 | 不提供 |
+
+没有列出的写工具在脚本中调用时,脚本收到 `ToolError`。
+
+## 5. 记录与进度
+
+- **调用明细**:会话工作区 `.agent/scripts/<脚本 id>/`:`script.py`(脚本)、`calls.jsonl`(每次调用一行:时间、工具、参数、耗时、是否出错、结果摘要)、`output.txt`(完整输出)。
+- **审计**:每个脚本一条(`gateway_call`):说明、调用次数与失败次数、各工具的次数、用时、退出码、明细目录、脚本内容。不为每次调用写审计,避免淹没审计页面。
+- **进度**:事件 `script.progress`(只推送、不保存,每秒最多一次):已调用次数、失败次数、脚本报告的进度、用时、状态。`<AgentChat>` 在时间线底部显示进度(有总数时带进度条),结束后几秒移除。工作组中暂不显示。
+
+## 6. 不做
+
+- 其他语言(只支持 Python)。
+- 脚本中调用网关的内置工具(文档转换、网页读取)和资源读取:需要时由模型直接调用。
+- 服务重启后接续脚本。

+ 12 - 0
docs/guides/mcp-guide.md

@@ -33,6 +33,7 @@
 | 资源(Resources)、资源模板 | ✅ 模型按需读取;用户可以附加到消息 | §8.1 |
 | 提示模板(Prompts) | ✅ 用户在输入框"快捷指令"中使用 | §8.3 |
 | 认证:请求头令牌、OAuth 2.1 | ✅ 令牌由服务端保存;OAuth 按服务共享一个账号 | §9 |
+| 脚本批量调用(平台能力,不需要服务支持) | ✅ 模型写 Python 脚本批量调用工具、在脚本中汇总 | §3.7 |
 | Sampling(服务借用平台的模型) | ❌ 不支持 | §10 |
 | 资源订阅、Roots、补全(completion)、日志(logging) | ❌ 暂不支持,声明了也不会使用 | §10 |
 
@@ -123,6 +124,16 @@
 - 会产生副作用的操作,尽量支持调用方传入去重键(例如 `requestId`),或者在服务端按业务键去重。
 - 提交类操作(创建作业、下单)做成幂等的:同样的任务已经在执行时,返回已有的结果或作业 id,不要再执行一次。这样就不需要模型先查一次状态再决定要不要提交。
 
+### 3.7 被脚本批量调用
+
+平台提供 `run_script` 工具:模型可以写一段 Python 脚本,在脚本里循环调用你的工具几百上千次,汇总后只把结论交回对话(设计见 [design/17-run-script.md](../design/17-run-script.md))。所以底层、细粒度的工具也能用于大批量的任务,不必为每种分析方式都提供汇总工具。为了让脚本好写、跑得稳:
+
+- **提供结构化输出**(§4.2):脚本中 `call()` 直接拿到 `structuredContent`(dict),不用从文本里解析。
+- **只读提示要准确**:脚本中只读工具可以直接调用;写工具要在脚本执行前统一审批;破坏性工具不能在脚本中调用。
+- **服务端自己限速**:平台默认同时发出 4 次调用(后台可调),每个脚本最多 2000 次。服务有频率限制时,在服务端排队或返回明确的错误,不要指望脚本按时间间隔调用。
+- **每次调用独立**:一次失败只影响这一次(脚本会收到异常,可以跳过或重试),不要因为一次失败让后续调用都失败。
+- 已知的高频需求仍然建议提供批量或汇总工具(如 `get_devices_status(sns)`),比脚本逐个调用更快。
+
 ---
 
 ## 4. 返回结果
@@ -427,6 +438,7 @@ server.registerPrompt('order_review', {
 - [ ] 只读 / 破坏性 / 幂等提示准确
 - [ ] 错误用 `isError` 返回,说明原因和下一步
 - [ ] 列表分页,结果控制在几千字以内
+- [ ] 会被大量调用的工具:有结构化输出,服务端限速或排队
 
 **结果**
 - [ ] 需要准确字段的工具有 `outputSchema` 和 `structuredContent`,并同时返回 JSON 文本

+ 4 - 0
docs/integration.md

@@ -185,6 +185,10 @@ const { prompts } = await client.sessions.mcpPrompts(sessionId)
 const { text, attachments } = await client.sessions.getMcpPrompt(sessionId, 'docs', 'weekly', { week: '44' })
 ```
 
+## 5.4 脚本批量调用工具的进度
+
+AI 用脚本批量调用外部工具(`run_script`,见 [design/17-run-script.md](design/17-run-script.md))时,事件流中有 `script.progress`(只推送、不保存,每秒最多一次):已调用次数、失败次数、脚本报告的进度(`done` / `total` / `message`)、用时、状态。`<AgentChat>` 会在时间线底部显示;自己渲染界面时可以按 `scriptId` 更新一行进度。
+
 ## 6. 外观
 
 - 亮色/暗色默认跟随系统;在 `<html>` 上设置 `data-theme="light"` 或 `"dark"` 可以强制指定。

+ 2 - 0
packages/chat-ui/src/components/AgentChat.vue

@@ -8,6 +8,7 @@ import BackgroundBar from './BackgroundBar.vue'
 import Composer from './Composer.vue'
 import McpPicker from './McpPicker.vue'
 import ScheduleBar from './ScheduleBar.vue'
+import ScriptProgress from './ScriptProgress.vue'
 import Timeline from './Timeline.vue'
 
 const props = defineProps<{ client: AgentClient; session: ChatSession; readonly?: boolean; placeholder?: string }>()
@@ -76,6 +77,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
           v-else :items="state.items" :readonly="readonly"
           :reply="readonly ? undefined : session.reply" @open-file="openFile"
         />
+        <ScriptProgress v-if="state.scripts.length" :scripts="state.scripts" />
         <div v-if="state.runState === 'queued'" class="working"><span class="codicon codicon-loading codicon-modifier-spin" />排队中…</div>
         <div v-else-if="state.runState === 'running'" class="working"><span class="codicon codicon-loading codicon-modifier-spin" />处理中…</div>
       </div>

+ 47 - 0
packages/chat-ui/src/components/ScriptProgress.vue

@@ -0,0 +1,47 @@
+<script setup lang="ts">
+// 脚本调用工具的进度(docs/design/17-run-script.md):已调用次数、脚本报告的进度、用时
+import type { ScriptProgressPayload } from '@agent-server/shared'
+
+defineProps<{ scripts: ScriptProgressPayload[] }>()
+
+const STATUS: Record<ScriptProgressPayload['status'], string> = { running: '脚本运行中', completed: '脚本已完成', failed: '脚本未正常结束', cancelled: '脚本已取消' }
+
+function elapsed(ms: number) {
+  const s = Math.floor(ms / 1000)
+  return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${s % 60} 秒`
+}
+const pct = (p: ScriptProgressPayload) => (p.total ? Math.min(100, Math.round(((p.done ?? 0) / p.total) * 100)) : null)
+</script>
+
+<template>
+  <div class="script-progress">
+    <div v-for="p in scripts" :key="p.scriptId" class="row" :class="p.status">
+      <div class="line">
+        <span class="codicon" :class="p.status === 'running' ? 'codicon-loading codicon-modifier-spin' : p.status === 'completed' ? 'codicon-pass' : 'codicon-error'" />
+        <span class="status">{{ STATUS[p.status] }}</span>
+        <span class="title">{{ p.title }}</span>
+        <span class="meta">
+          已调用工具 {{ p.calls }} 次<template v-if="p.failures">(失败 {{ p.failures }})</template>
+          <template v-if="p.total"> · {{ p.done ?? 0 }} / {{ p.total }}</template>
+          <template v-if="p.message"> · {{ p.message }}</template>
+          · {{ elapsed(p.elapsedMs) }}
+        </span>
+      </div>
+      <div v-if="pct(p) !== null" class="bar"><div class="fill" :style="{ width: `${pct(p)}%` }" /></div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.script-progress { display: flex; flex-direction: column; gap: 6px; padding: 6px 0 0 30px; }
+.row { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--app-secondary-foreground); }
+.line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 6px; min-width: 0; }
+.line .codicon { font-size: 12px; align-self: center; }
+.row.completed .codicon { color: var(--app-success); }
+.row.failed .codicon, .row.cancelled .codicon { color: var(--app-danger); }
+.status { color: var(--app-primary-foreground); font-weight: 500; }
+.title { overflow-wrap: anywhere; }
+.meta { overflow-wrap: anywhere; }
+.bar { height: 3px; max-width: 320px; border-radius: 2px; background: var(--app-input-border); overflow: hidden; }
+.fill { height: 100%; background: var(--app-claude-orange); transition: width 0.3s; }
+</style>

+ 1 - 0
packages/chat-ui/src/index.ts

@@ -19,3 +19,4 @@ export { AVATAR_COLORS, AVATAR_GROUPS, AVATAR_ICONS, avatarText, avatarValue, pa
 export { KEYLINE_AVATAR_SVGS } from './keyline-avatars'
 export { basename, fileIcon, formatSize, renderMarkdown, toolLabel } from './util'
 export { default as ScheduleBar } from './components/ScheduleBar.vue'
+export { default as ScriptProgress } from './components/ScriptProgress.vue'

+ 19 - 1
packages/chat-ui/src/store.ts

@@ -7,6 +7,7 @@ import type {
   FileDto,
   InteractionFormValue,
   InteractionRequest,
+  ScriptProgressPayload,
   InteractionResolution,
   InteractionResponse,
   UsagePayload,
@@ -37,6 +38,8 @@ export interface ChatState {
   connection: 'connecting' | 'open' | 'reconnecting' | 'closed'
   loading: boolean
   error: string | null
+  /** 正在执行(或刚结束)的脚本进度(docs/design/17-run-script.md) */
+  scripts: ScriptProgressPayload[]
 }
 
 export function createChatState(): ChatState {
@@ -51,6 +54,7 @@ export function createChatState(): ChatState {
     connection: 'connecting',
     loading: true,
     error: null,
+    scripts: [],
   }) as ChatState
 }
 
@@ -59,7 +63,7 @@ const MAX_DEBUG_EVENTS = 3000
 export function applyEvent(s: ChatState, e: AgentEvent) {
   if (e.seq <= s.lastSeq) return
   s.lastSeq = e.seq
-  if (e.type !== 'message.delta' && e.type !== 'reasoning.delta') {
+  if (e.type !== 'message.delta' && e.type !== 'reasoning.delta' && e.type !== 'script.progress') {
     s.events.push(e)
     if (s.events.length > MAX_DEBUG_EVENTS) s.events.splice(0, s.events.length - MAX_DEBUG_EVENTS)
   }
@@ -133,6 +137,20 @@ export function applyEvent(s: ChatState, e: AgentEvent) {
       if (!s.items.some(i => i.kind === 'interaction' && !i.resolution) && s.runState === 'waiting_input') s.runState = 'running'
       break
     }
+    case 'script.progress': {
+      const i = s.scripts.findIndex(x => x.scriptId === e.payload.scriptId)
+      if (i >= 0) s.scripts[i] = e.payload
+      else s.scripts.push(e.payload)
+      // 结束的脚本稍后从进度栏移除
+      if (e.payload.status !== 'running') {
+        const id = e.payload.scriptId
+        setTimeout(() => {
+          const j = s.scripts.findIndex(x => x.scriptId === id && x.status !== 'running')
+          if (j >= 0) s.scripts.splice(j, 1)
+        }, 4000)
+      }
+      break
+    }
     case 'usage':
       s.usage = e.payload
       s.totalUsage.inputTokens += e.payload.inputTokens

+ 18 - 1
packages/shared/src/events.ts

@@ -85,6 +85,22 @@ export interface UsagePayload {
   contextTokens?: number
 }
 
+/** 脚本调用工具的进度(docs/design/17-run-script.md):只推送、不保存,每秒最多一次 */
+export interface ScriptProgressPayload {
+  scriptId: string
+  /** 脚本的说明(模型调用 run_script 时填写) */
+  title: string
+  status: 'running' | 'completed' | 'failed' | 'cancelled'
+  /** 已调用工具的次数、其中失败的次数 */
+  calls: number
+  failures: number
+  /** 脚本用 progress() 报告的进度(没有报告时为 null) */
+  done: number | null
+  total: number | null
+  message: string | null
+  elapsedMs: number
+}
+
 export interface EventPayloads {
   'user.message': { text: string; attachments: Attachment[] }
   'run.queued': { position: number }
@@ -98,6 +114,7 @@ export interface EventPayloads {
   'file.changed': { fileId: string; path: string; kind: 'created' | 'modified'; size: number; mime: string }
   'interaction.required': InteractionRequest
   'interaction.resolved': { interactionId: string; resolution: InteractionResolution; answers?: Record<string, string | string[]>; reason?: string; content?: Record<string, InteractionFormValue> }
+  'script.progress': ScriptProgressPayload
   'usage': UsagePayload
   'run.completed': { durationMs: number }
   'run.failed': { code: string; message: string }
@@ -118,4 +135,4 @@ export interface AgentEventOf<T extends AgentEventType> {
 export type AgentEvent = { [T in AgentEventType]: AgentEventOf<T> }[AgentEventType]
 
 /** 只推送、不落库的高频事件(断线续传由内存缓冲覆盖) */
-export const TRANSIENT_EVENT_TYPES: ReadonlySet<AgentEventType> = new Set(['message.delta', 'reasoning.delta'])
+export const TRANSIENT_EVENT_TYPES: ReadonlySet<AgentEventType> = new Set(['message.delta', 'reasoning.delta', 'script.progress'])

+ 2 - 0
packages/shared/src/settings.ts

@@ -31,6 +31,8 @@ export interface AppSettings {
   /** 定时任务(docs/design/14-schedules.md §6) */
   /** 后台调用(docs/design/15-background-calls.md §3) */
   backgroundCalls: { autoAfterSec: number; maxHours: number; maxPerTarget: number }
+  /** 脚本调用工具(docs/design/17-run-script.md):每个脚本最多调用多少次工具、同时进行几次、最长运行多久 */
+  scripts: { maxCalls: number; concurrency: number; maxMinutes: number }
   schedules: { timezone: string; minIntervalHours: number; maxPerTarget: number; maxMinutesPerRun: number; maxCostUsdPerRun: number | null }
   /** 工作组的默认限额(创建工作组时可以调低,docs/design/13-bot-teams.md §7) */
   teamLimits: TeamLimits