|
@@ -10,12 +10,13 @@ import type {GatewayFrame} from '../../server/workspaceGateway.js'
|
|
|
import type {RunnerConfiguration} from '../../shared/runner.js'
|
|
import type {RunnerConfiguration} from '../../shared/runner.js'
|
|
|
import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
|
|
import {ContainerComputerProvider,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
|
|
|
import {ComputerPool,type ComputerLease} from '../computers/pool.js'
|
|
import {ComputerPool,type ComputerLease} from '../computers/pool.js'
|
|
|
|
|
+import {ComputerControls} from './control.js'
|
|
|
import {ComputerPublicProxy} from '../network/computerProxy.js'
|
|
import {ComputerPublicProxy} from '../network/computerProxy.js'
|
|
|
import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
|
|
import {HermesWorkerProcess,type WorkerModel,type WorkerTool,type WorkerFrame} from './process.js'
|
|
|
|
|
|
|
|
export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
|
|
export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string}
|
|
|
export interface WorkerSession {id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
|
|
export interface WorkerSession {id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain'}
|
|
|
-interface Live {proxy?:ComputerPublicProxy;session:WorkerSession;model:WorkerModel;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
|
|
|
|
|
|
|
+interface Live {workMarker?:string;paused?:boolean;pauseJob?:Promise<ComputerLease>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxy?:ComputerPublicProxy;session:WorkerSession;model:WorkerModel;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
|
|
|
const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
|
|
const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
|
|
|
const text={type:'string'}
|
|
const text={type:'string'}
|
|
|
const TOOLS:WorkerTool[]=[
|
|
const TOOLS:WorkerTool[]=[
|
|
@@ -28,10 +29,12 @@ const TOOLS:WorkerTool[]=[
|
|
|
]
|
|
]
|
|
|
const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
|
|
const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
|
|
|
export class ComputerRuntime {
|
|
export class ComputerRuntime {
|
|
|
|
|
+ readonly controls:ComputerControls
|
|
|
readonly provider:ContainerComputerProvider
|
|
readonly provider:ContainerComputerProvider
|
|
|
readonly pool:ComputerPool
|
|
readonly pool:ComputerPool
|
|
|
readonly ready:Promise<void>
|
|
readonly ready:Promise<void>
|
|
|
readonly config:NonNullable<RunnerConfiguration['computers']>
|
|
readonly config:NonNullable<RunnerConfiguration['computers']>
|
|
|
|
|
+ readonly gateways=new Map<string,ComputerGateway>()
|
|
|
readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
|
|
readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
|
|
|
readonly script:string
|
|
readonly script:string
|
|
|
readonly proxyScript:string
|
|
readonly proxyScript:string
|
|
@@ -44,6 +47,7 @@ export class ComputerRuntime {
|
|
|
this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
|
|
this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
|
|
|
db.exec('CREATE TABLE IF NOT EXISTS retired_computers(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,spec TEXT)')
|
|
db.exec('CREATE TABLE IF NOT EXISTS retired_computers(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,spec TEXT)')
|
|
|
db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
|
|
db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
|
|
|
|
|
+ this.controls=new ComputerControls(this)
|
|
|
this.ready=this.pool.recover();void this.ready.catch(()=>{})
|
|
this.ready=this.pool.recover();void this.ready.catch(()=>{})
|
|
|
}
|
|
}
|
|
|
assertTarget(meta:ComputerTarget){if(this.db.prepare('SELECT id FROM retired_computers WHERE id=?').get(meta.environmentId))throw new HttpError(410,'临时电脑已退役','computer_retired')}
|
|
assertTarget(meta:ComputerTarget){if(this.db.prepare('SELECT id FROM retired_computers WHERE id=?').get(meta.environmentId))throw new HttpError(410,'临时电脑已退役','computer_retired')}
|
|
@@ -109,14 +113,14 @@ export class ComputerGateway {
|
|
|
await this.authorized()
|
|
await this.authorized()
|
|
|
const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
|
|
const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
|
|
|
if(this.closed){controller.abort();await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
|
|
if(this.closed){controller.abort();await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
|
|
|
- this.live={session,lease,controller,model:resolved.model as WorkerModel,images:[],running:false,received:false}
|
|
|
|
|
- this.runtime.save(session)
|
|
|
|
|
|
|
+ this.live={session,lease,controller,model:resolved.model as WorkerModel,images:[],running:false,received:false,toolCalls:new Set(),journal:new Map()}
|
|
|
|
|
+ this.runtime.save(session);this.runtime.gateways.set(this.meta.environmentId,this)
|
|
|
if(this.runtime.config.network==='public-proxy'){
|
|
if(this.runtime.config.network==='public-proxy'){
|
|
|
const live=this.live
|
|
const live=this.live
|
|
|
- try{live.proxy=await ComputerPublicProxy.start(this.runtime.provider,this.spec(session),this.runtime.proxyScript,()=>this.runtime.pool.authorize(lease),()=>this.authorized(),()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})})}
|
|
|
|
|
|
|
+ try{live.proxy=await ComputerPublicProxy.start(this.runtime.provider,this.spec(session),this.runtime.proxyScript,()=>this.runtime.pool.authorize(live.lease),()=>this.authorized(),()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})})}
|
|
|
catch(error){await this.interrupt();throw error}
|
|
catch(error){await this.interrupt();throw error}
|
|
|
}
|
|
}
|
|
|
- this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
|
|
|
|
|
|
|
+ this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(this.live!.lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
|
|
|
return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
|
|
return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
|
|
|
}
|
|
}
|
|
|
const live=this.live
|
|
const live=this.live
|
|
@@ -146,29 +150,39 @@ export class ComputerGateway {
|
|
|
if(method==='prompt.submit'){
|
|
if(method==='prompt.submit'){
|
|
|
if(live.running||live.received)throw new HttpError(409,'电脑会话已经提交过本轮','computer_session_busy')
|
|
if(live.running||live.received)throw new HttpError(409,'电脑会话已经提交过本轮','computer_session_busy')
|
|
|
await this.authorized();this.guard()
|
|
await this.authorized();this.guard()
|
|
|
- const prompt=String(params.text??''),history=structuredClone(live.session.history)
|
|
|
|
|
- live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
|
|
|
|
|
- const directory=join(this.runtime.home,'workers',live.session.id,this.workId)
|
|
|
|
|
|
|
+ if(typeof params.workMarker==='string'&&/^\[yaoyao-run:[0-9a-f-]{36}:[0-9a-f-]{36}\]$/.test(params.workMarker))live.workMarker=params.workMarker
|
|
|
|
|
+ await this.startModel(live,String(params.text??''),structuredClone(live.session.history))
|
|
|
|
|
+ return {status:'streaming'}
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new HttpError(409,'当前隔离 Worker 不支持此交互','computer_method_unavailable')
|
|
|
|
|
+ }
|
|
|
|
|
+ private async startModel(live:Live,prompt:string,history:any[]){
|
|
|
|
|
+ live.received=false;live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
|
|
|
|
|
+ const directory=join(this.runtime.home,'workers',live.session.id,this.workId,String(live.segment=(live.segment??0)+1))
|
|
|
await mkdir(directory,{recursive:true,mode:0o700})
|
|
await mkdir(directory,{recursive:true,mode:0o700})
|
|
|
const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
|
|
const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
|
|
|
live.worker=worker
|
|
live.worker=worker
|
|
|
- this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>this.runtime.workers.delete(this.workId))
|
|
|
|
|
- worker.onTool=(name,args,id)=>this.tool(live,name,args,id)
|
|
|
|
|
|
|
+ this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>{if(this.runtime.workers.get(this.workId)?.process===worker)this.runtime.workers.delete(this.workId)})
|
|
|
|
|
+ worker.onTool=(name,args,id,callId)=>{
|
|
|
|
|
+ const task=this.tool(live,name,args,id).then(result=>{if(callId)live.journal.set(callId,{name,result});return result},error=>{if(callId)live.journal.set(callId,{name,result:{error:'操作未完成或已中断'}});throw error})
|
|
|
|
|
+ live.toolCalls.add(task);void task.finally(()=>live.toolCalls.delete(task)).catch(()=>{});return task
|
|
|
|
|
+ }
|
|
|
worker.onEvent=frame=>{
|
|
worker.onEvent=frame=>{
|
|
|
|
|
+ if(frame.type==='checkpoint'&&Array.isArray(frame.messages)){live.session.history=frame.messages;this.runtime.save(live.session);return}
|
|
|
|
|
+ if(live.paused)return
|
|
|
if(frame.type==='delta')this.event('message.delta',{text:frame.text})
|
|
if(frame.type==='delta')this.event('message.delta',{text:frame.text})
|
|
|
if(frame.type==='reasoning')this.event('reasoning.delta',{text:frame.text})
|
|
if(frame.type==='reasoning')this.event('reasoning.delta',{text:frame.text})
|
|
|
if(frame.type==='complete'){live.received=true;live.finishing=this.finish(live,frame);void live.finishing.catch(()=>this.lost(live))}
|
|
if(frame.type==='complete'){live.received=true;live.finishing=this.finish(live,frame);void live.finishing.catch(()=>this.lost(live))}
|
|
|
if(frame.type==='failed')void this.lost(live)
|
|
if(frame.type==='failed')void this.lost(live)
|
|
|
}
|
|
}
|
|
|
- void worker.exited.then(()=>{if(!live.received&&live.running&&!this.closed)void this.lost(live)})
|
|
|
|
|
- return {status:'streaming'}
|
|
|
|
|
- }
|
|
|
|
|
- throw new HttpError(409,'当前隔离 Worker 不支持此交互','computer_method_unavailable')
|
|
|
|
|
|
|
+ void worker.exited.then(()=>{if(!live.paused&&!live.received&&live.running&&!this.closed)void this.lost(live)})
|
|
|
}
|
|
}
|
|
|
installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>){this.team={id,catalog,call}}
|
|
installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>){this.team={id,catalog,call}}
|
|
|
removeTeamLease(id:string){if(this.team?.id===id)this.team=undefined}
|
|
removeTeamLease(id:string){if(this.team?.id===id)this.team=undefined}
|
|
|
private async tool(live:Live,name:string,args:unknown,id:string):Promise<unknown>{
|
|
private async tool(live:Live,name:string,args:unknown,id:string):Promise<unknown>{
|
|
|
|
|
+ if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')
|
|
|
await this.authorized();this.guard()
|
|
await this.authorized();this.guard()
|
|
|
|
|
+ if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')
|
|
|
this.event('tool.started',{tool_id:id,name})
|
|
this.event('tool.started',{tool_id:id,name})
|
|
|
try{
|
|
try{
|
|
|
const team=this.team?.catalog.find(tool=>tool.name===name)
|
|
const team=this.team?.catalog.find(tool=>tool.name===name)
|
|
@@ -218,7 +232,7 @@ export class ComputerGateway {
|
|
|
}finally{this.event('tool.completed',{tool_id:id,name})}
|
|
}finally{this.event('tool.completed',{tool_id:id,name})}
|
|
|
}
|
|
}
|
|
|
private async finish(live:Live,frame:WorkerFrame){
|
|
private async finish(live:Live,frame:WorkerFrame){
|
|
|
- if(this.closed||live.controller.signal.aborted)return
|
|
|
|
|
|
|
+ if(this.closed||live.paused||live.controller.signal.aborted)return
|
|
|
await this.authorized()
|
|
await this.authorized()
|
|
|
this.runtime.pool.renew(live.lease)
|
|
this.runtime.pool.renew(live.lease)
|
|
|
const success=frame.completed!==false&&!frame.interrupted
|
|
const success=frame.completed!==false&&!frame.interrupted
|
|
@@ -231,9 +245,44 @@ export class ComputerGateway {
|
|
|
}
|
|
}
|
|
|
private release(live:Live){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease)})();return live.releasing}
|
|
private release(live:Live){clearInterval(this.timer);live.releasing??=(async()=>{await live.proxy?.close();await this.runtime.pool.release(live.lease)})();return live.releasing}
|
|
|
private async lost(live:Live){
|
|
private async lost(live:Live){
|
|
|
- if(!live.running||this.closed||live.controller.signal.aborted)return
|
|
|
|
|
|
|
+ if(!live.running||live.paused||this.closed||live.controller.signal.aborted)return
|
|
|
live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
|
|
live.running=false;await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()
|
|
|
}
|
|
}
|
|
|
|
|
+ get active(){return !!this.live?.running&&!this.live.received&&!this.closed}
|
|
|
|
|
+ get paused(){return this.live?.paused===true}
|
|
|
|
|
+ get lease(){return this.live?.lease}
|
|
|
|
|
+ get specification(){return this.live?this.spec(this.live.session):undefined}
|
|
|
|
|
+ async takeControl(controlId:string,authorize:()=>void):Promise<ComputerLease>{
|
|
|
|
|
+ const live=this.live
|
|
|
|
|
+ if(!live||!this.active)throw new HttpError(409,'Agent 已不在执行,请重新打开电脑','computer_not_active')
|
|
|
|
|
+ if(live.pauseJob)return live.pauseJob
|
|
|
|
|
+ live.paused=true;this.event('computer.paused',{stage:'pausing'})
|
|
|
|
|
+ live.pauseJob=(async()=>{
|
|
|
|
|
+ await live.worker?.close()
|
|
|
|
|
+ await Promise.allSettled([...live.toolCalls])
|
|
|
|
|
+ authorize();this.guard()
|
|
|
|
|
+ const history=structuredClone(live.session.history),done=new Set(history.filter(message=>message.role==='tool').map(message=>message.tool_call_id))
|
|
|
|
|
+ for(const message of [...history])for(const tool of message.tool_calls??[])if(!done.has(tool.id)){
|
|
|
|
|
+ const entry=live.journal.get(tool.id),value=entry?.result??{error:'用户接管时此操作未完成,继续前请检查电脑状态,不要盲目重复'}
|
|
|
|
|
+ const content=typeof value==='string'?value:JSON.stringify(value)
|
|
|
|
|
+ history.push({role:'tool',name:tool.function?.name??entry?.name,tool_call_id:tool.id,content});done.add(tool.id)
|
|
|
|
|
+ }
|
|
|
|
|
+ live.session.history=history;this.runtime.save(live.session)
|
|
|
|
|
+ live.lease=await this.runtime.pool.transfer(live.lease,`human:${controlId}`,authorize)
|
|
|
|
|
+ this.event('computer.paused',{stage:'human',generation:live.lease.generation})
|
|
|
|
|
+ return live.lease
|
|
|
|
|
+ })()
|
|
|
|
|
+ return live.pauseJob
|
|
|
|
|
+ }
|
|
|
|
|
+ async giveBack(notes:string):Promise<void>{
|
|
|
|
|
+ const live=this.live
|
|
|
|
|
+ if(!live?.paused)throw new HttpError(409,'Agent 没有处于接管状态','computer_not_paused')
|
|
|
|
|
+ await live.pauseJob;await this.authorized()
|
|
|
|
|
+ live.lease=await this.runtime.pool.transfer(live.lease,this.workId,()=>this.guard())
|
|
|
|
|
+ live.paused=false;live.pauseJob=undefined;live.journal.clear()
|
|
|
|
|
+ this.event('computer.resumed',{generation:live.lease.generation,notes})
|
|
|
|
|
+ await this.startModel(live,`用户已完成电脑接管并明确交还控制。请先检查当前电脑状态,再继续原任务;不要重复已完成的副作用。用户说明:${notes||'无额外说明'}\n${live.workMarker??''}`,structuredClone(live.session.history))
|
|
|
|
|
+ }
|
|
|
async interrupt(){
|
|
async interrupt(){
|
|
|
const live=this.live;if(!live)return
|
|
const live=this.live;if(!live)return
|
|
|
if(live.stopping)return live.stopping
|
|
if(live.stopping)return live.stopping
|
|
@@ -245,5 +294,6 @@ export class ComputerGateway {
|
|
|
})()
|
|
})()
|
|
|
return live.stopping
|
|
return live.stopping
|
|
|
}
|
|
}
|
|
|
- close(){this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);return this.interrupt()}
|
|
|
|
|
|
|
+ async stopControl(){await this.interrupt();this.event('message.complete',{text:'电脑控制已结束,本轮已停止',status:'interrupted'})}
|
|
|
|
|
+ close(){if(this.runtime.gateways.get(this.meta.environmentId)===this)this.runtime.gateways.delete(this.meta.environmentId);this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);return this.interrupt()}
|
|
|
}
|
|
}
|