Pārlūkot izejas kodu

feat: 后台调用(外部工具的长调用转到后台,完成后把结果发给发起方继续处理;工作组子任务等待后台调用;SDK 0.5.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 nedēļu atpakaļ
vecāks
revīzija
e75ad15539
52 mainītis faili ar 3845 papildinājumiem un 38 dzēšanām
  1. 1 0
      apps/admin/src/components/Workbench.vue
  2. 4 1
      apps/admin/src/lib/api.ts
  3. 1 0
      apps/admin/src/router.ts
  4. 106 0
      apps/admin/src/views/BackgroundCallsView.vue
  5. 1 1
      apps/admin/src/views/BotPlaygroundView.vue
  6. 4 0
      apps/admin/src/views/McpView.vue
  7. 10 0
      apps/admin/src/views/SettingsView.vue
  8. 24 0
      apps/server/drizzle/0012_background_calls.sql
  9. 2732 0
      apps/server/drizzle/meta/0012_snapshot.json
  10. 7 0
      apps/server/drizzle/meta/_journal.json
  11. 21 0
      apps/server/src/api-reference/background-reference.ts
  12. 16 1
      apps/server/src/api-reference/bot-reference.ts
  13. 15 0
      apps/server/src/api-reference/reference.ts
  14. 3 1
      apps/server/src/app.ts
  15. 25 1
      apps/server/src/db/schema.ts
  16. 12 0
      apps/server/src/http/routes/admin.ts
  17. 10 0
      apps/server/src/http/routes/v1.ts
  18. 1 1
      apps/server/src/http/server.ts
  19. 1 1
      apps/server/src/lib/ids.ts
  20. 252 0
      apps/server/src/mcp-gateway/background.ts
  21. 17 5
      apps/server/src/mcp-gateway/server.ts
  22. 6 2
      apps/server/src/mcp-gateway/upstream.ts
  23. 14 5
      apps/server/src/services/mcp-servers.ts
  24. 1 0
      apps/server/src/services/prompt.ts
  25. 6 0
      apps/server/src/services/settings.ts
  26. 1 1
      apps/server/src/teams/jobs.ts
  27. 1 1
      apps/server/src/teams/prompt.ts
  28. 10 0
      apps/server/src/teams/routes.ts
  29. 43 5
      apps/server/src/teams/service.ts
  30. 255 0
      apps/server/test/background-calls.test.ts
  31. 2 1
      docs/README.md
  32. 4 0
      docs/design/11-roadmap.md
  33. 74 0
      docs/design/15-background-calls.md
  34. 11 3
      docs/integration.md
  35. 3 3
      examples/platform-demo/package.json
  36. 1 1
      packages/chat-ui/package.json
  37. 2 0
      packages/chat-ui/src/components/AgentChat.vue
  38. 88 0
      packages/chat-ui/src/components/BackgroundBar.vue
  39. 1 1
      packages/chat-ui/src/components/TeamCard.vue
  40. 2 0
      packages/chat-ui/src/components/TeamChat.vue
  41. 1 0
      packages/chat-ui/src/index.ts
  42. 3 0
      packages/chat-ui/src/store.ts
  43. 3 0
      packages/chat-ui/src/team-store.ts
  44. 1 1
      packages/client/package.json
  45. 4 0
      packages/client/src/bot.ts
  46. 8 0
      packages/client/src/index.ts
  47. 1 1
      packages/shared/package.json
  48. 2 0
      packages/shared/src/api.ts
  49. 30 0
      packages/shared/src/background.ts
  50. 1 0
      packages/shared/src/index.ts
  51. 2 0
      packages/shared/src/settings.ts
  52. 1 1
      packages/shared/src/teams.ts

+ 1 - 0
apps/admin/src/components/Workbench.vue

@@ -15,6 +15,7 @@ const nav = [
   { to: '/bot-cases', icon: 'codicon-beaker', label: '回归用例' },
   { to: '/sessions', icon: 'codicon-history', label: '会话记录' },
   { to: '/schedules', icon: 'codicon-watch', label: '定时任务' },
+  { to: '/background-calls', icon: 'codicon-server-environment', label: '后台调用' },
   { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
   { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
   { to: '/bots', icon: 'codicon-robot', label: '机器人' },

+ 4 - 1
apps/admin/src/lib/api.ts

@@ -2,7 +2,7 @@
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
   ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, SkillScanDto, SkillSourceDto, SkillSourceInput, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
-  ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto,
+  ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
@@ -121,6 +121,7 @@ export const admin = {
     remove: (id: string) => req<void>('DELETE', `/mcp/servers/${id}`),
     refresh: (id: string) => req<McpServerDto>('POST', `/mcp/servers/${id}/refresh`),
     setToolPolicy: (id: string, tool: string, policy: McpToolPolicy) => req<McpServerDto>('PUT', `/mcp/servers/${id}/tools/${encodeURIComponent(tool)}`, { policy }),
+    setToolBackground: (id: string, tool: string, background: boolean) => req<McpServerDto>('PUT', `/mcp/servers/${id}/tools/${encodeURIComponent(tool)}/background`, { background }),
     test: (input: McpServerInput, id: string | null) => req<McpTestResultDto>('POST', '/mcp/test', { input, id }),
   },
   communitySkills: {
@@ -155,6 +156,8 @@ export const admin = {
   botKeyDebugToken: (id: string) => req<BrowserTokenDto>('POST', `/bot-keys/${id}/debug-token`),
   /** 复制到 Playground:按相同成员组成新建后台工作组 */
   copyTeam: (id: string) => req<TeamDto>('POST', `/teams/${id}/copy`),
+  backgroundCalls: () => req<BackgroundCallOverviewDto[]>('GET', '/background-calls'),
+  cancelBackgroundCall: (id: string) => req<BackgroundCallDto>('POST', `/background-calls/${id}/cancel`, {}),
   schedules: () => req<ScheduleOverviewDto[]>('GET', '/schedules'),
   scheduleRuns: (id: string) => req<ScheduleRunDto[]>('GET', `/schedules/${id}/runs`),
   setScheduleStatus: (id: string, status: 'active' | 'paused') => req<ScheduleDto>('PATCH', `/schedules/${id}`, { status }),

+ 1 - 0
apps/admin/src/router.ts

@@ -11,6 +11,7 @@ export const router = createRouter({
     { path: '/bot-playground/:teamId?', name: 'bot-playground', component: () => import('./views/BotPlaygroundView.vue') },
     { path: '/bot-cases/:caseId?', name: 'bot-cases', component: () => import('./views/BotCasesView.vue') },
     { path: '/schedules', component: () => import('./views/SchedulesView.vue') },
+    { path: '/background-calls', component: () => import('./views/BackgroundCallsView.vue') },
     { path: '/bots', component: () => import('./views/BotsView.vue') },
     { path: '/overview', component: () => import('./views/OverviewView.vue') },
     { path: '/profiles/:id?', name: 'profiles', component: () => import('./views/ProfilesView.vue') },

+ 106 - 0
apps/admin/src/views/BackgroundCallsView.vue

@@ -0,0 +1,106 @@
+<script setup lang="ts">
+// 后台调用总览(docs/design/15-background-calls.md §7):外部工具的长调用由服务端在后台等待,完成后结果发给发起方。这里查看参数、结果,取消进行中的调用
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import type { BackgroundCallOverviewDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const rows = ref<BackgroundCallOverviewDto[]>([])
+const filter = ref<'all' | 'running' | 'completed' | 'failed' | 'cancelled'>('all')
+const keyNames = ref<Record<string, string>>({})
+const expanded = ref<string | null>(null)
+
+const STATUS = {
+  running: { text: '执行中', cls: 'text-accent' }, completed: { text: '完成', cls: 'text-success' },
+  failed: { text: '失败', cls: 'text-error' }, cancelled: { text: '已取消', cls: 'text-fg-muted' },
+} as const
+const shown = computed(() => rows.value.filter(r => filter.value === 'all' || r.status === filter.value))
+const sourceName = (s: string) => (s === 'admin' ? '后台' : keyNames.value[s] ?? s)
+
+function duration(r: BackgroundCallOverviewDto) {
+  const min = Math.max(0, Math.round(((r.endedAt ? Date.parse(r.endedAt) : Date.now()) - Date.parse(r.startedAt)) / 60_000))
+  return min < 60 ? `${min} 分钟` : `${Math.floor(min / 60)} 小时 ${min % 60} 分钟`
+}
+
+/** 会话:后台的会话在 Playground 中打开,平台的在会话记录中;工作组在 Bot Playground 中打开 */
+function targetLink(r: BackgroundCallOverviewDto) {
+  if (r.teamId) return `/bot-playground/${r.teamId}`
+  return r.source === 'admin' ? `/playground/${r.sessionId}` : `/sessions/${r.sessionId}`
+}
+
+async function load() {
+  try {
+    rows.value = await admin.backgroundCalls()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+async function cancel(r: BackgroundCallOverviewDto) {
+  if (!confirm(`取消后台调用「${r.serverTitle} · ${r.tool}」?AI 会收到取消的消息。`)) return
+  try {
+    await admin.cancelBackgroundCall(r.id)
+    await load()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  }
+}
+
+let timer: ReturnType<typeof setInterval> | undefined
+onMounted(async () => {
+  await load()
+  const [keys, botKeys] = await Promise.all([admin.apiKeys().catch(() => []), admin.botKeys().catch(() => [])])
+  keyNames.value = Object.fromEntries([...keys, ...botKeys].map(k => [k.id, k.name]))
+  timer = setInterval(() => {
+    if (rows.value.some(r => r.status === 'running')) void load()
+  }, 30_000)
+})
+onBeforeUnmount(() => clearInterval(timer))
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="后台调用" subtitle="外部工具的长调用:AI 先结束这一轮,服务端在后台等待,完成后把结果发给发起的会话或机器人">
+      <select v-model="filter" class="field w-32" aria-label="状态">
+        <option value="all">全部</option>
+        <option value="running">执行中</option>
+        <option value="completed">完成</option>
+        <option value="failed">失败</option>
+        <option value="cancelled">已取消</option>
+      </select>
+      <button type="button" class="btn-secondary" title="刷新" @click="load"><span class="codicon codicon-refresh" /></button>
+    </PageHeader>
+    <div class="flex-1 overflow-y-auto">
+      <div v-if="!shown.length" class="p-6 hint">没有后台调用。外部工具的调用超过"设置 › 后台调用"中的时限,或在"MCP 服务"中标记为后台执行时,会出现在这里。</div>
+      <div v-for="r in shown" :key="r.id" class="border-b border-b-solid border-border">
+        <div class="flex items-center gap-3 px-6 py-2">
+          <span class="codicon flex-none text-fg-muted" :class="r.teamId ? 'codicon-organization' : 'codicon-comment-discussion'" />
+          <div class="flex-1 min-w-0">
+            <div class="flex items-center gap-2">
+              <span class="font-600 font-mono truncate">{{ r.serverName }}__{{ r.tool }}</span>
+              <span class="text-[12px]" :class="STATUS[r.status].cls">{{ STATUS[r.status].text }}</span>
+              <span class="hint">{{ duration(r) }}</span>
+              <span v-if="r.status !== 'running' && !r.delivered" class="hint">· 等待通知</span>
+            </div>
+            <div class="hint truncate">
+              <RouterLink :to="targetLink(r)" class="text-link">{{ r.teamId ? '工作组' : '会话' }}「{{ r.targetTitle || '未命名' }}」</RouterLink>
+              · {{ sourceName(r.source) }} · 开始于 {{ formatTime(r.startedAt) }}
+            </div>
+          </div>
+          <button type="button" class="btn-ghost" @click="expanded = expanded === r.id ? null : r.id"><span class="codicon codicon-info" />详情</button>
+          <button v-if="r.status === 'running'" type="button" class="btn-ghost" @click="cancel(r)">取消</button>
+        </div>
+        <div v-if="expanded === r.id" class="px-12 pb-3 flex flex-col gap-1 text-[12.5px]">
+          <div class="hint">参数</div>
+          <pre class="m-0 p-2 rounded-md bg-code whitespace-pre-wrap break-all font-mono text-[12px]">{{ JSON.stringify(r.args, null, 2) }}</pre>
+          <template v-if="r.result">
+            <div class="hint">结果</div>
+            <pre class="m-0 p-2 rounded-md bg-code whitespace-pre-wrap break-all font-mono text-[12px] max-h-80 overflow-y-auto">{{ r.result }}</pre>
+          </template>
+          <div v-if="r.error" class="text-error">{{ r.error }}</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>

+ 1 - 1
apps/admin/src/views/BotPlaygroundView.vue

@@ -173,7 +173,7 @@ const JOB_STATUS: Record<JobDto['status'], { text: string; cls: string }> = {
 }
 const TASK_STATUS: Record<TaskDto['status'], { text: string; cls: string }> = {
   waiting: { text: '等待依赖', cls: 'text-fg-muted' }, queued: { text: '排队中', cls: 'text-fg-muted' }, running: { text: '执行中', cls: 'text-accent' },
-  stub: { text: '等替身结果', cls: 'text-warning' }, completed: { text: '已完成', cls: 'text-success' }, failed: { text: '失败', cls: 'text-error' }, cancelled: { text: '已取消', cls: 'text-fg-muted' },
+  stub: { text: '等替身结果', cls: 'text-warning' }, background: { text: '等待后台调用', cls: 'text-accent' }, completed: { text: '已完成', cls: 'text-success' }, failed: { text: '失败', cls: 'text-error' }, cancelled: { text: '已取消', cls: 'text-fg-muted' },
 }
 const jobList = computed(() => Object.values(team.value?.state.jobs ?? {}).sort((a, b) => b.no - a.no))
 const tasksOf = (jobId: string) => Object.values(team.value?.state.tasks ?? {}).filter(x => x.jobId === jobId).sort((a, b) => a.no - b.no)

+ 4 - 0
apps/admin/src/views/McpView.vue

@@ -47,6 +47,7 @@ const refresh = (s: McpServerDto) => act(`refresh:${s.id}`, async () => {
   return r
 })
 const setPolicy = (s: McpServerDto, tool: string, policy: McpToolPolicy) => act(`tool:${s.id}:${tool}`, () => admin.mcp.setToolPolicy(s.id, tool, policy))
+const setBackground = (s: McpServerDto, tool: string, background: boolean) => act(`bg:${s.id}:${tool}`, () => admin.mcp.setToolBackground(s.id, tool, background))
 const toggle = (s: McpServerDto) => act(`toggle:${s.id}`, () => admin.mcp.update(s.id, { ...toInput(s), enabled: !s.enabled }), s.enabled ? '已停用' : '已启用')
 async function remove(s: McpServerDto) {
   if (!window.confirm(`删除 MCP 服务「${s.title}」?使用它的 Profile 将不再提供这些工具${s.transport === 'stdio' ? ',它的本地数据目录也会删除' : ''}。`)) return
@@ -201,6 +202,9 @@ onMounted(load)
                   </div>
                   <div class="hint line-clamp-2">{{ t.description }}</div>
                 </div>
+                <label class="flex items-center gap-1 flex-none h-7 cursor-pointer whitespace-nowrap" title="总是在后台执行:调用后 AI 先结束这一轮,完成后再把结果发给它。适合执行时间很长的工具;其他工具超过一定时间也会自动转到后台">
+                  <input type="checkbox" :checked="t.background" :disabled="!!busy" @change="setBackground(s, t.name, ($event.target as HTMLInputElement).checked)">后台执行
+                </label>
                 <select class="field w-36 flex-none" :value="t.policy" :disabled="!!busy" :aria-label="`${t.name} 的调用策略`" @change="setPolicy(s, t.name, ($event.target as HTMLSelectElement).value as McpToolPolicy)">
                   <option v-for="(label, k) in POLICY_LABELS" :key="k" :value="k">{{ label }}</option>
                 </select>

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

@@ -144,6 +144,16 @@ const LABELS: Record<(typeof PATH_KEYS)[number], string> = {
           <div class="hint">同一份订阅额度与你在终端中的使用共享,并发不宜过高。</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="b-after">超过多少秒转为后台</label><input id="b-after" v-model.number="draft.backgroundCalls.autoAfterSec" type="number" min="1" class="field"></div>
+            <div><label class="label" for="b-max">后台调用的最长时间(小时)</label><input id="b-max" v-model.number="draft.backgroundCalls.maxHours" type="number" min="0.1" step="0.5" class="field"></div>
+            <div><label class="label" for="b-per">每个会话 / 工作组同时进行的后台调用数</label><input id="b-per" v-model.number="draft.backgroundCalls.maxPerTarget" type="number" min="1" class="field"></div>
+          </div>
+          <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">

+ 24 - 0
apps/server/drizzle/0012_background_calls.sql

@@ -0,0 +1,24 @@
+CREATE TABLE `background_calls` (
+	`id` text PRIMARY KEY NOT NULL,
+	`session_id` text NOT NULL,
+	`team_id` text,
+	`bot_id` text,
+	`task_id` text,
+	`server_id` text NOT NULL,
+	`server_name` text NOT NULL,
+	`server_title` text NOT NULL,
+	`tool` text NOT NULL,
+	`args` text NOT NULL,
+	`status` text DEFAULT 'running' NOT NULL,
+	`started_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) NOT NULL,
+	`ended_at` text,
+	`result` text,
+	`error` text,
+	`delivered` integer DEFAULT false NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `background_calls_session_idx` ON `background_calls` (`session_id`);--> statement-breakpoint
+CREATE INDEX `background_calls_team_idx` ON `background_calls` (`team_id`);--> statement-breakpoint
+CREATE INDEX `background_calls_status_idx` ON `background_calls` (`status`,`delivered`);--> statement-breakpoint
+ALTER TABLE `mcp_servers` ADD `background_tools` text DEFAULT '[]' NOT NULL;--> statement-breakpoint
+ALTER TABLE `tasks` ADD `follow_up` text;

+ 2732 - 0
apps/server/drizzle/meta/0012_snapshot.json

@@ -0,0 +1,2732 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "ab94bbd8-de0b-41e5-a1f9-97bd1752c3e1",
+  "prevId": "305d48b6-563e-4dbb-b0c8-8f1b276cdfc5",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "background_calls": {
+      "name": "background_calls",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "server_id": {
+          "name": "server_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_name": {
+          "name": "server_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "server_title": {
+          "name": "server_title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tool": {
+          "name": "tool",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "delivered": {
+          "name": "delivered",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "background_calls_session_idx": {
+          "name": "background_calls_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_team_idx": {
+          "name": "background_calls_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        },
+        "background_calls_status_idx": {
+          "name": "background_calls_status_idx",
+          "columns": [
+            "status",
+            "delivered"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bot_api_keys": {
+      "name": "bot_api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_bot_ids": {
+          "name": "allowed_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bot_api_keys_key_hash_unique": {
+          "name": "bot_api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bots": {
+      "name": "bots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "avatar": {
+          "name": "avatar",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'🤖'"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "bots_name_unique": {
+          "name": "bots_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skill_versions": {
+      "name": "custom_skill_versions",
+      "columns": {
+        "skill_id": {
+          "name": "skill_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reason": {
+          "name": "reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "changed": {
+          "name": "changed",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "custom_skill_versions_skill_id_version_pk": {
+          "columns": [
+            "skill_id",
+            "version"
+          ],
+          "name": "custom_skill_versions_skill_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "custom_skills": {
+      "name": "custom_skills",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source_id": {
+          "name": "source_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "include_in_all": {
+          "name": "include_in_all",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "published_version": {
+          "name": "published_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "latest_version": {
+          "name": "latest_version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "working_files": {
+          "name": "working_files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "custom_skills_name_unique": {
+          "name": "custom_skills_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "jobs": {
+      "name": "jobs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "plan": {
+          "name": "plan",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'running'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_runs": {
+          "name": "lead_runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "run_ids": {
+          "name": "run_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "jobs_team_idx": {
+          "name": "jobs_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "mcp_servers": {
+      "name": "mcp_servers",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "transport": {
+          "name": "transport",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "headers": {
+          "name": "headers",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "command": {
+          "name": "command",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "args": {
+          "name": "args",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "env": {
+          "name": "env",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "allow_network": {
+          "name": "allow_network",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "read_paths": {
+          "name": "read_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "write_paths": {
+          "name": "write_paths",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tool_policies": {
+          "name": "tool_policies",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{}'"
+        },
+        "background_tools": {
+          "name": "background_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_cache": {
+          "name": "tools_cache",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "tools_updated_at": {
+          "name": "tools_updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "mcp_servers_name_unique": {
+          "name": "mcp_servers_name_unique",
+          "columns": [
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profile_versions": {
+      "name": "profile_versions",
+      "columns": {
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "snapshot": {
+          "name": "snapshot",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "profile_versions_profile_id_version_pk": {
+          "columns": [
+            "profile_id",
+            "version"
+          ],
+          "name": "profile_versions_profile_id_version_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "mcp_server_ids": {
+          "name": "mcp_server_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin_key": {
+          "name": "builtin_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "context_tokens": {
+          "name": "context_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedule_runs": {
+      "name": "schedule_runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "schedule_id": {
+          "name": "schedule_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "outcome": {
+          "name": "outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "schedule_runs_idx": {
+          "name": "schedule_runs_idx",
+          "columns": [
+            "schedule_id",
+            "id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "schedules": {
+      "name": "schedules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_by_bot_id": {
+          "name": "created_by_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "prompt": {
+          "name": "prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "spec": {
+          "name": "spec",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "status_note": {
+          "name": "status_note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "next_run_at": {
+          "name": "next_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_run_at": {
+          "name": "last_run_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_outcome": {
+          "name": "last_outcome",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "run_count": {
+          "name": "run_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "schedules_due_idx": {
+          "name": "schedules_due_idx",
+          "columns": [
+            "status",
+            "next_run_at"
+          ],
+          "isUnique": false
+        },
+        "schedules_session_idx": {
+          "name": "schedules_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        },
+        "schedules_team_idx": {
+          "name": "schedules_team_idx",
+          "columns": [
+            "team_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "skill_sources": {
+      "name": "skill_sources",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "sync_mode": {
+          "name": "sync_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'manual'"
+        },
+        "interval_min": {
+          "name": "interval_min",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 10
+        },
+        "last_sync_at": {
+          "name": "last_sync_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "last_sync_digest": {
+          "name": "last_sync_digest",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'never'"
+        },
+        "last_error": {
+          "name": "last_error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tasks": {
+      "name": "tasks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "job_id": {
+          "name": "job_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "no": {
+          "name": "no",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "instructions": {
+          "name": "instructions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "depends_on": {
+          "name": "depends_on",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "follow_up": {
+          "name": "follow_up",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "result": {
+          "name": "result",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tasks_team_idx": {
+          "name": "tasks_team_idx",
+          "columns": [
+            "team_id",
+            "no"
+          ],
+          "isUnique": false
+        },
+        "tasks_job_idx": {
+          "name": "tasks_job_idx",
+          "columns": [
+            "job_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bot_sessions": {
+      "name": "team_bot_sessions",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "last_read_seq": {
+          "name": "last_read_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "reset_requested": {
+          "name": "reset_requested",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "team_bot_sessions_session_id_unique": {
+          "name": "team_bot_sessions_session_id_unique",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bot_sessions_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bot_sessions_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_bots": {
+      "name": "team_bots",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bot_id": {
+          "name": "bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "joined_at": {
+          "name": "joined_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "team_bots_bot_idx": {
+          "name": "team_bots_bot_idx",
+          "columns": [
+            "bot_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_bots_team_id_bot_id_pk": {
+          "columns": [
+            "team_id",
+            "bot_id"
+          ],
+          "name": "team_bots_team_id_bot_id_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_cases": {
+      "name": "team_cases",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lead_bot_id": {
+          "name": "lead_bot_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "worker_bot_ids": {
+          "name": "worker_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "stub_bot_ids": {
+          "name": "stub_bot_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "human_name": {
+          "name": "human_name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mentions": {
+          "name": "mentions",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "files": {
+          "name": "files",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "team_events": {
+      "name": "team_events",
+      "columns": {
+        "team_id": {
+          "name": "team_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "team_events_team_id_seq_pk": {
+          "columns": [
+            "team_id",
+            "seq"
+          ],
+          "name": "team_events_team_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "teams": {
+      "name": "teams",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "human": {
+          "name": "human",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'active'"
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "limits": {
+          "name": "limits",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "debug": {
+          "name": "debug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'{\"stepMode\":false,\"stubBotIds\":[]}'"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "case_id": {
+          "name": "case_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "teams_source_idx": {
+          "name": "teams_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        },
+        "teams_case_idx": {
+          "name": "teams_case_idx",
+          "columns": [
+            "case_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -85,6 +85,13 @@
       "when": 1793089168549,
       "tag": "0011_schedules",
       "breakpoints": true
+    },
+    {
+      "idx": 12,
+      "version": "6",
+      "when": 1793091344696,
+      "tag": "0012_background_calls",
+      "breakpoints": true
     }
   ]
 }

+ 21 - 0
apps/server/src/api-reference/background-reference.ts

@@ -0,0 +1,21 @@
+// 后台调用的协议参考(docs/design/15-background-calls.md §7),Agent API 与 Bot API 共用类型
+import type { ApiTypeDef } from '@agent-server/shared'
+
+export const BACKGROUND_DESC = '外部工具(MCP 服务)的调用超过一定时间,或工具被标记为"后台执行"时,转为后台执行:AI 先结束这一轮,服务端继续等待,完成、失败或取消后把结果作为一条新消息发给发起方,唤醒 AI 继续处理。平台可以查看进度和取消。'
+
+export const BACKGROUND_TYPES: ApiTypeDef[] = [
+  {
+    name: 'BackgroundCall', fields: [
+      { name: 'id', type: 'string', desc: 'bg_ 开头' },
+      { name: 'sessionId', type: 'string', desc: '发起调用的会话' },
+      { name: 'teamId / botId / taskId', type: 'string | null', desc: '工作组中发起时:工作组、机器人和所在的子任务' },
+      { name: 'serverName / serverTitle / tool', type: 'string', desc: 'MCP 服务和工具' },
+      { name: 'args', type: 'object', desc: '调用参数' },
+      { name: 'status', type: '"running" | "completed" | "failed" | "cancelled"', desc: '' },
+      { name: 'startedAt / endedAt', type: 'string | null', desc: '' },
+      { name: 'result', type: 'string | null', desc: '结果文本;较长时为开头和完整结果的文件路径' },
+      { name: 'error', type: 'string | null', desc: '失败或取消的原因' },
+      { name: 'delivered', type: 'boolean', desc: '结果是否已发给发起方' },
+    ],
+  },
+]

+ 16 - 1
apps/server/src/api-reference/bot-reference.ts

@@ -2,6 +2,7 @@
 // test/api-reference.test.ts 会核对这里列出的接口与实际注册的 /bot/v1 路由完全一致,新增或删除接口时必须同步修改。
 
 import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiTypeDef } from '@agent-server/shared'
+import { BACKGROUND_DESC, BACKGROUND_TYPES } from './background-reference'
 import { SCHEDULE_DESC, SCHEDULE_TYPES } from './schedule-reference'
 
 export const BOT_OVERVIEW: ApiOverview = {
@@ -262,6 +263,18 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
       },
     ],
   },
+  {
+    id: 'background-calls',
+    title: '后台调用',
+    desc: `${BACKGROUND_DESC}工作组中,结果发给发起调用的机器人;它在执行子任务时,子任务进入"等待后台调用"状态,结果到达后继续。`,
+    endpoints: [
+      { method: 'GET', path: team('/background-calls'), summary: '工作组的后台调用', response: { status: 200, type: 'BackgroundCall[]' } },
+      {
+        method: 'POST', path: team('/background-calls/:callId/cancel'), summary: '取消后台调用',
+        response: { status: 200, type: 'BackgroundCall' }, errors: ['409 background_call_ended'],
+      },
+    ],
+  },
   {
     id: 'schedules',
     title: '定时任务',
@@ -281,6 +294,7 @@ export const BOT_GROUPS: ApiEndpointGroup[] = [
 
 export const BOT_TYPES: ApiTypeDef[] = [
   ...SCHEDULE_TYPES,
+  ...BACKGROUND_TYPES,
   {
     name: 'BrowserToken', fields: [
       { name: 'token', type: 'string', desc: '以 bt_ 开头,只能用于 /bot/v1' },
@@ -332,7 +346,7 @@ export const BOT_TYPES: ApiTypeDef[] = [
       { name: 'jobId / botId', type: 'string', desc: '所属任务、执行的组员' },
       { name: 'title / instructions', type: 'string', desc: '组长写的标题与说明' },
       { name: 'dependsOn', type: 'number[]', desc: '依赖的子任务序号' },
-      { name: 'status', type: '"waiting" | "queued" | "running" | "stub" | "completed" | "failed" | "cancelled"', desc: 'waiting:等待依赖;stub:等待替身结果(仅后台)' },
+      { name: 'status', type: '"waiting" | "queued" | "running" | "stub" | "background" | "completed" | "failed" | "cancelled"', desc: 'waiting:等待依赖;stub:等待替身结果(仅后台);background:等待后台调用' },
       { name: 'result / files', type: 'string | null / object[]', desc: '结果(或失败原因)与交付文件' },
       { name: 'runId / sessionId', type: 'string | null', desc: '执行它的 Run 与会话' },
       { name: 'costUsd', type: 'number', desc: '' },
@@ -426,6 +440,7 @@ export const BOT_EVENTS: ApiEventDef[] = [
 
 export const BOT_ERRORS: ApiErrorDef[] = [
   { status: 400, code: 'bad_request', desc: '参数不正确(details 中有逐项说明)' },
+  { status: 409, code: 'background_call_ended', desc: '后台调用已经结束' },
   { status: 409, code: 'schedule_ended', desc: '定时任务已结束(只执行一次的已执行,或执行时间已经过去)' },
   { status: 400, code: 'mention_required', desc: '工作组有多个组员且没有组长,消息必须 @ 机器人' },
   { status: 401, code: 'unauthorized', desc: '缺少 Authorization' },

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

@@ -3,6 +3,7 @@
 
 import type { ApiEndpointGroup, ApiErrorDef, ApiEventDef, ApiOverview, ApiSpec, ApiTypeDef } from '@agent-server/shared'
 import { BOT_ERRORS, BOT_EVENTS, BOT_GROUPS, BOT_OVERVIEW, BOT_TYPES } from './bot-reference'
+import { BACKGROUND_DESC, BACKGROUND_TYPES } from './background-reference'
 import { SCHEDULE_DESC, SCHEDULE_TYPES } from './schedule-reference'
 
 export type { ApiEndpoint as Endpoint, ApiField as Field } from '@agent-server/shared'
@@ -222,6 +223,18 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
       },
     ],
   },
+  {
+    id: 'background-calls',
+    title: '后台调用',
+    desc: BACKGROUND_DESC,
+    endpoints: [
+      { method: 'GET', path: '/v1/sessions/:id/background-calls', summary: '会话的后台调用', desc: '最近 50 次,新的在前。', response: { status: 200, type: 'BackgroundCall[]' } },
+      {
+        method: 'POST', path: '/v1/background-calls/:id/cancel', summary: '取消后台调用',
+        desc: '取消后 AI 会收到【后台调用已取消】消息。', response: { status: 200, type: 'BackgroundCall' }, errors: ['409 background_call_ended'],
+      },
+    ],
+  },
   {
     id: 'schedules',
     title: '定时任务',
@@ -253,6 +266,7 @@ data: {"seq":12,"type":"message.delta","sessionId":"ses_…","runId":"run_…","
 
 export const API_TYPES: ApiTypeDef[] = [
   ...SCHEDULE_TYPES,
+  ...BACKGROUND_TYPES,
   {
     name: 'BrowserToken', fields: [
       { name: 'token', type: 'string', desc: '以 bt_ 开头,放在 Authorization: Bearer 中使用' },
@@ -388,6 +402,7 @@ export const API_ERRORS: ApiErrorDef[] = [
   { status: 401, code: 'invalid_token', desc: '浏览器令牌无效、过期,或签发它的 Agent Key 已停用(SDK 会自动刷新一次)' },
   { status: 403, code: 'forbidden', desc: '无权执行该操作' },
   { status: 403, code: 'profile_not_allowed', desc: 'Profile 不对本平台开放' },
+  { status: 409, code: 'background_call_ended', desc: '后台调用已经结束' },
   { status: 409, code: 'schedule_ended', desc: '定时任务已结束(只执行一次的已执行,或执行时间已经过去)' },
   { status: 404, code: 'not_found', desc: '资源不存在,或不属于本平台' },
   { status: 409, code: 'session_busy', desc: '会话正在运行' },

+ 3 - 1
apps/server/src/app.ts

@@ -30,6 +30,7 @@ import { SkillSourceService } from './custom-skills/sources'
 import { FileManagerService } from './services/file-manager'
 import { BrowserTokenService } from './services/browser-tokens'
 import { BotKeyService, BotService } from './teams/bots'
+import { BackgroundCallService } from './mcp-gateway/background'
 import { ScheduleService } from './schedules/service'
 import { TeamCaseService } from './teams/cases'
 import { ensureLeadProfiles } from './teams/lead-profile'
@@ -97,9 +98,10 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
     },
   })
   const teamCases = new TeamCaseService({ db, teams, dir: teamCasesDir() })
+  const backgroundCalls = new BackgroundCallService({ db, log, settings, runs, events, teams })
   const schedules = new ScheduleService({ db, log, settings, sessions, runs, events, teams, bots, profiles })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, skillCatalog }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, browserTokens, apiPdf, mcpUpstream, mcpServers, customSkills, skillSources, fileManager, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs, bots, botKeys, teams, teamCards, teamCases, schedules, backgroundCalls, skillCatalog }
 }
 
 export type App = ReturnType<typeof createApp>

+ 25 - 1
apps/server/src/db/schema.ts

@@ -193,6 +193,8 @@ export const mcpServers = sqliteTable('mcp_servers', {
   writePaths: text('write_paths', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   /** 工具策略(工具名 → auto / ask / off);没有设置的工具按默认:只读工具 auto,其余 ask */
   toolPolicies: text('tool_policies', { mode: 'json' }).$type<Record<string, McpToolPolicy>>().notNull().default(sql`'{}'`),
+  /** 总是在后台执行的工具(docs/design/15-background-calls.md §3) */
+  backgroundTools: text('background_tools', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
   /** 最近一次列出的工具(引擎列工具时直接使用,不必每次启动服务) */
   toolsCache: text('tools_cache', { mode: 'json' }).$type<McpCachedTool[]>().notNull().default(sql`'[]'`),
   toolsUpdatedAt: text('tools_updated_at'),
@@ -354,7 +356,9 @@ export const tasks = sqliteTable('tasks', {
   title: text('title').notNull(),
   instructions: text('instructions').notNull(),
   dependsOn: text('depends_on', { mode: 'json' }).$type<number[]>().notNull().default(sql`'[]'`),
-  status: text('status', { enum: ['waiting', 'queued', 'running', 'stub', 'completed', 'failed', 'cancelled'] }).notNull(),
+  status: text('status', { enum: ['waiting', 'queued', 'running', 'stub', 'background', 'completed', 'failed', 'cancelled'] }).notNull(),
+  /** 等待的后台调用结束后,组员下一轮收到的后续输入(docs/design/15-background-calls.md §4) */
+  followUp: text('follow_up'),
   result: text('result'),
   files: text('files', { mode: 'json' }).$type<TeamFileRef[]>().notNull().default(sql`'[]'`),
   runId: text('run_id'),
@@ -411,3 +415,23 @@ export const scheduleRuns = sqliteTable('schedule_runs', {
   costUsd: real('cost_usd'),
   note: text('note'),
 }, t => [index('schedule_runs_idx').on(t.scheduleId, t.id)])
+
+/** 后台调用(docs/design/15-background-calls.md):外部工具的长调用,由服务端等待,完成后通知发起方 */
+export const backgroundCalls = sqliteTable('background_calls', {
+  id: text('id').primaryKey(),
+  sessionId: text('session_id').notNull(),
+  teamId: text('team_id'),
+  botId: text('bot_id'),
+  taskId: text('task_id'),
+  serverId: text('server_id').notNull(),
+  serverName: text('server_name').notNull(),
+  serverTitle: text('server_title').notNull(),
+  tool: text('tool').notNull(),
+  args: text('args', { mode: 'json' }).notNull(),
+  status: text('status', { enum: ['running', 'completed', 'failed', 'cancelled'] }).notNull().default('running'),
+  startedAt: text('started_at').notNull().default(now),
+  endedAt: text('ended_at'),
+  result: text('result'),
+  error: text('error'),
+  delivered: integer('delivered', { mode: 'boolean' }).notNull().default(false),
+}, t => [index('background_calls_session_idx').on(t.sessionId), index('background_calls_team_idx').on(t.teamId), index('background_calls_status_idx').on(t.status, t.delivered)])

+ 12 - 0
apps/server/src/http/routes/admin.ts

@@ -183,6 +183,10 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'off']) }), req.body).policy
       return app.mcpServers.setToolPolicy(req.params.id, req.params.tool, policy)
     })
+    g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/background', async req => {
+      const { background } = parse(z.object({ background: z.boolean() }), req.body)
+      return app.mcpServers.setToolBackground(req.params.id, req.params.tool, background)
+    })
     g.post('/mcp/test', async req => {
       const b = req.body as { input: McpServerInput; id?: string | null }
       return app.mcpServers.test(b.input, b.id ?? null)
@@ -519,6 +523,14 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       reply.code(204)
     })
 
+    // 后台调用总览(docs/design/15-background-calls.md §7)
+    g.get('/background-calls', async () => app.backgroundCalls.overview())
+    g.post<IdParams>('/background-calls/:id/cancel', async req => {
+      const call = app.backgroundCalls.cancel(req.params.id, '管理员取消了这次调用')
+      app.audit.log({ actor: 'admin', kind: 'admin', summary: `取消后台调用:${call.serverName}__${call.tool}` })
+      return call
+    })
+
     // 定时任务总览(docs/design/14-schedules.md §9)
     g.get('/schedules', async () => app.schedules.overview())
     g.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(app.schedules.get(req.params.id).id))

+ 10 - 0
apps/server/src/http/routes/v1.ts

@@ -117,6 +117,7 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
   f.delete<IdParams>('/sessions/:id', async (req, reply) => {
     await app.sessions.delete(req.params.id, principal(req).source)
     app.schedules.deleteFor({ kind: 'session', sessionId: req.params.id })
+    app.backgroundCalls.cancelFor({ sessionId: req.params.id })
     app.gatewayTokens.forgetSession(req.params.id)
     forgetGatewaySession(req.params.id)
     reply.code(204)
@@ -138,6 +139,15 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
   })
   f.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(ownSchedule(req).id))
 
+  // ---- 后台调用(docs/design/15-background-calls.md):外部工具的长调用,平台可以查看和取消 ----
+  f.get<IdParams>('/sessions/:id/background-calls', async req => app.backgroundCalls.list({ sessionId: sessionFor(req).id }))
+  f.post<IdParams>('/background-calls/:id/cancel', async req => {
+    const call = app.backgroundCalls.get(req.params.id)
+    if (call.teamId) throw notFound('后台调用')
+    app.sessions.getRow(call.sessionId, principal(req).source)
+    return app.backgroundCalls.cancel(call.id)
+  })
+
   f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/sessions/:id/events', async req => {
     const row = sessionFor(req)
     return { items: app.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq }

+ 1 - 1
apps/server/src/http/server.ts

@@ -54,7 +54,7 @@ export async function buildServer(app: App, opts: { logger: FastifyServerOptions
 
   await f.register(v1Routes, { prefix: '/v1', app })
   await f.register(botRoutes, { prefix: '/bot/v1', app })
-  registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream })
+  registerGateway(f, app.gatewayTokens, { mcp: app.mcpServers, upstream: app.mcpUpstream, background: app.backgroundCalls })
   await f.register(adminRoutes, { prefix: '/admin/v1', app })
 
   // 生产环境由服务托管后台静态资源,同源访问

+ 1 - 1
apps/server/src/lib/ids.ts

@@ -1,6 +1,6 @@
 import { ulid } from 'ulid'
 
-export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch'
+export type IdPrefix = 'ses' | 'run' | 'itx' | 'file' | 'prf' | 'kb' | 'key' | 'adm' | 'msg' | 'mcp' | 'csk' | 'src' | 'bot' | 'bkey' | 'team' | 'tmsg' | 'job' | 'task' | 'step' | 'case' | 'sch' | 'bg'
 
 export const newId = (prefix: IdPrefix) => `${prefix}_${ulid().toLowerCase()}`
 

+ 252 - 0
apps/server/src/mcp-gateway/background.ts

@@ -0,0 +1,252 @@
+// 后台调用(docs/design/15-background-calls.md):外部 MCP 工具的调用超过一定时间(或工具被标记为总是后台执行)时,
+// 网关立即告诉模型"已转为后台执行",由服务端继续等待;结束后把结果作为一条新消息发给发起方(会话,或工作组中发起调用的机器人),唤醒 AI 继续处理
+import fs from 'node:fs'
+import path from 'node:path'
+import { and, desc, eq, inArray } from 'drizzle-orm'
+import type { AgentEvent, BackgroundCallDto, BackgroundCallOverviewDto, BackgroundCallStatus } from '@agent-server/shared'
+import type { AppLogger } from '../app'
+import type { DB } from '../db'
+import { backgroundCalls, sessions, teams } from '../db/schema'
+import { conflict, notFound } from '../lib/errors'
+import { newId, nowIso } from '../lib/ids'
+import type { EventStore } from '../services/event-store'
+import type { RunManager } from '../services/runs'
+import type { SettingsService } from '../services/settings'
+import type { TeamService } from '../teams/service'
+import { ok, type ToolResult } from './builtin'
+import type { GatewayRunContext } from './tokens'
+
+type CallRow = typeof backgroundCalls.$inferSelect
+
+/** 发给 AI 的结果超过这个长度时保存为文件,消息中只放开头和路径 */
+const INLINE_RESULT = 8000
+
+export interface BackgroundCallRequest<R> {
+  ctx: GatewayRunContext
+  server: { id: string; name: string; title: string }
+  tool: string
+  args: Record<string, unknown>
+  /** 工具被标记为总是后台执行 */
+  always: boolean
+  /** 这一轮的中断信号:转为后台之前中断这一轮时,调用随之取消 */
+  runSignal: AbortSignal
+  /** 按原来的方式等待时的超时 */
+  foregroundTimeoutMs: number
+  invoke: (signal: AbortSignal, timeoutMs: number) => Promise<R>
+  toResult: (r: R) => ToolResult
+}
+
+export class BackgroundCallService {
+  private controllers = new Map<string, AbortController>()
+  /** 已结束、等会话这一轮执行完再通知的调用 */
+  private pending = new Set<string>()
+  private timer: NodeJS.Timeout
+
+  constructor(private deps: { db: DB; log: AppLogger; settings: SettingsService; runs: RunManager; events: EventStore; teams: TeamService }) {
+    deps.teams.onTaskCancelled = taskId => {
+      for (const r of this.deps.db.select().from(backgroundCalls).where(and(eq(backgroundCalls.taskId, taskId), eq(backgroundCalls.status, 'running'))).all()) {
+        this.end(r.id, 'cancelled', null, '子任务已取消', false)
+      }
+    }
+    // 会话的这一轮结束后,补发等待中的通知
+    deps.events.subscribe('*', (e: AgentEvent) => {
+      if ((e.type === 'run.completed' || e.type === 'run.failed' || e.type === 'run.interrupted') && this.pending.size) setImmediate(() => this.flush(e.sessionId))
+    })
+    this.timer = setInterval(() => this.flush(), 15_000)
+    this.timer.unref()
+    // 服务重启:与外部服务的连接已断开,进行中的调用无法接续
+    const interrupted = deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.status, 'running')).all()
+    const undelivered = deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.delivered, false)).all().filter(r => r.status !== 'running')
+    setImmediate(() => {
+      for (const r of interrupted) this.end(r.id, 'failed', null, '服务重启,后台调用中断(可以重新发起)')
+      for (const r of undelivered) this.deliver(r.id)
+    })
+  }
+
+  stop() {
+    clearInterval(this.timer)
+    for (const c of this.controllers.values()) c.abort()
+  }
+
+  /** 网关调用外部工具:必要时转到后台 */
+  async call<R>(req: BackgroundCallRequest<R>): Promise<ToolResult> {
+    const s = this.deps.settings.get().backgroundCalls
+    const team = this.deps.teams.teamOfSession(req.ctx.sessionId)
+    const running = this.deps.db.select({ id: backgroundCalls.id }).from(backgroundCalls)
+      .where(and(eq(backgroundCalls.status, 'running'), team ? eq(backgroundCalls.teamId, team.teamId) : eq(backgroundCalls.sessionId, req.ctx.sessionId))).all().length
+    // 后台调用已达上限:按原来的方式等待
+    if (running >= s.maxPerTarget) return req.toResult(await req.invoke(req.runSignal, req.foregroundTimeoutMs))
+
+    const controller = new AbortController()
+    const onAbort = () => controller.abort()
+    req.runSignal.addEventListener('abort', onAbort, { once: true })
+    const promise = req.invoke(controller.signal, s.maxHours * 3_600_000)
+
+    if (!req.always) {
+      let timer: NodeJS.Timeout | undefined
+      const first = await Promise.race([
+        promise.then(r => ({ done: true as const, r }), (err: unknown) => ({ done: true as const, err })),
+        new Promise<{ done: false }>(resolve => (timer = setTimeout(() => resolve({ done: false }), s.autoAfterSec * 1000))),
+      ])
+      clearTimeout(timer)
+      if (first.done) {
+        req.runSignal.removeEventListener('abort', onAbort)
+        if ('err' in first) throw first.err
+        return req.toResult(first.r)
+      }
+    }
+
+    // 转为后台:与这一轮无关,只能单独取消
+    req.runSignal.removeEventListener('abort', onAbort)
+    const id = newId('bg')
+    const ref = this.deps.teams.noteBackground(req.ctx.sessionId)
+    this.deps.db.insert(backgroundCalls).values({
+      id, sessionId: req.ctx.sessionId, teamId: ref?.teamId ?? null, botId: ref?.botId ?? null, taskId: ref?.taskId ?? null,
+      serverId: req.server.id, serverName: req.server.name, serverTitle: req.server.title, tool: req.tool, args: req.args,
+    }).run()
+    this.controllers.set(id, controller)
+    const limit = setTimeout(() => this.end(id, 'failed', null, `超过 ${s.maxHours} 小时,已停止`), s.maxHours * 3_600_000)
+    limit.unref()
+    promise.then(
+      r => {
+        const result = req.toResult(r)
+        const text = resultText(result)
+        if (result.isError) this.end(id, 'failed', null, text, true, req.ctx.workspaceDir)
+        else this.end(id, 'completed', text, null, true, req.ctx.workspaceDir)
+      },
+      (err: unknown) => this.end(id, 'failed', null, (err as Error).message),
+    ).finally(() => clearTimeout(limit))
+    this.deps.log.info({ id, sessionId: req.ctx.sessionId, tool: `${req.server.name}__${req.tool}` }, 'tool call moved to background')
+    return ok(`调用「${req.server.title} · ${req.tool}」耗时较长,已转为后台执行(后台调用 ${id})。完成后系统会把结果作为一条新消息发给你。请结束本轮,告诉用户已提交、完成后会继续处理;不要等待或轮询。`)
+  }
+
+  // ---- 查询与取消 ----
+
+  list(filter: { sessionId?: string; teamId?: string }, limit = 50): BackgroundCallDto[] {
+    const where = filter.teamId ? eq(backgroundCalls.teamId, filter.teamId) : eq(backgroundCalls.sessionId, filter.sessionId!)
+    return this.deps.db.select().from(backgroundCalls).where(where).orderBy(desc(backgroundCalls.startedAt)).limit(limit).all().map(toDto)
+  }
+
+  overview(limit = 200): BackgroundCallOverviewDto[] {
+    const rows = this.deps.db.select().from(backgroundCalls).orderBy(desc(backgroundCalls.startedAt)).limit(limit).all()
+    const sessionIds = [...new Set(rows.filter(r => !r.teamId).map(r => r.sessionId))]
+    const teamIds = [...new Set(rows.flatMap(r => (r.teamId ? [r.teamId] : [])))]
+    const sessionMap = new Map(sessionIds.length ? this.deps.db.select({ id: sessions.id, title: sessions.title, source: sessions.source }).from(sessions).where(inArray(sessions.id, sessionIds)).all().map(x => [x.id, x]) : [])
+    const teamMap = new Map(teamIds.length ? this.deps.db.select({ id: teams.id, title: teams.title, source: teams.source }).from(teams).where(inArray(teams.id, teamIds)).all().map(x => [x.id, x]) : [])
+    return rows.map(r => {
+      const t = r.teamId ? teamMap.get(r.teamId) : sessionMap.get(r.sessionId)
+      return { ...toDto(r), targetTitle: t?.title || '(已删除)', source: t?.source ?? '' }
+    })
+  }
+
+  get(id: string): CallRow {
+    const row = this.deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.id, id)).get()
+    if (!row) throw notFound('后台调用')
+    return row
+  }
+
+  /** 取消进行中的调用,并通知发起方 */
+  cancel(id: string, reason = '成员取消了这次调用'): BackgroundCallDto {
+    const row = this.get(id)
+    if (row.status !== 'running') throw conflict('background_call_ended', '后台调用已经结束')
+    this.end(id, 'cancelled', null, reason)
+    return toDto(this.get(id))
+  }
+
+  /** 会话或工作组删除时:取消进行中的调用,不再通知 */
+  cancelFor(filter: { sessionId?: string; teamId?: string }) {
+    const where = filter.teamId ? eq(backgroundCalls.teamId, filter.teamId) : eq(backgroundCalls.sessionId, filter.sessionId!)
+    for (const r of this.deps.db.select().from(backgroundCalls).where(and(where, eq(backgroundCalls.status, 'running'))).all()) {
+      this.end(r.id, 'cancelled', null, '会话或工作组已删除', false)
+    }
+  }
+
+  // ---- 结束与通知 ----
+
+  private end(id: string, status: Exclude<BackgroundCallStatus, 'running'>, result: string | null, error: string | null, notify = true, workspaceDir?: string) {
+    const row = this.deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.id, id)).get()
+    if (!row || row.status !== 'running') return
+    this.controllers.get(id)?.abort()
+    this.controllers.delete(id)
+    const stored = result !== null && workspaceDir ? this.storeResult(id, row.tool, result, workspaceDir) : result
+    this.deps.db.update(backgroundCalls).set({ status, result: stored, error, endedAt: nowIso(), delivered: !notify }).where(eq(backgroundCalls.id, id)).run()
+    if (notify) this.deliver(id)
+  }
+
+  /** 较长的结果保存到工作区,交给 AI 的只有开头和路径 */
+  private storeResult(id: string, tool: string, text: string, workspaceDir: string): string {
+    if (text.length <= INLINE_RESULT) return text
+    try {
+      const dir = path.join(workspaceDir, '.agent', 'mcp')
+      fs.mkdirSync(dir, { recursive: true })
+      const file = path.join(dir, `background-${tool.replace(/[^\w.-]/g, '_')}-${id}.txt`)
+      fs.writeFileSync(file, text)
+      return `${text.slice(0, 2000)}\n…(共 ${text.length} 字,完整结果已保存到 ${path.relative(workspaceDir, file)})`
+    } catch {
+      return `${text.slice(0, INLINE_RESULT)}\n…(结果过长,已截断)`
+    }
+  }
+
+  /** 把结果发给发起方:工作组交给发起调用的机器人;会话正在执行时等这一轮结束 */
+  private deliver(id: string) {
+    const row = this.deps.db.select().from(backgroundCalls).where(eq(backgroundCalls.id, id)).get()
+    if (!row || row.delivered || row.status === 'running') return
+    const text = deliveryText(row)
+    const done = () => {
+      this.pending.delete(id)
+      this.deps.db.update(backgroundCalls).set({ delivered: true }).where(eq(backgroundCalls.id, id)).run()
+    }
+    try {
+      if (row.teamId) {
+        this.deps.teams.deliverBackground(row.teamId, row.botId!, row.taskId, text)
+        done()
+        return
+      }
+      const session = this.deps.db.select().from(sessions).where(eq(sessions.id, row.sessionId)).get()
+      if (!session) return done()
+      if (this.deps.runs.activeForSession(session.id)) {
+        this.pending.add(id)
+        return
+      }
+      this.deps.runs.start(session, text, [])
+      done()
+    } catch (err) {
+      if ((err as { code?: string }).code === 'session_busy') {
+        this.pending.add(id)
+        return
+      }
+      this.deps.log.warn({ err, id }, 'background call delivery failed')
+      done()
+    }
+  }
+
+  private flush(sessionId?: string) {
+    for (const id of [...this.pending]) {
+      if (sessionId && this.deps.db.select({ s: backgroundCalls.sessionId }).from(backgroundCalls).where(eq(backgroundCalls.id, id)).get()?.s !== sessionId) continue
+      this.deliver(id)
+    }
+  }
+}
+
+function resultText(r: ToolResult): string {
+  return r.content.map(c => (c.type === 'text' ? c.text : c.type === 'image' ? '[图片]' : `[${c.type}]`)).join('\n').trim() || '(没有返回内容)'
+}
+
+function duration(from: string, to: string | null) {
+  const min = Math.max(0, Math.round(((to ? Date.parse(to) : Date.now()) - Date.parse(from)) / 60_000))
+  return min < 60 ? `${min} 分钟` : `${Math.floor(min / 60)} 小时 ${min % 60} 分钟`
+}
+
+function deliveryText(r: CallRow): string {
+  const head = { completed: '后台调用完成', failed: '后台调用失败', cancelled: '后台调用已取消', running: '' }[r.status]
+  const meta = `${r.serverTitle} · ${r.tool}(${r.id},耗时 ${duration(r.startedAt, r.endedAt)})`
+  const body = r.status === 'completed' ? `结果:\n${r.result ?? ''}` : `原因:${r.error ?? ''}`
+  return `【${head}】${meta}\n参数:${JSON.stringify(r.args).slice(0, 500)}\n${body}`
+}
+
+function toDto(r: CallRow): BackgroundCallDto {
+  return {
+    id: r.id, sessionId: r.sessionId, teamId: r.teamId, botId: r.botId, taskId: r.taskId, serverName: r.serverName, serverTitle: r.serverTitle,
+    tool: r.tool, args: r.args, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt, result: r.result, error: r.error, delivered: r.delivered,
+  }
+}

+ 17 - 5
apps/server/src/mcp-gateway/server.ts

@@ -12,6 +12,7 @@ import { z } from 'zod'
 import type { AppSettings } from '@agent-server/shared'
 import type { McpCachedTool } from '../db/schema'
 import { defaultPolicy, type McpServerService } from '../services/mcp-servers'
+import type { BackgroundCallService } from './background'
 import { BUILTIN_TOOLS, fail, type ToolContent, type ToolResult } from './builtin'
 import type { GatewayRunContext, RunTokenRegistry } from './tokens'
 import { isTransient, type McpServerRow, type McpUpstream } from './upstream'
@@ -32,6 +33,8 @@ export const gatewayToolTimeoutSec = (s: AppSettings) => s.limits.interactionTim
 export interface GatewayDeps {
   mcp: McpServerService
   upstream: McpUpstream
+  /** 长调用转到后台(docs/design/15-background-calls.md) */
+  background?: BackgroundCallService
 }
 
 /** 会话内"本会话允许"过的第三方工具 */
@@ -117,15 +120,24 @@ 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]))
     }
-    try {
-      const call = () => deps.upstream.callTool(row, tool.name, args, extra.signal, CALL_TIMEOUT_MS) as Promise<{ content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }>
+    type Raw = { content?: ToolContent[]; structuredContent?: unknown; isError?: boolean }
+    const invoke = (signal: AbortSignal, timeoutMs: number) => {
+      const call = () => deps.upstream.callTool(row, tool.name, args, signal, timeoutMs) as Promise<Raw>
       // 偶发的网络错误:只读工具重新连接后重试一次;其他工具不重试,避免重复执行
-      const r = await call().catch(async err => {
-        if (!tool.readOnly || !isTransient(err)) throw err
+      return call().catch(async err => {
+        if (!tool.readOnly || !isTransient(err) || signal.aborted) throw err
         await deps.upstream.close(row.id)
         return call()
       })
-      const result = postProcess(r, ctx.workspaceDir, name)
+    }
+    const toResult = (r: Raw) => postProcess(r, ctx.workspaceDir, name)
+    try {
+      const result = deps.background
+        ? await deps.background.call({
+          ctx, server: { id: row.id, name: row.name, title: row.title }, tool: tool.name, args, always: row.backgroundTools.includes(tool.name),
+          runSignal: extra.signal, foregroundTimeoutMs: CALL_TIMEOUT_MS, invoke, toResult,
+        })
+        : toResult(await invoke(extra.signal, CALL_TIMEOUT_MS))
       audit(`${name}${result.isError ? '(失败)' : ''}`, { server: row.name })
       return result
     } catch (err) {

+ 6 - 2
apps/server/src/mcp-gateway/upstream.ts

@@ -26,6 +26,8 @@ interface Conn {
   client: Client
   stderr: string[]
   lastUsed: number
+  /** 正在执行的调用数:有调用时不因空闲关闭(后台调用可能执行数小时) */
+  inFlight: number
 }
 
 /** 影响连接的配置;变化时需要重新连接 */
@@ -97,9 +99,11 @@ export class McpUpstream {
   async callTool(s: McpServerRow, name: string, args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number) {
     const c = await this.conn(s)
     c.lastUsed = Date.now()
+    c.inFlight++
     try {
       return await c.client.callTool({ name, arguments: args }, undefined, { signal, timeout: timeoutMs, resetTimeoutOnProgress: true, maxTotalTimeout: timeoutMs * 2 })
     } finally {
+      c.inFlight--
       c.lastUsed = Date.now()
     }
   }
@@ -128,7 +132,7 @@ export class McpUpstream {
   }
 
   private closeIdle() {
-    for (const [id, c] of this.conns) if (Date.now() - c.lastUsed > IDLE_MS) void this.close(id)
+    for (const [id, c] of this.conns) if (c.inFlight === 0 && Date.now() - c.lastUsed > IDLE_MS) void this.close(id)
   }
 
   private async conn(s: McpServerRow): Promise<Conn> {
@@ -145,7 +149,7 @@ export class McpUpstream {
     // 远程服务偶发的网络错误重试一次
     const connectOnce = () => this.connect(s, stderr)
     const p = connectOnce().catch(err => (s.transport === 'http' && isTransient(err) ? connectOnce() : Promise.reject(err))).then(client => {
-      const c: Conn = { key, client, stderr, lastUsed: Date.now() }
+      const c: Conn = { key, client, stderr, lastUsed: Date.now(), inFlight: 0 }
       this.conns.set(s.id, c)
       this.errors.delete(s.id)
       // 连接断开(进程退出、服务端关闭)后下次调用重新连接

+ 14 - 5
apps/server/src/services/mcp-servers.ts

@@ -109,6 +109,15 @@ export class McpServerService {
     return tools
   }
 
+  /** 总是在后台执行(docs/design/15-background-calls.md §3) */
+  setToolBackground(id: string, tool: string, background: boolean): McpServerDto {
+    const r = this.row(id)
+    const list = r.backgroundTools.filter(t => t !== tool)
+    this.db.update(mcpServers).set({ backgroundTools: background ? [...list, tool] : list, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
+    this.audit(`MCP 工具 ${r.name}__${tool} ${background ? '设为总是后台执行' : '取消总是后台执行'}`)
+    return this.get(id)
+  }
+
   setToolPolicy(id: string, tool: string, policy: McpToolPolicy): McpServerDto {
     const r = this.row(id)
     this.db.update(mcpServers).set({ toolPolicies: { ...r.toolPolicies, [tool]: policy }, updatedAt: nowIso() }).where(eq(mcpServers.id, id)).run()
@@ -124,7 +133,7 @@ export class McpServerService {
     const tempId = id ?? `test_${Date.now()}`
     try {
       const { tools, stderr } = await this.upstream.test({ ...(prev ?? emptyRow()), ...data, id: tempId })
-      return { ok: true, tools: tools.map(t => toToolDto(t, prev?.toolPolicies ?? {})), error: null, stderrTail: stderr.length ? stderr.join('\n') : null, ms: Date.now() - t0 }
+      return { ok: true, tools: tools.map(t => toToolDto(t, prev?.toolPolicies ?? {}, prev?.backgroundTools ?? [])), error: null, stderrTail: stderr.length ? stderr.join('\n') : null, ms: Date.now() - t0 }
     } catch (err) {
       return { ok: false, tools: [], error: (err as Error).message, stderrTail: null, ms: Date.now() - t0 }
     } finally {
@@ -157,7 +166,7 @@ export class McpServerService {
       id: r.id, name: r.name, title: r.title, description: r.description, transport: r.transport, enabled: r.enabled,
       url: r.url, headers: masked(r.headers), command: r.command, args: r.args, env: masked(r.env),
       allowNetwork: r.allowNetwork, readPaths: r.readPaths, writePaths: r.writePaths,
-      tools: r.toolsCache.map(t => toToolDto(t, r.toolPolicies)), toolsUpdatedAt: r.toolsUpdatedAt,
+      tools: r.toolsCache.map(t => toToolDto(t, r.toolPolicies, r.backgroundTools)), toolsUpdatedAt: r.toolsUpdatedAt,
       ...this.upstream.status(r.id), profileNames, createdAt: r.createdAt, updatedAt: r.updatedAt,
     }
   }
@@ -226,14 +235,14 @@ function mergeSecrets(entries: { name: string; value: string | null }[], prev: R
   return out
 }
 
-function toToolDto(t: McpCachedTool, policies: Record<string, McpToolPolicy>): McpToolDto {
-  return { name: t.name, description: t.description, readOnly: t.readOnly, policy: policies[t.name] ?? defaultPolicy(t) }
+function toToolDto(t: McpCachedTool, policies: Record<string, McpToolPolicy>, background: string[] = []): McpToolDto {
+  return { name: t.name, description: t.description, readOnly: t.readOnly, policy: policies[t.name] ?? defaultPolicy(t), background: background.includes(t.name) }
 }
 
 function emptyRow(): McpServerRow {
   const now = nowIso()
   return {
     id: '', name: '', title: '', description: '', transport: 'http', enabled: true, url: '', headers: {}, command: '', args: [], env: {},
-    allowNetwork: false, readPaths: [], writePaths: [], toolPolicies: {}, toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
+    allowNetwork: false, readPaths: [], writePaths: [], toolPolicies: {}, backgroundTools: [], toolsCache: [], toolsUpdatedAt: null, createdAt: now, updatedAt: now,
   }
 }

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

@@ -25,6 +25,7 @@ export function buildSystemPrompt(opts: {
   - Excel 公式重算与错误检查:\`recalc_workbook\`
 - 抓取网页内容用 \`web_fetch\`(返回 Markdown;PDF 等文件会下载到工作区);查找本地资料前用 \`kb_list_roots\` 查看可用的资料库。
 - 用户要求定期执行或在将来某个时间执行("每天 9 点…""两小时后…")时,用 \`schedule_create\` 创建定时任务:到点时系统会把指令作为新消息发到当前会话(工作组中发到工作组),由你执行。不要用 sleep、后台进程等方式等待。时间按 ${opts.settings.schedules.timezone} 时区;查看、修改、暂停、删除用 \`schedule_list\`、\`schedule_update\`、\`schedule_delete\`。
+- 外部工具的调用耗时较长时会转为后台执行:工具会返回"已转为后台执行",这时结束本轮并告诉用户,结果到达时系统会发来【后台调用完成】消息,再继续处理;不要等待或轮询。
 - Node 脚本可以直接 require 预装的包:docx、pptxgenjs、sharp、react、react-dom、react-icons(不要 npm install)。
 - matplotlib 已配置中文字体,图表可以直接使用中文。
 - 你只能访问被授权的路径;访问被拒绝时向用户说明,不要尝试绕过限制。${opts.permissionMode === 'readonly' ? '\n- 当前会话是只读模式:不能修改文件或执行命令。' : ''}

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

@@ -31,6 +31,7 @@ export function defaultSettings(): AppSettings {
       workspaceMaxMB: 2048,
     },
     retention: { workspaceDays: 30 },
+    backgroundCalls: { autoAfterSec: 180, maxHours: 24, maxPerTarget: 3 },
     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: {
@@ -84,6 +85,11 @@ const settingsSchema: z.ZodType<AppSettings> = z.object({
   }),
   retention: z.object({ workspaceDays: z.number().int().min(1).max(3650) }),
   teamLimits: teamLimitsSchema,
+  backgroundCalls: z.object({
+    autoAfterSec: z.number().int().min(1).max(3600),
+    maxHours: z.number().min(0.1).max(24 * 7),
+    maxPerTarget: z.number().int().min(1).max(50),
+  }),
   schedules: z.object({
     timezone: z.string().refine(tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true } catch { return false } }, '无效的时区'),
     minIntervalHours: z.number().min(0.25).max(24 * 30),

+ 1 - 1
apps/server/src/teams/jobs.ts

@@ -19,7 +19,7 @@ export const LIMIT_LABELS: Record<TeamLimitKey, string> = {
 
 export const ACTIVE_JOB = ['running', 'blocked'] as const
 /** 还没有结束的子任务 */
-export const OPEN_TASK: TaskStatus[] = ['waiting', 'queued', 'running', 'stub']
+export const OPEN_TASK: TaskStatus[] = ['waiting', 'queued', 'running', 'stub', 'background']
 
 export class JobStore {
   constructor(private db: DB) {}

+ 1 - 1
apps/server/src/teams/prompt.ts

@@ -98,7 +98,7 @@ function senderLabel(m: TeamMessage, selfId: string): string {
 
 const TASK_EVENT = { completed: '已完成', failed: '失败', cancelled: '已取消' } as const
 const JOB_STATUS = { running: '进行中', blocked: '已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' } as const
-const TASK_STATUS = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' } as const
+const TASK_STATUS = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', background: '等待后台调用', completed: '已完成', failed: '失败', cancelled: '已取消' } as const
 
 /** 任务纪要:任务结束时保存到共享目录,组长以后可以读取(docs/design/13-bot-teams.md §17) */
 export const MEMO_DIR = '.team/jobs'

+ 10 - 0
apps/server/src/teams/routes.ts

@@ -64,9 +64,19 @@ export default async function botRoutes(f: FastifyInstance, { app }: { app: App
   f.delete<IdParams>('/teams/:id', async (req, reply) => {
     await app.teams.delete(req.params.id, source(req))
     app.schedules.deleteFor({ kind: 'team', teamId: req.params.id })
+    app.backgroundCalls.cancelFor({ teamId: req.params.id })
     reply.code(204)
   })
 
+  // ---- 后台调用(docs/design/15-background-calls.md):机器人调用外部工具的长调用,平台可以查看和取消 ----
+  f.get<IdParams>('/teams/:id/background-calls', async req => app.backgroundCalls.list({ teamId: teamFor(req).id }))
+  f.post<{ Params: { id: string; callId: string } }>('/teams/:id/background-calls/:callId/cancel', async req => {
+    const team = app.teams.getRow(req.params.id, source(req))
+    const call = app.backgroundCalls.get(req.params.callId)
+    if (call.teamId !== team.id) throw notFound('后台调用')
+    return app.backgroundCalls.cancel(call.id)
+  })
+
   // ---- 定时任务(docs/design/14-schedules.md):由机器人在对话中创建,平台可以查看、暂停、恢复、删除 ----
   type ScheduleParams = { Params: { id: string; scheduleId: string } }
   f.get<IdParams>('/teams/:id/schedules', async req => app.schedules.list({ kind: 'team', teamId: teamFor(req).id }))

+ 43 - 5
apps/server/src/teams/service.ts

@@ -82,6 +82,8 @@ interface BotRuntime {
   pendingWrites: Map<string, { name: string; paths: string[] }>
   /** 本轮有其他机器人同时工作:扫描到的改动无法确定是谁的,只认写文件工具的记录 */
   overlapped: boolean
+  /** 本轮有外部工具调用被转到后台(docs/design/15-background-calls.md §4) */
+  backgroundStarted: boolean
   /** 本轮执行的子任务,以及组员调用 task_complete / task_fail 报告的结果 */
   taskId: string | null
   taskOutcome: { status: 'completed' | 'failed'; result: string; paths: string[] } | null
@@ -338,6 +340,33 @@ export class TeamService {
     throw new AppError(400, 'mention_required', '工作组有多个机器人且没有组长,请 @ 要处理这条消息的机器人')
   }
 
+  // ---- 后台调用(docs/design/15-background-calls.md)使用 ----
+
+  /** 子任务等待的后台调用被取消(子任务被取消时) */
+  onTaskCancelled?: (taskId: string) => void
+
+  /** 机器人这一轮有调用转到了后台;返回它正在执行的子任务 */
+  noteBackground(sessionId: string): { teamId: string; botId: string; taskId: string | null } | null {
+    const ref = this.lookup(sessionId)
+    if (!ref) return null
+    const rt = this.rt(ref.teamId, ref.botId)
+    rt.backgroundStarted = true
+    return { ...ref, taskId: rt.taskId }
+  }
+
+  /** 后台调用结束:子任务在等待时让它继续,否则以系统消息发给发起调用的机器人 */
+  deliverBackground(teamId: string, botId: string, taskId: string | null, text: string) {
+    if (!this.findRow(teamId)) return
+    const task = taskId ? this.jobs.tasks(teamId, { botId, status: ['background'] }).find(t => t.id === taskId) : undefined
+    if (task) {
+      this.emitTask(this.jobs.updateTask(task.id, { status: 'queued', followUp: text }))
+      this.wake(teamId, botId)
+      return
+    }
+    if (!this.memberRows(teamId).some(m => m.botId === botId)) return
+    this.post(teamId, { sender: { kind: 'system' }, text, mentions: [botId], files: [], targets: [botId] })
+  }
+
   // ---- 定时任务(docs/design/14-schedules.md)使用 ----
 
   /** 机器人会话所属的工作组 */
@@ -786,8 +815,11 @@ export class TeamService {
       const deps = task.dependsOn.flatMap(no => this.jobs.taskByNo(team.id, no) ?? [])
       rt.taskId = task.id
       rt.taskOutcome = null
-      const run = this.deps.runs.start(session, taskInput(task, job, this.botName(this.leadOf(team.id) ?? ''), deps, id => this.botName(id)), [])
-      this.emitTask(this.jobs.updateTask(task.id, { status: 'running', runId: run.id, sessionId: session.id, startedAt: nowIso() }))
+      const input = task.followUp
+        ? `【子任务 #${task.no}「${task.title}」继续】\n${task.followUp}\n\n继续完成这个子任务;完成后调用 task_complete(result, files),无法完成时调用 task_fail(reason)。`
+        : taskInput(task, job, this.botName(this.leadOf(team.id) ?? ''), deps, id => this.botName(id))
+      const run = this.deps.runs.start(session, input, [])
+      this.emitTask(this.jobs.updateTask(task.id, { status: 'running', runId: run.id, sessionId: session.id, followUp: null, ...(task.startedAt ? {} : { startedAt: nowIso() }) }))
       this.jobs.addRun(job.id, run.id, false)
       return
     }
@@ -822,7 +854,7 @@ export class TeamService {
     const rt = this.rt(ref.teamId, ref.botId)
     switch (e.type) {
       case 'run.queued':
-        Object.assign(rt, { lastText: '', changedPaths: new Set<string>(), writtenPaths: new Set<string>(), pendingWrites: new Map(), overlapped: false, posted: false })
+        Object.assign(rt, { lastText: '', changedPaths: new Set<string>(), writtenPaths: new Set<string>(), pendingWrites: new Map(), overlapped: false, backgroundStarted: false, posted: false })
         this.setBotStatus(ref, e.sessionId, 'queued', e.runId)
         break
       case 'run.started':
@@ -926,6 +958,11 @@ export class TeamService {
       return
     }
     const outcome = rt.taskOutcome
+    // 外部工具的调用转到了后台、组员还没有汇报:等后台调用结束后继续这个子任务
+    if (!outcome && rt.backgroundStarted) {
+      this.emitTask(this.jobs.updateTask(task.id, { status: 'background', runId: null }))
+      return
+    }
     const reported = outcome ? await this.resolvePaths(team, outcome.paths) : []
     const files = [...changed, ...reported.filter(f => !changed.some(c => c.path === f.path))]
     this.endTask(task, outcome?.status ?? 'completed', outcome?.result ?? (rt.lastText || '(没有填写结果)'), files, meta)
@@ -950,6 +987,7 @@ export class TeamService {
 
   private cancelTask(task: TaskRow) {
     this.emitTask(this.jobs.updateTask(task.id, { status: 'cancelled', endedAt: nowIso() }))
+    if (task.status === 'background') this.onTaskCancelled?.(task.id)
     if (task.status === 'running') {
       const rt = this.rt(task.teamId, task.botId)
       if (rt.taskId === task.id) this.interruptBot(task.teamId, task.botId)
@@ -1293,7 +1331,7 @@ export class TeamService {
     if (!r) {
       r = {
         status: 'idle', runId: null, pending: false, starting: false, lastText: '', changedPaths: new Set(),
-        writtenPaths: new Set(), pendingWrites: new Map(), overlapped: false, taskId: null, taskOutcome: null, posted: false,
+        writtenPaths: new Set(), pendingWrites: new Map(), overlapped: false, backgroundStarted: false, taskId: null, taskOutcome: null, posted: false,
       }
       this.runtime.set(key(teamId, botId), r)
     }
@@ -1311,4 +1349,4 @@ export class TeamService {
 
 const key = (teamId: string, botId: string) => `${teamId}/${botId}`
 
-const TASK_STATUS = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' } as const
+const TASK_STATUS = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', background: '等待后台调用', completed: '已完成', failed: '失败', cancelled: '已取消' } as const

+ 255 - 0
apps/server/test/background-calls.test.ts

@@ -0,0 +1,255 @@
+// 后台调用(docs/design/15-background-calls.md):外部工具的长调用转到后台,结束后把结果发给发起方
+import http from 'node:http'
+import type { AddressInfo } from 'node:net'
+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 { BackgroundCallDto, ProviderStatusDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared'
+import { createApp, type App } from '../src/app'
+import { buildServer } from '../src/http/server'
+import type { AgentProvider, RunContext } from '../src/providers/types'
+
+type ToolCall = (tool: string, args?: Record<string, unknown>) => Promise<string>
+type Script = (input: string, tool: ToolCall, collab: ToolCall) => Promise<string>
+
+/** 外部服务的慢工具:等测试放行后才返回 */
+const gates = new Map<string, () => void>()
+const waitGate = (key: string) => new Promise<void>(r => gates.set(key, r))
+const release = async (key: string) => {
+  const r = await waitFor(() => gates.get(key))
+  gates.delete(key)
+  r()
+}
+
+class ScriptedProvider implements AgentProvider {
+  readonly id = 'claude' as const
+  scripts: Record<string, Script> = {}
+  inputs: { bot: string; text: string; sessionId: string }[] = []
+  hang = false
+  async run(ctx: RunContext) {
+    ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
+    const title = app.sessions.getRow(ctx.sessionId).title
+    const bot = title.includes(' · ') ? title.split(' · ').at(-1)! : 'session'
+    this.inputs.push({ bot, text: ctx.input.text, sessionId: ctx.sessionId })
+    if (this.hang) await new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(new Error('aborted'))))
+    // 与真实引擎一样经 HTTP 连接网关(测试服务的端口与配置不同,替换主机和端口)
+    const url = new URL(ctx.gateway.url)
+    const client = new Client({ name: 'fake', version: '1' })
+    await client.connect(new StreamableHTTPClientTransport(new URL(`${url.pathname}${url.search}`, base), { requestInit: { headers: { Authorization: `Bearer ${ctx.gateway.token}` } } }))
+    const tool: ToolCall = async (name, args = {}) => {
+      const r = await client.callTool({ name: `slowsvc__${name}`, arguments: args }, undefined, { timeout: 60_000 })
+      return (r.content as { text: string }[])[0]!.text
+    }
+    const gctx = app.gatewayTokens.get(ctx.gateway.token)!
+    const collab: ToolCall = async (name, args = {}) => {
+      const t = gctx.extraTools!.find(x => x.name === name)!
+      const r = await t.handler(t.schema.parse(args) as Record<string, unknown>, gctx)
+      return String(r.content[0]?.text ?? '')
+    }
+    try {
+      const reply = await (this.scripts[bot] ?? (async () => 'ok'))(ctx.input.text, tool, collab)
+      ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: reply })
+    } finally {
+      await client.close()
+    }
+    return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
+  }
+  async status(): Promise<ProviderStatusDto> {
+    return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
+  }
+}
+
+let app: App
+let server: Awaited<ReturnType<typeof buildServer>>
+let base: string
+let upstream: http.Server
+let apiKey: string
+let botKey: string
+let serverId: string
+let adminCookie: Record<string, string>
+const fake = new ScriptedProvider()
+const auth = (key = apiKey) => ({ authorization: `Bearer ${key}` })
+
+async function waitFor<T>(fn: () => T | undefined | false | null, ms = 6000): Promise<T> {
+  const end = Date.now() + ms
+  while (Date.now() < end) {
+    const v = fn()
+    if (v) return v
+    await new Promise(r => setTimeout(r, 20))
+  }
+  throw new Error('timeout')
+}
+
+function startUpstream(): Promise<string> {
+  upstream = http.createServer(async (req, res) => {
+    const mcp = new McpServer({ name: 'slow', version: '1.0.0' })
+    mcp.registerTool('export', { description: '导出(很慢)', inputSchema: { key: z.string() }, annotations: { readOnlyHint: true } }, async ({ key }) => {
+      await waitGate(key)
+      return { content: [{ type: 'text', text: key === 'big' ? `结果:${'数'.repeat(9000)}` : `导出完成:${key}` }] }
+    })
+    mcp.registerTool('quick', { description: '很快', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: '马上好了' }] }))
+    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`)))
+}
+
+beforeAll(async () => {
+  const log = pino({ level: 'silent' })
+  app = createApp(log, { providers: { claude: fake } })
+  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: 'slowsvc', title: '慢服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [],
+    allowNetwork: false, readPaths: [], writePaths: [],
+  }).id
+  await app.mcpServers.refresh(serverId)
+  const builtin = app.profiles.getBuiltin()
+  const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...input } = builtin
+  const profile = app.profiles.create({ ...input, name: '带慢服务', isDefault: false, mcpServerIds: [serverId] })
+  apiKey = app.apiKeys.create('平台A', { allowedProfileIds: [profile.id], defaultProfileId: profile.id }).key
+  for (const [name, kind] of [['组长', 'lead'], ['导出员', 'worker']] as const) app.bots.create({ name, avatar: 'kl:bot:blue', kind, description: name, profileId: profile.id, enabled: true })
+  botKey = app.botKeys.create('平台A', app.bots.list().map(b => b.id)).key
+  app.settings.update({ backgroundCalls: { autoAfterSec: 1 } })
+  const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
+  adminCookie = { [login.cookies[0]!.name]: login.cookies[0]!.value }
+})
+afterAll(async () => {
+  app.backgroundCalls.stop()
+  await app.mcpUpstream.closeAll()
+  await server.close()
+  upstream.close()
+})
+
+const newSession = async () => (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string
+const calls = async (sid: string) => (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/background-calls`, headers: auth() })).json() as BackgroundCallDto[]
+const send = (sid: string, text: string) => server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text } })
+const idle = (sid: string) => !app.runs.activeForSession(sid)
+
+describe('会话中的后台调用', () => {
+  it('很快返回的调用照常返回;超过时限的转为后台,完成后把结果作为新消息发到会话', async () => {
+    const sid = await newSession()
+    let reply = ''
+    fake.scripts.session = async (input, tool) => {
+      if (input.startsWith('【后台调用')) return '收到结果'
+      reply = await tool(input.includes('快') ? 'quick' : 'export', input.includes('快') ? {} : { key: 'q3' })
+      return reply
+    }
+    await send(sid, '快一点')
+    await waitFor(() => idle(sid) && reply)
+    expect(reply).toBe('马上好了')
+    expect(await calls(sid)).toHaveLength(0)
+
+    reply = ''
+    await send(sid, '导出 Q3')
+    await waitFor(() => idle(sid) && reply)
+    expect(reply).toMatch(/已转为后台执行(后台调用 bg_/)
+    const [running] = await calls(sid)
+    expect(running).toMatchObject({ status: 'running', serverTitle: '慢服务', tool: 'export', args: { key: 'q3' } })
+
+    const before = fake.inputs.length
+    await release('q3')
+    const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid))
+    expect(delivered.text).toMatch(/^【后台调用完成】慢服务 · export(bg_.+,耗时 \d+ 分钟)/)
+    expect(delivered.text).toContain('结果:\n导出完成:q3')
+    await waitFor(() => idle(sid))
+    expect((await calls(sid))[0]).toMatchObject({ status: 'completed', result: '导出完成:q3', delivered: true })
+  })
+
+  it('会话正在执行时,等这一轮结束再通知;较长的结果保存为文件', async () => {
+    const sid = await newSession()
+    fake.scripts.session = async (input, tool) => (input.startsWith('【后台调用') || input === '别的事' ? 'ok' : tool('export', { key: 'big' }))
+    await send(sid, '导出大文件')
+    await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid })[0])
+    fake.hang = true
+    await send(sid, '别的事')
+    await waitFor(() => !idle(sid))
+    await release('big')
+    await waitFor(() => app.backgroundCalls.list({ sessionId: sid })[0]?.status === 'completed')
+    const before = fake.inputs.length
+    await new Promise(r => setTimeout(r, 100))
+    expect(fake.inputs.length).toBe(before)
+    expect(app.backgroundCalls.list({ sessionId: sid })[0]!.delivered).toBe(false)
+    fake.hang = false
+    app.runs.interrupt(app.runs.activeForSession(sid)!.runId, sid)
+    const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.text.startsWith('【后台调用完成】')))
+    expect(delivered.text).toMatch(/完整结果已保存到 \.agent\/mcp\/background-export-bg_.+\.txt/)
+    await waitFor(() => idle(sid))
+  })
+
+  it('标记为总是后台执行的工具立即转到后台;平台可以取消,AI 收到取消的消息', async () => {
+    const r = await server.inject({ method: 'PUT', url: `/admin/v1/mcp/servers/${serverId}/tools/export/background`, cookies: adminCookie, payload: { background: true } })
+    expect(r.json().tools.find((t: { name: string }) => t.name === 'export').background).toBe(true)
+    const sid = await newSession()
+    let reply = ''
+    const t0 = Date.now()
+    fake.scripts.session = async (input, tool) => (input.startsWith('【后台调用') ? 'ok' : (reply = await tool('export', { key: 'never' })))
+    await send(sid, '导出')
+    await waitFor(() => idle(sid) && reply)
+    expect(reply).toContain('已转为后台执行')
+    expect(Date.now() - t0).toBeLessThan(900)
+    const [call] = await calls(sid)
+    // 其他平台不能取消
+    const other = app.apiKeys.create('平台B', { allowedProfileIds: [app.profiles.getBuiltin().id], defaultProfileId: app.profiles.getBuiltin().id }).key
+    expect((await server.inject({ method: 'POST', url: `/v1/background-calls/${call!.id}/cancel`, headers: auth(other) })).statusCode).toBe(404)
+    const before = fake.inputs.length
+    const cancelled = await server.inject({ method: 'POST', url: `/v1/background-calls/${call!.id}/cancel`, headers: auth() })
+    expect(cancelled.json()).toMatchObject({ status: 'cancelled', error: '成员取消了这次调用' })
+    const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid))
+    expect(delivered.text).toMatch(/^【后台调用已取消】/)
+    expect((await server.inject({ method: 'POST', url: `/v1/background-calls/${call!.id}/cancel`, headers: auth() })).statusCode).toBe(409)
+    await waitFor(() => idle(sid))
+    gates.delete('never')
+    await server.inject({ method: 'PUT', url: `/admin/v1/mcp/servers/${serverId}/tools/export/background`, cookies: adminCookie, payload: { background: false } })
+  })
+})
+
+describe('工作组中的后台调用', () => {
+  it('组员在子任务中发起的调用转到后台时,子任务等待;结果到达后组员继续并汇报给组长', async () => {
+    const [lead, worker] = [app.bots.list().find(b => b.kind === 'lead')!, app.bots.list().find(b => b.kind === 'worker')!]
+    const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: auth(botKey), payload: { human: { id: 'u', name: '张三' }, leadBotId: lead.id, workerBotIds: [worker.id] } })).json() as TeamDetailDto
+    fake.scripts['组长'] = async (input, _tool, collab) => {
+      if (input.includes('子任务 #') && input.includes('已完成')) {
+        await collab('job_complete', { summary: '导出好了' })
+        return '完成'
+      }
+      if (input.includes('导出')) {
+        await collab('job_start', { title: '导出', plan: '' })
+        await collab('task_create', { bot_id: worker.id, title: '导出数据', instructions: '调用 export' })
+      }
+      return '安排了'
+    }
+    fake.scripts['导出员'] = async (input, tool, collab) => {
+      if (input.includes('继续完成这个子任务')) {
+        expect(input).toContain('【后台调用完成】慢服务 · export')
+        await collab('task_complete', { result: input.includes('导出完成:team') ? '已导出' : '?' })
+        return '汇报了'
+      }
+      return tool('export', { key: 'team' })
+    }
+    await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: auth(botKey), payload: { text: '帮我导出' } })
+    const task = await waitFor(() => app.teams.jobs.tasks(team.id, { status: ['background'] })[0])
+    expect(task.title).toBe('导出数据')
+    const list = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/background-calls`, headers: auth(botKey) })).json() as BackgroundCallDto[]
+    expect(list[0]).toMatchObject({ status: 'running', teamId: team.id, botId: worker.id, taskId: task.id })
+    // 组长没有被唤醒去处理"已转为后台"的回复
+    const messages = () => app.teams.events.list(team.id, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
+    expect(messages().some(m => m.card?.kind === 'task' && m.card.event === 'completed')).toBe(false)
+
+    await release('team')
+    const job = await waitFor(() => app.teams.jobs.jobs(team.id)[0]?.status === 'completed' && app.teams.jobs.jobs(team.id)[0])
+    expect(job.summary).toBe('导出好了')
+    expect(app.teams.jobs.tasks(team.id)[0]).toMatchObject({ status: 'completed', result: '已导出', followUp: null })
+    const overview = (await server.inject({ method: 'GET', url: '/admin/v1/background-calls', cookies: adminCookie })).json()
+    expect(overview.find((x: { id: string }) => x.id === list[0]!.id)).toMatchObject({ status: 'completed', targetTitle: team.title })
+  })
+})

+ 2 - 1
docs/README.md

@@ -24,7 +24,8 @@ docs/
 │  ├─ 11-roadmap.md           里程碑与待决事项
 │  ├─ 12-custom-skills.md     自定义技能(导入或在后台编写,两个引擎共用)
 │  ├─ 13-bot-teams.md         Bot 工作组:多岗位机器人、组长分派任务、Bot API(一期至三期已实施)
-│  └─ 14-schedules.md         定时任务:在对话中创建,到点自动在会话或工作组中执行
+│  ├─ 14-schedules.md         定时任务:在对话中创建,到点自动在会话或工作组中执行
+│  └─ 15-background-calls.md  后台调用:外部工具的长调用由服务端等待,完成后通知 AI 继续
 ├─ adr/                       架构决策记录(一事一档,只追加不改写;推翻时新增一条并标注 Superseded)
 └─ research/                  调研材料(选型依据、参考实现分析)
 ```

+ 4 - 0
docs/design/11-roadmap.md

@@ -39,6 +39,10 @@
 - 在对话中由 AI 创建(确认卡片),会话或工作组到点自动执行;Agent API、Bot API、SDK 组件与后台总览
 - 以后:执行结果的 Webhook 通知
 
+### M6:后台调用(✅ 2026-10-27,见 15)
+- 外部工具的长调用超过时限(或工具标记为后台执行)时转到后台,完成后把结果发给发起方;工作组子任务等待后台调用后继续
+- 以后:对接 MCP 规范的异步任务机制,重启后可以接续
+
 ### 以后(按需)
 - 内部平台连接器(设计见 08)
 - 专用系统用户(迁移步骤见 06 §5.3)

+ 74 - 0
docs/design/15-background-calls.md

@@ -0,0 +1,74 @@
+# 15 后台调用
+
+> 状态:**已实施(2026-10-27)**。
+
+## 1. 问题
+
+接入的外部工具(MCP 服务)中,有些调用要执行很久(导出、跑批、训练,几十分钟到几个小时)。在此之前:
+
+- 网关对外部工具的单次调用最多等 10 分钟(有进度通知时最多 20 分钟),超时即失败。
+- 让模型一直等:引擎进程挂着、占用并发;服务重启或断线时前功尽弃;用户在此期间无法继续。
+- 让模型轮询:每次询问都是一轮模型调用,花 token、上下文越来越长,模型只起到昂贵的定时器作用。
+
+## 2. 方案:等待交给服务端
+
+外部工具的调用超过一定时间,或者工具被标记为"总是后台执行"时,网关把它转到后台:
+
+1. 网关立即返回给模型:"已转为后台执行(后台调用 id),完成后系统会把结果发给你"。模型结束本轮,告诉用户已提交。
+2. 服务端继续等待这次调用(不涉及模型,不花 token)。
+3. 完成、失败或被取消时,服务端往发起调用的会话(或工作组中发起调用的机器人)发一条消息:`【后台调用完成】服务 · 工具(id,耗时)` + 结果。这条消息唤醒 AI 继续处理。
+   - 会话正在执行时,等这一轮结束后再发。
+   - 结果较长时保存为文件(工作区 `.agent/mcp/` 下),消息中给出路径。
+
+## 3. 何时转为后台
+
+| 条件 | 说明 |
+|---|---|
+| 自动 | 调用超过 `backgroundCalls.autoAfterSec`(默认 180 秒)仍未返回 |
+| 总是 | 管理员在"MCP 服务"页面把工具标记为"后台执行":调用开始时就转到后台 |
+| 不转 | 发起方(会话 / 工作组)正在进行的后台调用已达 `backgroundCalls.maxPerTarget`(默认 3):按原来的方式等待(10 分钟超时) |
+
+- 后台调用的最长时间 `backgroundCalls.maxHours`(默认 24 小时),超时按失败处理。
+- 转为后台之前用户中断了这一轮,调用随之取消;转为后台之后与这一轮无关,只能单独取消。
+- 只针对外部 MCP 服务的工具;内置工具(文档转换等)不转后台。
+
+## 4. 工作组中的子任务
+
+组员在执行子任务时发起的调用被转到后台:
+
+- 这一轮结束时如果组员没有调用 `task_complete` / `task_fail`,子任务进入"等待后台调用"(`background`)状态,不汇报给组长。
+- 后台调用结束后,子任务重新排队;组员下一轮收到的输入是"后台调用结果 + 继续完成子任务",完成后照常 `task_complete`。
+- 子任务被取消(组长取消、任务结束)时,它等待的后台调用一并取消。
+- 不在子任务中的调用(成员直接 @ 组员,或组长自己调用),结果以系统消息发给发起调用的机器人。
+- 任务的时长限额照常生效:后台调用很长时,组长下次被唤醒时任务可能因超时暂停,由成员选择继续。
+
+## 5. 服务重启
+
+- 后台调用依赖与外部服务的连接,服务重启后无法接续:进行中的调用标记为失败,并通知发起方("服务重启,后台调用中断"),由 AI 决定是否重试。
+- 外部服务本身是异步作业(提交后返回作业 id、可以查询状态)的,建议提供"提交"和"查询"两个工具;MCP 规范中的异步任务机制成熟后再直接对接。
+- 连接管理:有调用在执行的上游连接不会因空闲被关闭(之前空闲 10 分钟会关闭,长调用会被中断)。
+
+## 6. 数据模型
+
+`background_calls`:id、会话 id、工作组 id 与机器人 id(工作组中发起时)、子任务 id、MCP 服务 id 与名称、工具名、参数、状态(running / completed / failed / cancelled)、开始与结束时间、结果文本、错误、是否已通知。
+
+## 7. API 与界面
+
+| API | 接口 |
+|---|---|
+| Agent API | `GET /v1/sessions/:id/background-calls`;`POST /v1/background-calls/:id/cancel` |
+| Bot API | `GET /bot/v1/teams/:id/background-calls`;`POST /bot/v1/teams/:id/background-calls/:callId/cancel` |
+| 后台 | `GET /admin/v1/background-calls`;`POST /admin/v1/background-calls/:id/cancel`;MCP 服务的工具可以标记"后台执行" |
+
+- `<AgentChat>`、`<TeamChat>`:有进行中的后台调用时,顶部显示后台调用栏(工具、已执行时长,可以取消)。
+- 后台"后台调用"页面:全部后台调用,查看参数和结果,取消进行中的调用。
+- 系统设置增加"后台调用"一节(§3 的三个值)。
+
+## 8. 实施记录(2026-10-27)
+
+- 修复了上游连接的空闲回收:之前按最后使用时间回收,执行超过 10 分钟的调用会在中途被关闭;现在有调用在执行的连接不回收。
+- 系统提示词的"运行环境"中说明:工具返回"已转为后台执行"时结束本轮并告诉用户,不要等待或轮询。
+- 已用真实 Claude(haiku)验收:
+  - 模拟的报表系统 `export_report` 需要 90 秒,转后台时限设为 20 秒。
+  - Claude 调用后第 20 秒转为后台,Claude 告诉用户"已提交、完成后再总结"并结束本轮;Playground 顶部显示"后台执行中"。
+  - 90 秒后结果作为新消息发到会话,Claude 收到后给出总结。

+ 11 - 3
docs/integration.md

@@ -47,9 +47,9 @@ app.post('/api/agent-token', requireLogin, async (req, res) => {
 SDK 由 AgentServer 自己提供,三个包需要一起安装:
 
 ```bash
-pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.4.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-client-0.4.0.tgz \
-         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.4.0.tgz
+pnpm add https://agent.zenpower.cc/sdk/agent-server-shared-0.5.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-client-0.5.0.tgz \
+         https://agent.zenpower.cc/sdk/agent-server-chat-ui-0.5.0.tgz
 ```
 
 | 包 | 内容 |
@@ -155,6 +155,14 @@ await bots.teams.schedules(teamId)                         // 工作组的定时
 - 定时执行时没有人在场:需要审批的操作会在等待超时后被拒绝。希望定时任务稳定执行的场景,使用"自动执行"或"只读"的 Profile。
 - 每次执行有时长和成本上限(后台"设置 › 定时任务"),超出成本上限时定时任务自动暂停。
 
+## 5.2 后台调用(外部工具的长调用)
+
+接入的外部工具(MCP 服务)中执行时间很长的调用(导出、跑批等)会转到后台:AI 先告诉用户"已提交",结束这一轮;服务端继续等待,完成后把结果作为一条新消息发到会话(或工作组中发起调用的机器人),AI 收到后继续处理。设计见 [design/15-background-calls.md](design/15-background-calls.md)。
+
+- `<AgentChat>`、`<TeamChat>` 顶部会显示"后台执行中",可以取消;结果到达后自动出现在时间线中。
+- 自己渲染界面时:`client.sessions.backgroundCalls(id)`、`client.backgroundCalls.cancel(callId)`;工作组用 `bots.teams.backgroundCalls(teamId)`、`bots.teams.cancelBackgroundCall(teamId, callId)`。
+- 超过多少秒转后台、最长多久,在后台"设置 › 后台调用"中调整;执行时间一定很长的工具,可以在"MCP 服务"中标记为"后台执行"。
+
 ## 6. 外观
 
 - 亮色/暗色默认跟随系统;在 `<html>` 上设置 `data-theme="light"` 或 `"dark"` 可以强制指定。

+ 3 - 3
examples/platform-demo/package.json

@@ -9,9 +9,9 @@
     "typecheck": "vue-tsc --noEmit"
   },
   "dependencies": {
-    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.4.0.tgz",
-    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.4.0.tgz",
-    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.4.0.tgz",
+    "@agent-server/chat-ui": "AGENT_SERVER_URL/sdk/agent-server-chat-ui-0.5.0.tgz",
+    "@agent-server/client": "AGENT_SERVER_URL/sdk/agent-server-client-0.5.0.tgz",
+    "@agent-server/shared": "AGENT_SERVER_URL/sdk/agent-server-shared-0.5.0.tgz",
     "vue": "^3.5.44"
   },
   "devDependencies": {

+ 1 - 1
packages/chat-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/chat-ui",
-  "version": "0.4.0",
+  "version": "0.5.0",
   "private": true,
   "type": "module",
   "exports": {

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

@@ -4,6 +4,7 @@ import type { AgentClient } from '@agent-server/client'
 import type { FileDto } from '@agent-server/shared'
 import { downloadFile, provideClient } from '../context'
 import type { ChatSession } from '../store'
+import BackgroundBar from './BackgroundBar.vue'
 import Composer from './Composer.vue'
 import ScheduleBar from './ScheduleBar.vue'
 import Timeline from './Timeline.vue'
@@ -65,6 +66,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 <template>
   <div class="agent-chat">
     <ScheduleBar :source="session" :refresh-key="state.runState" :readonly="readonly" />
+    <BackgroundBar :source="session" :refresh-key="state.runState" :readonly="readonly" />
     <div ref="scroller" class="scroller" @scroll="onScroll">
       <div class="column">
         <div v-if="state.loading" class="empty">加载中…</div>

+ 88 - 0
packages/chat-ui/src/components/BackgroundBar.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+// 后台调用栏(docs/design/15-background-calls.md §7):有进行中的后台调用时显示在顶部(工具、已执行时长,可以取消)。
+// 调用结束后结果会作为一条新消息出现在时间线中;refreshKey 变化时(例如一轮执行结束)重新加载,进行中时每 30 秒刷新一次
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+import type { BackgroundCallDto } from '@agent-server/shared'
+
+const props = defineProps<{
+  source: {
+    backgroundCalls: () => Promise<BackgroundCallDto[]>
+    cancelBackgroundCall: (id: string) => Promise<unknown>
+  }
+  refreshKey: string
+  readonly?: boolean
+}>()
+
+const list = ref<BackgroundCallDto[]>([])
+const now = ref(Date.now())
+const error = ref('')
+
+async function load() {
+  try {
+    list.value = await props.source.backgroundCalls()
+    now.value = Date.now()
+  } catch {
+    // 没有权限或网络错误时不显示
+  }
+}
+watch(() => props.refreshKey, () => void load(), { immediate: true })
+
+const running = computed(() => list.value.filter(c => c.status === 'running'))
+const timer = setInterval(() => {
+  if (running.value.length) void load()
+}, 30_000)
+onBeforeUnmount(() => clearInterval(timer))
+
+function elapsed(from: string) {
+  const min = Math.max(0, Math.floor((now.value - Date.parse(from)) / 60_000))
+  if (min < 1) return '不到 1 分钟'
+  return min < 60 ? `${min} 分钟` : `${Math.floor(min / 60)} 小时 ${min % 60} 分钟`
+}
+
+async function cancel(c: BackgroundCallDto) {
+  if (!confirm(`取消后台调用「${c.serverTitle} · ${c.tool}」?`)) return
+  error.value = ''
+  try {
+    await props.source.cancelBackgroundCall(c.id)
+    await load()
+  } catch (err) {
+    error.value = (err as Error).message
+  }
+}
+</script>
+
+<template>
+  <div v-if="running.length" class="background-bar">
+    <div v-for="c in running" :key="c.id" class="row">
+      <span class="codicon codicon-loading codicon-modifier-spin" />
+      <span class="label">后台执行中</span>
+      <span class="text" :title="JSON.stringify(c.args)">{{ c.serverTitle }} · {{ c.tool }} · 已执行 {{ elapsed(c.startedAt) }},完成后会继续处理</span>
+      <button v-if="!readonly" type="button" class="btn" @click="cancel(c)">取消</button>
+    </div>
+    <div v-if="error" class="err">{{ error }}</div>
+  </div>
+</template>
+
+<style scoped>
+.background-bar {
+  flex: none;
+  padding: 2px 12px;
+  border-bottom: 1px solid var(--app-input-border);
+  background: var(--app-input-secondary-background);
+  font-size: 12.5px;
+  color: var(--app-primary-foreground);
+}
+.row { display: flex; align-items: center; gap: 6px; height: 28px; }
+.label { font-weight: 600; white-space: nowrap; }
+.text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--app-secondary-foreground); }
+.btn {
+  padding: 1px 8px;
+  border: 1px solid var(--app-input-border);
+  border-radius: 4px;
+  background: transparent;
+  color: inherit;
+  font-size: 12px;
+  cursor: pointer;
+}
+.err { color: var(--app-error-foreground); }
+</style>

+ 1 - 1
packages/chat-ui/src/components/TeamCard.vue

@@ -13,7 +13,7 @@ const task = computed(() => ('taskId' in props.card ? state.value.tasks[props.ca
 const botName = (id: string) => state.value.team?.bots.find(b => b.id === id)?.name ?? '组员'
 
 const JOB_STATUS: Record<JobStatus, string> = { running: '进行中', blocked: '已暂停(超出限额)', completed: '已完成', failed: '失败', cancelled: '已取消' }
-const TASK_STATUS: Record<TaskStatus, string> = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' }
+const TASK_STATUS: Record<TaskStatus, string> = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', background: '等待后台调用', completed: '已完成', failed: '失败', cancelled: '已取消' }
 const tone = (s: string) => (s === 'completed' ? 'ok' : s === 'failed' ? 'bad' : s === 'cancelled' ? 'muted' : s === 'blocked' || s === 'stub' ? 'warn' : 'busy')
 
 const busy = ref(false)

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

@@ -4,6 +4,7 @@ import type { BotClient } from '@agent-server/client'
 import type { FileDto, TeamBotDto, TeamMessage } from '@agent-server/shared'
 import { downloadFile, provideClient } from '../context'
 import type { TeamItem, TeamSession } from '../team-store'
+import BackgroundBar from './BackgroundBar.vue'
 import BotAvatar from './BotAvatar.vue'
 import Composer from './Composer.vue'
 import FileCard from './FileCard.vue'
@@ -122,6 +123,7 @@ defineExpose({ focus: () => composer.value?.focus(), setText: (t: string) => com
 <template>
   <div class="agent-chat team-chat">
     <ScheduleBar :source="session" :refresh-key="scheduleKey" :readonly="readonly" />
+    <BackgroundBar :source="session" :refresh-key="scheduleKey" :readonly="readonly" />
     <div ref="scroller" class="scroller" @scroll="onScroll">
       <div class="column">
         <div v-if="state.loading" class="empty">加载中…</div>

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

@@ -2,6 +2,7 @@ import '@vscode/codicons/dist/codicon.css'
 import './style.css'
 
 export { default as AgentChat } from './components/AgentChat.vue'
+export { default as BackgroundBar } from './components/BackgroundBar.vue'
 export { default as BotAvatar } from './components/BotAvatar.vue'
 export { default as Composer } from './components/Composer.vue'
 export { default as FileCard } from './components/FileCard.vue'

+ 3 - 0
packages/chat-ui/src/store.ts

@@ -208,6 +208,9 @@ export function useChatSession(client: AgentClient, sessionId: string, opts: { l
       if (state.activeRunId) await client.runs.interrupt(state.activeRunId)
     },
     reply: (interactionId: string, body: InteractionResponse) => client.interactions.reply(interactionId, body),
+    /** 后台调用(docs/design/15-background-calls.md) */
+    backgroundCalls: () => client.sessions.backgroundCalls(sessionId),
+    cancelBackgroundCall: (id: string) => client.backgroundCalls.cancel(id),
     /** 定时任务(docs/design/14-schedules.md) */
     schedules: () => client.sessions.schedules(sessionId),
     setScheduleStatus: (id: string, status: 'active' | 'paused') => client.schedules.setStatus(id, status),

+ 3 - 0
packages/chat-ui/src/team-store.ts

@@ -124,6 +124,9 @@ export function useTeamSession(client: BotClient, teamId: string) {
     continueJob: (jobId: string) => client.teams.continueJob(teamId, jobId),
     stubResult: (taskId: string, body: StubTaskBody) => client.teams.stubResult(teamId, taskId, body),
     continueStep: (stepId: string) => client.teams.continueStep(teamId, stepId),
+    /** 后台调用(docs/design/15-background-calls.md) */
+    backgroundCalls: () => client.teams.backgroundCalls(teamId),
+    cancelBackgroundCall: (id: string) => client.teams.cancelBackgroundCall(teamId, id),
     /** 定时任务(docs/design/14-schedules.md) */
     schedules: () => client.teams.schedules(teamId),
     setScheduleStatus: (id: string, status: 'active' | 'paused') => client.teams.setScheduleStatus(teamId, id, status),

+ 1 - 1
packages/client/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/client",
-  "version": "0.4.0",
+  "version": "0.5.0",
   "private": true,
   "type": "module",
   "exports": {

+ 4 - 0
packages/client/src/bot.ts

@@ -1,5 +1,6 @@
 // Bot API 客户端(/bot/v1,docs/design/13-bot-teams.md §9)。使用 Bot Key 或 Bot 浏览器令牌,与 AgentClient 的 Key 互不通用
 import type {
+  BackgroundCallDto,
   BotCard,
   BrowserTokenDto,
   CreateTeamBody,
@@ -57,6 +58,9 @@ export class BotClient extends HttpClient {
     cancelJob: (id: string, jobId: string) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/jobs/${jobId}/cancel`),
     continueJob: (id: string, jobId: string) => this.request<{ ok: true }>('POST', `/bot/v1/teams/${id}/jobs/${jobId}/continue`),
     usage: (id: string) => this.request<TeamUsageDto>('GET', `/bot/v1/teams/${id}/usage`),
+    /** 工作组的后台调用(机器人调用外部工具的长调用) */
+    backgroundCalls: (id: string) => this.request<BackgroundCallDto[]>('GET', `/bot/v1/teams/${id}/background-calls`),
+    cancelBackgroundCall: (id: string, callId: string) => this.request<BackgroundCallDto>('POST', `/bot/v1/teams/${id}/background-calls/${callId}/cancel`, {}),
     /** 工作组的定时任务(由机器人在对话中创建) */
     schedules: (id: string) => this.request<ScheduleDto[]>('GET', `/bot/v1/teams/${id}/schedules`),
     setScheduleStatus: (id: string, scheduleId: string, status: 'active' | 'paused') =>

+ 8 - 0
packages/client/src/index.ts

@@ -1,6 +1,7 @@
 // AgentServer TS 客户端(docs/design/04-api.md §6)
 import type {
   AgentEvent,
+  BackgroundCallDto,
   BrowserTokenDto,
   CreateSessionBody,
   FileDto,
@@ -46,6 +47,8 @@ export class AgentClient extends HttpClient {
     files: (id: string) => this.request<FileDto[]>('GET', `/v1/sessions/${id}/files`),
     /** 会话的定时任务(由 AI 在对话中创建) */
     schedules: (id: string) => this.request<ScheduleDto[]>('GET', `/v1/sessions/${id}/schedules`),
+    /** 会话的后台调用(外部工具的长调用) */
+    backgroundCalls: (id: string) => this.request<BackgroundCallDto[]>('GET', `/v1/sessions/${id}/background-calls`),
     upload: (id: string, files: Blob[] | File[]) => {
       const form = new FormData()
       for (const f of files) form.append('file', f, (f as File).name ?? 'upload')
@@ -53,6 +56,11 @@ export class AgentClient extends HttpClient {
     },
   }
 
+  /** 后台调用:取消(AI 会收到取消的消息) */
+  backgroundCalls = {
+    cancel: (id: string) => this.request<BackgroundCallDto>('POST', `/v1/background-calls/${id}/cancel`, {}),
+  }
+
   /** 定时任务:暂停、恢复、删除、执行记录 */
   schedules = {
     setStatus: (id: string, status: 'active' | 'paused') => this.request<ScheduleDto>('PATCH', `/v1/schedules/${id}`, { status }),

+ 1 - 1
packages/shared/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/shared",
-  "version": "0.4.0",
+  "version": "0.5.0",
   "private": true,
   "type": "module",
   "exports": {

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

@@ -509,6 +509,8 @@ export interface McpToolDto {
   /** 服务声明的只读工具(annotations.readOnlyHint);只读模式下只能调用这类工具 */
   readOnly: boolean
   policy: McpToolPolicy
+  /** 总是在后台执行(长时间运行的工具,docs/design/15-background-calls.md) */
+  background: boolean
 }
 
 /** 密钥类的值(请求头、环境变量):读取时只返回是否已设置 */

+ 30 - 0
packages/shared/src/background.ts

@@ -0,0 +1,30 @@
+// 后台调用(docs/design/15-background-calls.md):外部工具的长调用由服务端在后台等待,完成后把结果发给发起方
+
+export type BackgroundCallStatus = 'running' | 'completed' | 'failed' | 'cancelled'
+
+export interface BackgroundCallDto {
+  id: string
+  /** 发起调用的会话;工作组中发起时另有 teamId、botId(以及所在的子任务) */
+  sessionId: string
+  teamId: string | null
+  botId: string | null
+  taskId: string | null
+  serverName: string
+  serverTitle: string
+  tool: string
+  args: unknown
+  status: BackgroundCallStatus
+  startedAt: string
+  endedAt: string | null
+  /** 结果文本(较长时为摘要和文件路径) */
+  result: string | null
+  error: string | null
+  /** 结果是否已发给发起方 */
+  delivered: boolean
+}
+
+/** 后台总览:带发起方的名称与来源 */
+export interface BackgroundCallOverviewDto extends BackgroundCallDto {
+  targetTitle: string
+  source: string
+}

+ 1 - 0
packages/shared/src/index.ts

@@ -3,3 +3,4 @@ export * from './api'
 export * from './settings'
 export * from './teams'
 export * from './schedules'
+export * from './background'

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

@@ -29,6 +29,8 @@ export interface AppSettings {
   }
   retention: { workspaceDays: number }
   /** 定时任务(docs/design/14-schedules.md §6) */
+  /** 后台调用(docs/design/15-background-calls.md §3) */
+  backgroundCalls: { autoAfterSec: number; maxHours: number; maxPerTarget: number }
   schedules: { timezone: string; minIntervalHours: number; maxPerTarget: number; maxMinutesPerRun: number; maxCostUsdPerRun: number | null }
   /** 工作组的默认限额(创建工作组时可以调低,docs/design/13-bot-teams.md §7) */
   teamLimits: TeamLimits

+ 1 - 1
packages/shared/src/teams.ts

@@ -182,7 +182,7 @@ export interface JobDto {
 }
 
 /** waiting:等待依赖的子任务完成;stub:分派给替身,等待管理员填写结果 */
-export type TaskStatus = 'waiting' | 'queued' | 'running' | 'stub' | 'completed' | 'failed' | 'cancelled'
+export type TaskStatus = 'waiting' | 'queued' | 'running' | 'stub' | 'background' | 'completed' | 'failed' | 'cancelled'
 
 export interface TaskDto {
   id: string