workspaceRuntime.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. import { randomUUID } from 'node:crypto'
  2. import { readFileSync } from 'node:fs'
  3. import { z } from 'zod'
  4. import { WorkspaceStore, parse } from './workspaceStore.js'
  5. import { WorkspaceNodes, WorkspaceGateway, type GatewayFrame } from './workspaceGateway.js'
  6. import { HttpError } from './errors.js'
  7. import { applyWorkingDirectory, configuredWorkingDirectory } from './sessionWorkingDirectory.js'
  8. import {type StoredWorkspaceFile} from './workspaceAssets.js'
  9. import { UploadStore } from './uploads.js'
  10. import type {
  11. WorkspaceAgent as Agent,
  12. WorkspaceConversation as Conversation,
  13. WorkspaceMessage as Message,
  14. WorkspaceRun,
  15. WorkspaceInteraction,
  16. } from '../shared/workspace.js'
  17. import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK } from './workspaceScheduler.js'
  18. import { mentionedAgents } from './workspaceMentions.js'
  19. import { WorkspaceTeamTools, TEAM_TOOL_RULES } from './workspaceTeamTools.js'
  20. import { createWorkspaceToolLease, type WorkspaceToolLease } from './workspaceToolLease.js'
  21. import { WorkspaceTaskCoordinator } from './taskCoordinator.js'
  22. export { mentionedAgents } from './workspaceMentions.js'
  23. type Run = WorkspaceRun
  24. export interface WorkspaceBinding {
  25. runnerId?: string
  26. execution?:string
  27. remoteAgentId?: string
  28. id: string
  29. nodeId: string
  30. profile: string
  31. storedId: string
  32. runtimeId: string
  33. aliases: string[]
  34. runId: string
  35. messageId: string
  36. conversationTaskId?: string
  37. taskId?: string
  38. contextSeq?: number
  39. }
  40. interface LiveTurn {
  41. gateway: WorkspaceGateway
  42. runtimeId: string
  43. runId: string
  44. agentId: string
  45. conversationTaskId?: string
  46. taskId: string
  47. done(error?: Error): void
  48. }
  49. export const sendInput = z
  50. .object({
  51. requestId: z.string().uuid(),
  52. content: z.string().max(65_536).default(''),
  53. taskId: z.string().uuid().optional(),
  54. mentionIds: z.array(z.string().uuid()).max(8).default([]),
  55. fileIds: z.array(z.string().uuid()).max(8).default([]),
  56. })
  57. .strict()
  58. .refine((b) => b.content.trim() || b.fileIds.length, '请输入消息或添加附件')
  59. export class WorkspaceRuntime extends WorkspaceScheduler {
  60. private live = new Map<string, LiveTurn>()
  61. readonly teamTools: WorkspaceTeamTools
  62. readonly tasks: WorkspaceTaskCoordinator
  63. retireHelper:(owner:string,helper:Agent)=>Promise<void>=async()=>{throw new Error('电脑清理服务未连接')}
  64. publishArtifact:(owner:string,message:Message,agent:Agent,name:string,bytes:Buffer)=>import('../shared/workspace.js').WorkspaceFile=()=>{throw new Error('产物服务未初始化')}
  65. onTeamCreated: (owner: string, team: Conversation) => void = () => {}
  66. constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true,
  67. readonly authorizationVersion: (owner: string) => number = () => 0) {
  68. super(store, userActive)
  69. this.teamTools = new WorkspaceTeamTools(store, nodes, this)
  70. this.tasks = new WorkspaceTaskCoordinator(store, this)
  71. }
  72. send(owner: string, conversationId: string, input: unknown): Run {
  73. return this.submit(owner, conversationId, input)
  74. }
  75. /** Server-owned provenance. This is never exposed as a model-supplied identity. */
  76. dispatch(owner: string, conversationId: string, input: unknown, source: {
  77. agentId: string; assignmentId?: string; targetAgentId?: string; kind: NonNullable<Run['triggerKind']>; instruction?: string; taskReference?: {conversationId:string;taskId:string}
  78. }): Run {
  79. this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', source.agentId))
  80. return this.submit(owner, conversationId, input, source)
  81. }
  82. private files(owner:string,ids:string[]){return ids.map(id=>{const file=this.store.get<StoredWorkspaceFile>(owner,'file',id);if(file){if(file.sourceNodeId&&file.profile)this.nodes.requireSource(owner,{nodeId:file.sourceNodeId,profile:file.profile});return file}return {...this.uploads.records([id],owner)[0]!,sender:'user' as const}})}
  83. private submit(owner: string, conversationId: string, input: unknown, source?: {
  84. agentId: string; assignmentId?: string; targetAgentId?: string; kind: NonNullable<Run['triggerKind']>; instruction?: string; taskReference?: {conversationId:string;taskId:string}
  85. }): Run {
  86. if (!this.userActive(owner)) throw new HttpError(403,'账号授权已失效','account_inactive')
  87. const body = parse(sendInput, input)
  88. for (const id of this.store.require<Conversation>(owner, 'conversation', conversationId).memberIds)
  89. this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', id))
  90. const result = this.store.command(owner, body.requestId, { conversationId, ...body, ...(source ? { source } : {}) }, () => {
  91. const c = this.store.require<Conversation>(owner, 'conversation', conversationId)
  92. if (c.archived) throw new HttpError(409, '聊天已归档', 'conversation_archived')
  93. const conversationTask = this.store.resolveTask(owner, conversationId, body.taskId),
  94. conversationTaskId = conversationTask?.id
  95. if (conversationTaskId) {
  96. const alreadyActive = this.store.list<Run>(owner, 'run').some(run => run.conversationTaskId === conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
  97. if (!alreadyActive && this.store.activeTaskCount(owner, conversationId) >= 4)
  98. throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
  99. }
  100. if (body.mentionIds.some((a) => !this.store.taskMemberIds(owner,c,conversationTaskId).includes(a)))
  101. throw new HttpError(400, '只能 @ 群内成员', 'invalid_mentions')
  102. const records = this.files(owner,body.fileIds)
  103. const now = Date.now(),
  104. runId = randomUUID(),
  105. agents = this.store.taskMemberIds(owner,c,conversationTaskId).map((id) => this.store.require<Agent>(owner, 'agent', id))
  106. const mentions = [
  107. ...new Set(
  108. [...body.mentionIds, ...mentionedAgents(body.content, agents)],
  109. ),
  110. ]
  111. const message: Message = {
  112. id: randomUUID(),
  113. conversationId,
  114. conversationTaskId,
  115. seq: 0,
  116. role: source ? 'system' : 'user',
  117. ...(source?.taskReference ? {taskReference:source.taskReference} : {}),
  118. ...(source ? { agentId: source.agentId, agentName: this.store.require<Agent>(owner, 'agent', source.agentId).name } : {}),
  119. content: body.content.trim(),
  120. reasoning: '',
  121. runId,
  122. status: 'complete',
  123. attachments: records.map((f) => ({
  124. id: f.id,
  125. name: f.name,
  126. mimeType: f.mimeType,
  127. size: f.size,
  128. sender: f.sender,
  129. createdAt: now,
  130. })),
  131. tools: [],
  132. createdAt: now,
  133. }
  134. this.store.saveMessage(owner, message)
  135. for (const file of records) {
  136. const existing = this.store.get<Record<string, unknown>>(owner, 'file', file.id)
  137. if (existing && !existing.messageId)
  138. this.store.put(owner, 'file', file.id, {
  139. ...existing,
  140. conversationId,
  141. messageId: message.id,
  142. })
  143. }
  144. const run: Run = {
  145. id: runId,
  146. conversationId,
  147. conversationTaskId,
  148. messageId: message.id,
  149. mentionIds: mentions,
  150. activeAgentId: c.administratorId,
  151. status: 'queued',
  152. round: 0,
  153. createdAt: now,
  154. updatedAt: now,
  155. authorizationVersion: this.authorizationVersion(owner),
  156. ...(source ? { assignmentId: source.assignmentId, targetAgentId: source.targetAgentId, triggerKind: source.kind, internalInstruction: source.instruction } : {}),
  157. }
  158. this.admit(owner, run, c, message)
  159. this.uploads.markReferenced(body.fileIds, owner)
  160. return run
  161. })
  162. this.wake()
  163. return this.store.require<Run>(owner, 'run', result.id)
  164. }
  165. protected async performTurn(
  166. owner: string,
  167. c: Conversation,
  168. agent: Agent,
  169. run: Work,
  170. recovering = false,
  171. ): Promise<Message> {
  172. this.requireAuthorization(owner, run.runId)
  173. this.store.requireAgentTask(owner,agent,c.id,run.conversationTaskId)
  174. this.nodes.requireSource(owner, agent)
  175. if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
  176. const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
  177. target = agent.remoteAgentId || agent.execution==='computer' ? this.nodes.targetForAgent(owner,agent) : this.nodes.target(owner, agent.nodeId)
  178. const gateway = new WorkspaceGateway(target,{workId:run.id,publishArtifact:async(name,bytes)=>{
  179. this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
  180. const file=this.publishArtifact(owner,resultMessage,agent,name,bytes)
  181. if(!resultMessage.attachments.some(item=>item.id===file.id))resultMessage.attachments.push(file)
  182. this.store.saveMessage(owner,resultMessage)
  183. return {...file,url:`/api/app/files/${file.id}/download`}
  184. },authorize:()=>{
  185. this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
  186. const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
  187. if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮 Agent 或任务授权已结束','run_authorization_revoked')
  188. }})
  189. let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
  190. const movedRunner=!!binding&&(binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
  191. if(movedRunner) {
  192. if(recovering)throw new HttpError(409,'执行节点已变化,不能在另一节点重放原执行','runner_target_changed')
  193. binding=undefined
  194. }
  195. let message =
  196. run.currentMessageId
  197. ? this.store.require<Message>(owner, 'message', run.currentMessageId)
  198. : undefined
  199. if (!message) {
  200. message = {
  201. id: randomUUID(),
  202. conversationId: c.id,
  203. conversationTaskId: run.conversationTaskId,
  204. seq: 0,
  205. role: 'assistant',
  206. execution:agent.execution??'profile',
  207. agentId: agent.id,
  208. agentName: agent.name,
  209. content: '',
  210. reasoning: '',
  211. status: 'streaming',
  212. runId: run.runId,
  213. taskId: run.id,
  214. visible: run.replyMode !== 'automatic' || run.requiredReply,
  215. tools: [],
  216. attachments: [],
  217. createdAt: Date.now(),
  218. }
  219. run.currentMessageId = message.id
  220. this.store.atomic(() => {
  221. this.store.saveMessage(owner, message!)
  222. this.saveWork(owner, run)
  223. })
  224. }
  225. if(!recovering&&message.status==='queued'){message.status='streaming';message.error=undefined;message.visible=run.replyMode!=='automatic'||run.requiredReply}
  226. const resultMessage = message
  227. let submitted = recovering,
  228. settled = false,
  229. completedEvidence = false,
  230. completing = false,
  231. runtimeId = '',
  232. lastFlush = 0
  233. let flushTimer: ReturnType<typeof setTimeout> | undefined
  234. const toolController = new AbortController()
  235. let toolLease: WorkspaceToolLease | undefined
  236. let resolveTurn!: (m: Message) => void, rejectTurn!: (e: Error) => void
  237. const completion = new Promise<Message>((resolve, reject) => {
  238. resolveTurn = resolve
  239. rejectTurn = reject
  240. })
  241. // Register rejection immediately; setup RPCs can still be awaiting a reply.
  242. void completion.catch(() => {})
  243. const finish = (error?: Error) => {
  244. if (settled) return
  245. settled = true
  246. toolController.abort()
  247. void toolLease?.dispose()
  248. clearTimeout(flushTimer)
  249. flushTimer = undefined
  250. this.live.delete(key)
  251. try {
  252. const current = this.getWork(owner, run.id)
  253. if(error&&!submitted&&current.status!=='interrupted'&&error instanceof HttpError&&['computer_busy','computer_quota','runner_offline'].includes(error.code??'')){
  254. current.status='queued';current.resourceWait=true;current.error='等待执行节点或电脑资源';resultMessage.status='queued';resultMessage.visible=false;resultMessage.error=undefined
  255. }else if (error) {
  256. current.status = current.status === 'interrupted' ? 'interrupted' : submitted ? 'uncertain' : 'failed'
  257. current.error = error.message.slice(0, 1000)
  258. resultMessage.status = current.status
  259. resultMessage.error = current.error
  260. const optionalUnpublished = current.replyMode === 'automatic' && !current.requiredReply && resultMessage.visible === false
  261. resultMessage.visible = !optionalUnpublished && (submitted || current.status !== 'interrupted')
  262. if (!resultMessage.content.trim() && current.status === 'failed') resultMessage.content = `执行失败:${current.error}`
  263. } else {
  264. const silent = (resultMessage.content.trim() === NO_REPLY || !resultMessage.content.trim()) && current.replyMode === 'automatic' && (!current.requiredReply || current.hadInteraction) && !resultMessage.tools.length && !resultMessage.attachments.length
  265. current.silent = silent
  266. if (current.replyMode === 'automatic') {
  267. resultMessage.content = resultMessage.content.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '').trim()
  268. resultMessage.reasoning = resultMessage.reasoning.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '')
  269. }
  270. if (current.requiredReply && !silent && !resultMessage.content) resultMessage.content = HOST_FALLBACK
  271. if (!silent && !resultMessage.content && !resultMessage.attachments.length && !resultMessage.tools.length) {
  272. current.status = 'failed'; current.error = '未返回有效回复'; resultMessage.content = '执行失败:未返回有效回复'
  273. } else { current.status = 'complete'; current.error = undefined }
  274. resultMessage.visible = !silent
  275. if (silent) resultMessage.reasoning = ''
  276. resultMessage.status = current.status
  277. resultMessage.error = current.error
  278. }
  279. if (current.status !== 'uncertain') {
  280. for (const tool of resultMessage.tools) {
  281. if (!/complete|error|failed/.test(String(tool.status))) tool.status = current.status === 'complete' ? 'tool.complete' : 'tool.error'
  282. }
  283. }
  284. this.store.atomic(() => {
  285. this.store.saveMessage(owner, resultMessage)
  286. this.saveWork(owner, current)
  287. if (current.status !== 'uncertain') this.resolveInteractions(owner, current.id)
  288. })
  289. if (error) rejectTurn(error)
  290. else resolveTurn(resultMessage)
  291. } catch (failure) { rejectTurn(failure instanceof Error ? failure : new Error('无法保存执行状态')) }
  292. finally { gateway.close() }
  293. }
  294. gateway.onDisconnect = () =>
  295. finish(completedEvidence ? undefined : new Error('Hermes 连接断开'))
  296. gateway.onEvent = (frame: GatewayFrame) => {
  297. if (settled || !runtimeId || frame.session_id !== runtimeId) return
  298. const p = frame.payload ?? {},
  299. type = frame.type
  300. if (type === 'message.delta') {
  301. resultMessage.content += String(p.text ?? p.delta ?? '')
  302. if (resultMessage.content.trim() && !NO_REPLY.startsWith(resultMessage.content.trim()) && !resultMessage.content.trim().startsWith('[[YAOYAO_')) resultMessage.visible = true
  303. }
  304. else if (type === 'reasoning.delta')
  305. resultMessage.reasoning += String(p.text ?? p.delta ?? '')
  306. else if (type === 'message.interim') {
  307. resultMessage.content += '\n\n'
  308. } else if (type.startsWith('tool.')) {
  309. resultMessage.visible = true
  310. const id = String(p.tool_id ?? p.id ?? ''),
  311. index = resultMessage.tools.findIndex((t) => t.id === id)
  312. const value = { ...p, id, status: type }
  313. if (index >= 0) resultMessage.tools[index] = { ...resultMessage.tools[index], ...value }
  314. else resultMessage.tools.push(value)
  315. } else if (
  316. ['approval.request', 'approval.requested', 'clarify.request', 'clarify.requested'].includes(
  317. type,
  318. )
  319. ) {
  320. const upstreamId = String(p.request_id ?? p.requestId ?? p.id ?? '')
  321. const previous = this.store.list<WorkspaceInteraction>(owner, 'interaction').find(i => {
  322. const b = this.store.get<{ taskId: string; upstreamId: string }>(owner, 'interaction-binding', i.id)
  323. return b?.taskId === run.id && b.upstreamId === upstreamId
  324. })
  325. if (previous?.resolved) return
  326. const interaction: WorkspaceInteraction = previous ?? {
  327. id: randomUUID(),
  328. conversationId: c.id,
  329. conversationTaskId: run.conversationTaskId,
  330. runId: run.runId,
  331. agentId: agent.id,
  332. kind: type.startsWith('approval') ? 'approval' : 'clarification',
  333. message: String(p.question ?? p.message ?? p.prompt ?? '需要确认'),
  334. choices: Array.isArray(p.choices) ? p.choices.map(String) : [],
  335. resolved: false,
  336. }
  337. this.store.put(owner, 'interaction', interaction.id, interaction)
  338. this.store.put(owner, 'interaction-binding', interaction.id, { key, upstreamId, taskId: run.id, conversationTaskId: run.conversationTaskId })
  339. this.store.event(owner, 'interaction.changed', interaction, c.id)
  340. const current = this.getWork(owner, run.id)
  341. current.hadInteraction = true
  342. current.status = 'waiting'
  343. this.saveWork(owner, current)
  344. this.notify(owner, c, this.store.require<Run>(owner, 'run', run.runId), undefined, interaction)
  345. } else if (['message.complete', 'run.completed'].includes(type)) {
  346. if (typeof p.text === 'string') resultMessage.content = p.text
  347. if (typeof p.reasoning === 'string') resultMessage.reasoning = p.reasoning
  348. if (p.status === 'interrupted') {
  349. const current = this.getWork(owner, run.id)
  350. current.status = 'interrupted'
  351. this.saveWork(owner, current)
  352. finish(new Error('已停止'))
  353. } else if (p.error || p.status === 'failed') {
  354. submitted = false
  355. finish(new HttpError(502, String(p.error ?? '运行失败'), 'run_failed'))
  356. } else if (!completing) {
  357. completedEvidence = true
  358. completing = true
  359. let timeout: ReturnType<typeof setTimeout>
  360. void Promise.race([
  361. gateway.rpc('session.usage', { session_id: runtimeId }),
  362. new Promise<undefined>((resolve) => {
  363. timeout = setTimeout(() => resolve(undefined), 1000)
  364. }),
  365. ])
  366. .then((usage) => {
  367. if (usage && !this.closing) {
  368. const used = usage.context_used ?? usage.used_tokens,
  369. limit = usage.context_max ?? usage.context_limit
  370. const contextKey = run.conversationTaskId ?? c.id,
  371. context = {
  372. ...usage,
  373. conversationTaskId: run.conversationTaskId,
  374. usedTokens: used,
  375. limitTokens: limit,
  376. percent:
  377. typeof used === 'number' && typeof limit === 'number' && limit > 0
  378. ? Math.round((used / limit) * 1000) / 10
  379. : undefined,
  380. }
  381. this.store.put(owner, 'context', contextKey, context)
  382. this.store.event(
  383. owner,
  384. 'context.changed',
  385. context,
  386. c.id,
  387. )
  388. }
  389. })
  390. .catch(() => {})
  391. .finally(() => {
  392. clearTimeout(timeout)
  393. finish()
  394. })
  395. }
  396. return
  397. } else if (['error', 'message.error', 'run.failed'].includes(type)) {
  398. submitted = false
  399. finish(new HttpError(502, String(p.message ?? p.error ?? '运行失败'), 'run_failed'))
  400. return
  401. } else if (['session.usage', 'usage.update', 'context.update'].includes(type)) {
  402. const context = { ...p, conversationTaskId: run.conversationTaskId }
  403. this.store.put(owner, 'context', run.conversationTaskId ?? c.id, context)
  404. this.store.event(owner, 'context.changed', context, c.id)
  405. } else if (type === 'session.info' && binding) {
  406. const storedId = p.stored_session_id ?? p.session_key
  407. if (typeof storedId === 'string' && storedId && storedId !== binding.storedId) {
  408. binding.aliases = [...new Set([...binding.aliases, binding.storedId, storedId])]
  409. binding.storedId = storedId
  410. this.store.put(owner, 'binding', key, binding)
  411. }
  412. }
  413. const flush = () => {
  414. clearTimeout(flushTimer)
  415. flushTimer = undefined
  416. if (settled || this.closing) return
  417. try {
  418. this.store.saveMessage(owner, resultMessage)
  419. lastFlush = Date.now()
  420. } catch (error) {
  421. finish(error instanceof Error ? error : new Error('无法保存流式消息'))
  422. }
  423. }
  424. const remaining = 100 - (Date.now() - lastFlush)
  425. if (remaining <= 0) flush()
  426. // A stream may pause after any chunk. Publish the latest text even when
  427. // no subsequent event arrives to trigger the next leading-edge flush.
  428. else if (flushTimer === undefined) flushTimer = setTimeout(flush, remaining)
  429. }
  430. try {
  431. await gateway.connect()
  432. this.requireAuthorization(owner, run.runId)
  433. this.nodes.requireSource(owner, agent)
  434. // Paired Web nodes enforce their own profile config at the destination.
  435. const cwd = recovering || target.pairedToken ? undefined
  436. : await configuredWorkingDirectory((path, options) => target.session.request(path, options), agent.profile)
  437. this.nodes.requireSource(owner, agent)
  438. const opened = binding?.storedId
  439. ? await gateway.rpc('session.resume', {
  440. profile: agent.profile,
  441. session_id: binding.storedId,
  442. omit_messages: true,
  443. ...(recovering&&target.runner?.computer?{recoverOnly:true}:{}),
  444. close_on_disconnect: false,
  445. })
  446. : await gateway.rpc('session.create', {
  447. profile: agent.profile,
  448. title: `Yaoyao ${c.id}${run.conversationTaskId ? ` / ${run.conversationTaskId}` : ''}`,
  449. source: 'yaoyao_workspace',
  450. ...(cwd ? { cwd } : {}),
  451. hidden: true,
  452. room_plumbing: true,
  453. close_on_disconnect: false,
  454. })
  455. runtimeId = String(opened.session_id ?? '')
  456. const storedId = String(
  457. opened.stored_session_id ?? opened.session_key ?? opened.resumed ?? binding?.storedId ?? '',
  458. )
  459. if (
  460. !runtimeId ||
  461. !storedId ||
  462. (opened.info?.profile_name && opened.info.profile_name !== agent.profile)
  463. )
  464. throw new Error('Hermes 会话身份不匹配')
  465. binding = {
  466. runnerId: target.runner?.id,
  467. execution:agent.execution,
  468. id: key,
  469. nodeId: agent.nodeId,
  470. remoteAgentId: agent.remoteAgentId,
  471. profile: agent.profile,
  472. storedId,
  473. runtimeId,
  474. aliases: [
  475. ...new Set([
  476. ...(binding?.aliases ?? []),
  477. ...(binding ? [binding.storedId] : []),
  478. storedId,
  479. ]),
  480. ],
  481. runId: run.runId,
  482. conversationTaskId: run.conversationTaskId,
  483. taskId: run.id,
  484. contextSeq: binding?.contextSeq ?? 0,
  485. messageId: resultMessage.id,
  486. }
  487. this.store.put(owner, 'binding', key, binding)
  488. this.live.set(key, { gateway, runtimeId, runId: run.runId, conversationTaskId: run.conversationTaskId, taskId: run.id, agentId: agent.id, done: finish })
  489. if (this.closing || this.getWork(owner, run.id).cancelRequested || this.getWork(owner, run.id).status === 'interrupted')
  490. throw new Error('运行已停止')
  491. if (recovering) {
  492. if (opened.running) {
  493. const current = this.getWork(owner, run.id)
  494. current.status = 'running'
  495. this.saveWork(owner, current)
  496. } else {
  497. const marker = `[yaoyao-run:${run.runId}:${resultMessage.id}]`
  498. let answer: { content?: string; text?: string } | undefined
  499. let found = false, seenLastEvent = false
  500. for (let page = 0; page < 20 && !found; page++) {
  501. const response = await target.session.request(`/api/sessions/${encodeURIComponent(storedId)}/messages`, {
  502. search: new URLSearchParams({ profile: agent.profile, limit: '500', offset: String(page * 500), order: 'latest', include_compacted: 'true' }),
  503. maxResponseBytes: 8 * 1024 * 1024,
  504. })
  505. if (response.status !== 200) throw new Error('无法核对历史')
  506. const history = JSON.parse(response.body.toString()).messages
  507. if (!Array.isArray(history)) throw new Error('历史响应无效')
  508. for (const item of [...history].reverse()) {
  509. if (item.role === 'user') {
  510. if (String(item.content ?? item.text).includes(marker)) { found = true; break }
  511. answer = undefined; seenLastEvent = false
  512. } else if (!seenLastEvent && ['assistant', 'tool'].includes(item.role)) {
  513. seenLastEvent = true
  514. const calls = item.tool_calls
  515. const hasCalls = Array.isArray(calls) ? calls.length > 0 : !!calls && calls !== '[]'
  516. if (item.role === 'assistant' && !hasCalls && !item.function_call && item.finish_reason !== 'tool_calls' && (item.content || item.text)) answer = item
  517. }
  518. }
  519. if (history.length < 500) break
  520. }
  521. if (!found || !answer) throw new Error('无法确认本轮的最终回复,正在核对原执行;不会重复提交')
  522. resultMessage.content = String(answer.content ?? answer.text)
  523. binding.contextSeq = Math.max(binding.contextSeq ?? 0, run.contextThroughSeq ?? 0)
  524. this.store.put(owner, 'binding', key, binding)
  525. finish()
  526. }
  527. } else {
  528. if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
  529. this.nodes.requireSource(owner, agent)
  530. // Only explicit grants mount the bridge; ordinary Bot turns never query plugins.
  531. if (agent.canManageTeam === true && !this.store.require<Run>(owner, 'run', run.runId).assignmentId) {
  532. const granted = this.getWork(owner, run.id)
  533. granted.teamManagementRevision = agent.revision
  534. this.saveWork(owner, granted)
  535. await this.teamTools.requireAvailable(owner, agent)
  536. toolLease = await createWorkspaceToolLease({
  537. target, profile: agent.profile, workId: run.id, signal: toolController.signal,
  538. session: () => ({ runtimeId, storedId: binding!.storedId }),
  539. assertActive: () => { if (settled || this.closing) throw new Error('运行已停止'); this.teamTools.assertTurn(owner, run.id) },
  540. catalog: () => this.teamTools.catalog(owner, run.id),
  541. call: (toolId, args) => this.teamTools.call(owner, run.id, toolId, args),
  542. onFailure: error => { if (!settled) void gateway.rpc('session.interrupt', { session_id: runtimeId }).catch(() => {}).finally(() => finish(error)) },
  543. })
  544. await toolLease.bind()
  545. if (settled) return await completion
  546. }
  547. await applyWorkingDirectory((method, params) => gateway.rpc(method, params), runtimeId, cwd, undefined, opened.info?.cwd)
  548. const trigger = this.store.require<Message>(owner, 'message', run.messageId)
  549. const attachmentRefs: string[] = []
  550. for (const file of trigger.attachments) {
  551. this.requireAuthorization(owner, run.runId)
  552. const record = this.files(owner,[file.id])[0]!
  553. const bytes = readFileSync(record.path).toString('base64')
  554. const attached = record.mimeType.startsWith('image/')
  555. ? await gateway.rpc('image.attach_bytes', {
  556. session_id: runtimeId,
  557. filename: record.name,
  558. content_base64: bytes,
  559. })
  560. : await gateway.rpc('file.attach', {
  561. session_id: runtimeId,
  562. name: record.name,
  563. data_url: `data:${record.mimeType};base64,${bytes}`,
  564. })
  565. if (attached.ref_text) attachmentRefs.push(String(attached.ref_text))
  566. }
  567. const members = run.turnConfiguration!.members
  568. const goal = run.conversationTaskId ? this.store.get<import('../shared/agentTasks.js').AgentGoal>(owner, 'goal', run.conversationTaskId) : undefined
  569. const assignmentId = this.store.require<Run>(owner, 'run', run.runId).assignmentId
  570. const rules = [
  571. `你是 ${agent.name}。以下是用户为这个独立 Agent 配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端 Agent 管理。' : agent.instructions}`,
  572. c.kind === 'group' && Object.keys(c.memberRoles ?? {}).length
  573. ? `本群角色分工(仅在本群生效):\n${members.flatMap(member => {
  574. const role = c.memberRoles?.[member.id]
  575. return role ? [`@${member.name}:${role.name};${role.description}`] : []
  576. }).join('\n')}\n协作时使用上面的真实成员名称进行 @,不要使用职责名称代替成员名称。`
  577. : '',
  578. c.kind === 'group'
  579. ? `你正在群聊「${c.name}」发言。群成员:${members.map((a) => `@${a.name} (id=${a.id})`).join('、')}。\n群规则:${c.instructions}\n${c.mode === 'host' ? (agent.id === c.administratorId ? '你是管理员。必要时用精确 @成员名称 委派工作;收到结果后复核并给用户结论。任务完成时不要继续 @。' : '执行当前委派任务。公开给出结果,由管理员复核;不要安排其他成员。') : '按自己的职责回复,只在需要协作时 @成员。不要重复已完成的工作。'}`
  580. : '',
  581. run.requiredReply ? '你必须公开处理本次消息,直接回答、委派或澄清;禁止静默。管理员可按依赖一次 @一人,也可同时 @多人并行执行,整批结束后系统统一交回复核。' : run.replyMode === 'automatic' ? `你按自动参与配置收到消息。若与职责无关,禁止调用工具、禁止 @,完整答复只能是 ${NO_REPLY}。有关时正常回答。` : '',
  582. `本轮用户指定成员:${this.store.require<Run>(owner, 'run', run.runId).mentionIds.map(id => members.find(a => a.id === id)).filter(Boolean).map(a => '@' + a!.name).join('、') || '未指定'}`,
  583. '角色规则不赋予额外工具权限;仍遵守基础 Hermes 的工具和安全约束。',
  584. this.store.require<Run>(owner, 'run', run.runId).internalInstruction || '',
  585. goal ? `当前团队目标 ID:${goal.id}。目标:${goal.objective.slice(0,8000)}\n验收要求:${goal.acceptanceCriteria.join(';')}\n${assignmentId ? `你在执行子任务 ${assignmentId},请完成分派并提交结果,不要扩大团队或再次委派。` : '优先使用 workspace_assign_task 进行有依赖和验收要求的结构化分派;成员结果需要通过 workspace_review_assignment 复核。最终使用 workspace_finish_team_task 记录完成、受阻或等待用户。不要将一次模型回复结束当作目标完成。'}` : '',
  586. agent.temporaryGoalId ? `你是当前任务的临时助手,任务 ID:${agent.temporaryGoalId}。仅处理分派工作,使用 computer_export 回传产物。任务结束后会退役;不要创建团队或改变自身权限。` : '',
  587. toolLease ? TEAM_TOOL_RULES : '',
  588. `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
  589. ]
  590. .filter(Boolean)
  591. .join('\n\n')
  592. const text = c.kind === 'group' ? this.contextText(owner, c, agent, run, binding.contextSeq ?? 0, movedRunner)
  593. : movedRunner ? `执行节点已切换。以下是原会话的近期记录,旧路径和执行状态需要在当前节点重新核实;不要重新执行已完成的操作。\n\n${this.contextText(owner,c,agent,run,0,true)}` : trigger.content
  594. const admission = this.getWork(owner, run.id)
  595. if (!this.store.taskMemberIds(owner,this.store.require<Conversation>(owner,'conversation',c.id),run.conversationTaskId).includes(agent.id)) {
  596. admission.status = 'interrupted'; admission.error = '执行前成员已移除'; this.saveWork(owner, admission)
  597. throw new Error('执行前成员已移除')
  598. }
  599. this.nodes.requireSource(owner, agent)
  600. this.requireAuthorization(owner, run.runId)
  601. if (admission.cancelRequested || admission.status === 'interrupted') throw new Error('运行已停止')
  602. admission.contextThroughSeq = run.contextThroughSeq ?? run.triggerSeq
  603. admission.submitted = true
  604. this.saveWork(owner, admission)
  605. submitted = true
  606. try {
  607. await gateway.rpc('prompt.submit', {
  608. session_id: runtimeId,
  609. text: `${rules}\n\n${text}\n${attachmentRefs.join('\n')}`,
  610. })
  611. binding.contextSeq = Math.max(binding.contextSeq ?? 0, admission.contextThroughSeq)
  612. this.store.put(owner, 'binding', key, binding)
  613. } catch (error) {
  614. // A JSON-RPC error is a definitive rejection, not a lost receipt.
  615. if (error instanceof HttpError && ['gateway_rejected','runner_command_not_admitted'].includes(error.code??'')) { submitted = false; const current = this.getWork(owner, run.id); current.submitted = false; this.saveWork(owner, current) }
  616. throw error
  617. }
  618. }
  619. return await completion
  620. } catch (error) {
  621. finish(error instanceof Error ? error : new Error('执行失败'))
  622. return await completion
  623. }
  624. }
  625. private requireAuthorization(owner: string, runId: string): void {
  626. const run=this.store.require<Run>(owner,'run',runId)
  627. if(!this.userActive(owner)||(run.authorizationVersion!==undefined&&run.authorizationVersion!==this.authorizationVersion(owner)))
  628. throw new HttpError(403,'本轮账号授权已失效','run_authorization_revoked')
  629. }
  630. private contextText(owner: string, c: Conversation, agent: Agent, work: Work, after: number, includeOwn = false): string {
  631. const rootTrigger = this.store.require<Message>(owner, 'message', work.messageId)
  632. const roots = new Map(this.store.list<Run>(owner, 'run').filter(r => r.conversationId === c.id && r.conversationTaskId === work.conversationTaskId).map(r => [r.id, this.store.get<Message>(owner, 'message', r.messageId)?.seq ?? 0]))
  633. const history = this.store.messages(owner, c.id, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, true, work.conversationTaskId)
  634. const unfinished = history.find(m => m.seq > after && m.seq <= work.triggerSeq && (['queued', 'streaming', 'uncertain'].includes(m.status) || (m.runId && (roots.get(m.runId) ?? 0) > rootTrigger.seq)))
  635. work.contextThroughSeq = unfinished ? unfinished.seq - 1 : work.triggerSeq
  636. const eligible = history
  637. .filter(m => m.seq > after && m.seq <= work.triggerSeq && m.visible !== false && ['complete', 'failed', 'interrupted'].includes(m.status)
  638. && (includeOwn || !(m.role === 'assistant' && m.agentId === agent.id)) && (!m.runId || (roots.get(m.runId) ?? 0) <= rootTrigger.seq))
  639. const selected: string[] = []
  640. let size = 0, omitted = 0
  641. for (const message of [...eligible].reverse()) {
  642. const files = message.attachments.map(f => `[附件 ${f.name}](${f.sourcePath || `/api/app/files/${f.id}/download`})`).join('\n')
  643. const content = message.content.length > 24_000 ? message.content.slice(0, 12_000) + '\n[内容过长,保留首尾片段]\n' + message.content.slice(-12_000) : message.content
  644. const reasoning = message.reasoning ? `\n思考摘要:${message.reasoning.slice(0, 2000)}` : ''
  645. const tools = [...new Set(message.tools.map(t => String(t.name ?? t.tool_name ?? '')).filter(name => /^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$/.test(name)))].slice(0, 16)
  646. const row = `${message.role === 'user' ? '用户' : message.agentName ?? '系统'}(${message.status}):${content}${reasoning}${tools.length ? `\n使用工具:${tools.join('、')}` : ''}${message.error ? `\n错误:${message.error}` : ''}${files ? '\n' + files : ''}`
  647. if (selected.length >= 50 || size + row.length > 30_000) { omitted++; continue }
  648. selected.unshift(row); size += row.length
  649. }
  650. // Preserve a large trigger as a bounded excerpt rather than silently losing it.
  651. if (!selected.length && eligible.length) selected.push(eligible.at(-1)!.content.slice(-30_000))
  652. const batch = work.reviewOf ? this.works(owner, work.runId).filter(t => t.batchId === work.reviewOf).map(t => `${this.store.get<Agent>(owner, 'agent', t.agentId)?.name ?? t.agentId}:${t.status}${t.error ? `;${t.error}` : ''}`).join('\n') : ''
  653. return `${batch ? `本批次执行结果:\n${batch}\n\n` : ''}${omitted ? `较早上下文有 ${omitted} 条因长度限制省略,请勿假定已完整读取。\n\n` : ''}${selected.join('\n\n')}`
  654. }
  655. private resolveInteractions(owner: string, taskId: string): void {
  656. for (const interaction of this.store.list<WorkspaceInteraction>(owner, 'interaction')) {
  657. const binding = this.store.get<{ taskId?: string }>(owner, 'interaction-binding', interaction.id)
  658. if (binding?.taskId === taskId && !interaction.resolved) {
  659. interaction.resolved = true
  660. this.store.put(owner, 'interaction', interaction.id, interaction)
  661. this.store.event(owner, 'interaction.changed', interaction, interaction.conversationId)
  662. }
  663. }
  664. }
  665. async respond(owner: string, id: string, answer: string): Promise<void> {
  666. type Reply = WorkspaceInteraction & { answer?: string; responseState?: 'sending' | 'uncertain' | 'sent' }
  667. const interaction = this.store.require<Reply>(owner, 'interaction', id)
  668. this.requireAuthorization(owner,interaction.runId)
  669. this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', interaction.agentId))
  670. if (interaction.resolved) {
  671. if (interaction.answer && interaction.answer !== answer) throw new HttpError(409, '该请求已使用其他答复完成', 'interaction_answer_conflict')
  672. return
  673. }
  674. if (interaction.responseState === 'sending' || interaction.responseState === 'uncertain') throw new HttpError(409, '答复状态待确认,请核对原请求', 'interaction_uncertain')
  675. const binding = this.store.require<{ key: string; upstreamId: string; taskId: string }>(owner, 'interaction-binding', id)
  676. const live = this.live.get(binding.key)
  677. if (!live || live.taskId !== binding.taskId || live.runId !== interaction.runId) throw new HttpError(409, '请先恢复此轮连接', 'interaction_offline')
  678. if (interaction.kind === 'approval' && !['once', 'session', 'always', 'deny', 'allow', 'approve'].includes(answer)) throw new HttpError(400, '审批选项无效', 'invalid_approval')
  679. interaction.answer = answer; interaction.responseState = 'sending'
  680. this.store.put(owner, 'interaction', id, interaction)
  681. try {
  682. await live.gateway.rpc(interaction.kind === 'approval' ? 'approval.respond' : 'clarify.respond', {
  683. session_id: live.runtimeId, request_id: binding.upstreamId,
  684. ...(interaction.kind === 'approval' ? { choice: answer } : { answer }),
  685. })
  686. } catch (error) {
  687. const latest = this.store.require<Reply>(owner, 'interaction', id)
  688. if (!latest.resolved) {
  689. latest.responseState = error instanceof HttpError && error.code === 'gateway_rejected' ? undefined : 'uncertain'
  690. this.store.put(owner, 'interaction', id, latest)
  691. }
  692. throw error
  693. }
  694. interaction.resolved = true; interaction.responseState = 'sent'
  695. this.store.put(owner, 'interaction', id, interaction)
  696. this.store.event(owner, 'interaction.changed', interaction, interaction.conversationId)
  697. const current = this.getWork(owner, binding.taskId)
  698. if (current.status === 'waiting') { current.status = 'running'; this.saveWork(owner, current) }
  699. }
  700. protected async interruptTurn(owner: string, work: Work): Promise<void> {
  701. const key = this.bindingKey(work.conversationId, work.conversationTaskId, work.agentId)
  702. const live = this.live.get(key)
  703. if (live?.taskId === work.id) {
  704. await live.gateway.rpc('session.interrupt', { session_id: live.runtimeId })
  705. const current = this.getWork(owner, work.id)
  706. if (['complete', 'failed', 'interrupted'].includes(current.status)) return
  707. current.status = 'interrupted'; current.error = '已停止'
  708. this.saveWork(owner, current)
  709. if(current.currentMessageId){const message=this.store.require<Message>(owner,'message',current.currentMessageId);message.status='interrupted';message.error='已停止';this.store.saveMessage(owner,message)}
  710. live.done(new Error('已停止'))
  711. } else {
  712. const binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
  713. if (work.submitted) {
  714. if (!binding || binding.taskId !== work.id) throw new Error('无法确认待停止成员的会话身份')
  715. const target=binding.remoteAgentId||binding.execution==='computer'?this.nodes.targetForAgent(owner,{...binding,id:work.agentId}):this.nodes.target(owner,binding.nodeId)
  716. if(binding.runnerId!==target.runner?.id)throw new Error('执行节点已变化,无法确认原任务已停止')
  717. const gateway = new WorkspaceGateway(target,{workId:work.id,cleanupOnly:true,sessionId:binding.storedId,authorize:()=>{const current=this.store.get<WorkspaceBinding>(owner,'binding',key);if(current?.taskId!==work.id||current.storedId!==binding.storedId)throw new Error('原会话绑定已改变')}})
  718. try {
  719. await gateway.connect()
  720. const opened = await gateway.rpc('session.resume', { profile: binding.profile, session_id: binding.storedId, omit_messages: true, close_on_disconnect: false,...(binding.execution==='computer'?{recoverOnly:true}:{}) })
  721. if (opened.running) await gateway.rpc('session.interrupt', { session_id: opened.session_id })
  722. } finally { gateway.close() }
  723. }
  724. const current = this.getWork(owner, work.id)
  725. current.status = 'interrupted'; current.error = '已停止'
  726. this.saveWork(owner, current)
  727. if (current.currentMessageId) {
  728. const message = this.store.require<Message>(owner, 'message', current.currentMessageId)
  729. message.status = 'interrupted'; this.store.saveMessage(owner, message)
  730. }
  731. }
  732. this.resolveInteractions(owner, work.id)
  733. }
  734. override async stopTask(owner: string, conversationId: string, taskId: string, visited = new Set<string>()): Promise<void> {
  735. const key = `${owner}:${taskId}`
  736. if (visited.has(key)) return
  737. visited.add(key)
  738. this.tasks.cancel(owner, taskId)
  739. await Promise.all([super.stopTask(owner, conversationId, taskId), this.tasks.cancelOrigin(owner, conversationId, taskId, visited)])
  740. }
  741. override async stopConversation(owner: string, conversationId: string): Promise<void> {
  742. for (const task of this.store.list<import('../shared/workspace.js').WorkspaceTask>(owner, 'conversation-task'))
  743. if (task.conversationId === conversationId) this.tasks.cancel(owner, task.id)
  744. await Promise.all([super.stopConversation(owner, conversationId), this.tasks.cancelOrigin(owner, conversationId)])
  745. }
  746. close(): void {
  747. this.tasks.close()
  748. this.closeScheduler()
  749. for (const live of [...this.live.values()]) live.done(new Error('服务关闭'))
  750. this.live.clear()
  751. }
  752. }