UNPKG

@humanlayer/sdk

Version:

typescript client for humanlayer.dev

350 lines (312 loc) 10.4 kB
import crypto from 'crypto' import { AgentBackend, HumanLayerException } from './protocol' import { ContactChannel, Escalation, FunctionCall, FunctionCallSpec, FunctionCallStatus, HumanContact, HumanContactSpec, HumanContactStatus, } from './models' import { CloudHumanLayerBackend, HumanLayerCloudConnection } from './cloud' import { logger } from './logger' export enum ApprovalMethod { cli = 'cli', backend = 'backend', } const nullIsh = (value: any) => value === null || typeof value === 'undefined' /** * sure this'll work for now */ const defaultGenid = (prefix: string) => { return `${prefix}-${crypto.randomUUID().slice(0, 8)}` } export const humanlayer = (params?: HumanLayerParams) => { return new HumanLayer(params) } export interface HumanLayerParams { runId?: string approvalMethod?: ApprovalMethod backend?: AgentBackend agentName?: string genid?: (prefix: string) => string sleep?: (ms: number) => Promise<void> contactChannel?: ContactChannel apiKey?: string apiBaseUrl?: string verbose?: boolean httpTimeoutSeconds?: number } export class HumanLayer { approvalMethod: ApprovalMethod backend?: AgentBackend runId: string agentName: string genid: (prefix: string) => string sleep: (ms: number) => Promise<void> contactChannel?: ContactChannel verbose?: boolean constructor(params?: HumanLayerParams) { const { runId, approvalMethod, backend, agentName, genid = defaultGenid, sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)), contactChannel, apiKey, apiBaseUrl, verbose = false, httpTimeoutSeconds = parseInt(process.env.HUMANLAYER_HTTP_TIMEOUT_SECONDS || '10'), } = params || {} this.genid = genid this.sleep = sleep this.verbose = verbose this.contactChannel = contactChannel // Simplified approval method logic this.approvalMethod = approvalMethod || (backend || apiKey || process.env.HUMANLAYER_API_KEY ? ApprovalMethod.backend : ApprovalMethod.cli) // Initialize backend if approval method is backend if (this.approvalMethod === ApprovalMethod.backend) { this.backend = backend || new CloudHumanLayerBackend( new HumanLayerCloudConnection( apiKey || process.env.HUMANLAYER_API_KEY, apiBaseUrl || process.env.HUMANLAYER_API_BASE, ), ) } this.agentName = agentName || 'agent' this.genid = genid || defaultGenid this.runId = runId || this.genid(this.agentName) } static cloud(params?: { connection?: HumanLayerCloudConnection apiKey?: string apiBaseUrl?: string }): HumanLayer { let { connection, apiKey, apiBaseUrl } = params || {} if (!connection) { connection = new HumanLayerCloudConnection(apiKey, apiBaseUrl) } return new HumanLayer({ approvalMethod: ApprovalMethod.backend, backend: new CloudHumanLayerBackend(connection), }) } static cli(): HumanLayer { return new HumanLayer({ approvalMethod: ApprovalMethod.cli, }) } requireApproval<TFn extends Function>(contactChannel?: ContactChannel): (fn: TFn) => TFn { return (fn: TFn) => { if (this.approvalMethod === ApprovalMethod.cli) { return this.approveCli(fn) } return this.approveWithBackend(fn, contactChannel) } } approveCli<TFn extends Function>(fn: TFn): TFn { const name = fn.name // todo fix the types here const f: any = async (kwargs: any) => { logger.info(`Agent ${this.runId} wants to call ${fn.name}(${JSON.stringify(kwargs, null, 2)}) ${kwargs.length ? ' with args: ' + JSON.stringify(kwargs, null, 2) : ''}`) const readline = require('readline').createInterface({ input: process.stdin, output: process.stdout, }) const feedback = await new Promise(resolve => { readline.question( 'Hit ENTER to proceed, or provide feedback to the agent to deny: \n\n', (answer: string) => { readline.close() resolve(answer) }, ) }) if (feedback !== null && feedback !== '') { return `User denied ${fn.name} with feedback: ${feedback}` } try { return await fn(kwargs) } catch (e) { return `Error running ${fn.name}: ${e}` } } Object.defineProperty(f, 'name', { value: name, writable: false }) return f } approveWithBackend<TFn extends Function>(fn: TFn, contactChannel?: ContactChannel): TFn { const channel = contactChannel || this.contactChannel const name = fn.name // todo fix the types here const f: any = async (kwargs: any) => { const backend = this.backend! const callId = this.genid('call') await backend.functions().add({ run_id: this.runId, call_id: callId, spec: { fn: fn.name, kwargs: kwargs, channel: channel, }, }) if (this.verbose) { logger.info(`HumanLayer: Requested approval for function ${name}`) } while (true) { await this.sleep(3000) const functionCall = await backend.functions().get(callId) if ( functionCall.status?.approved === null || typeof functionCall.status?.approved === 'undefined' ) { continue } if (functionCall.status?.approved) { if (this.verbose) { logger.info(`HumanLayer: User approved function ${functionCall.spec.fn}`) } return fn(kwargs) } else { return `User denied function ${functionCall.spec.fn} with comment: ${functionCall.status?.comment}` } } } Object.defineProperty(f, 'name', { value: name, writable: false }) return f } humanAsTool(contactChannel?: ContactChannel): ({ message }: { message: string }) => Promise<string> { if (this.approvalMethod === ApprovalMethod.cli) { return this.humanAsToolCli() } return this.humanAsToolBackend(contactChannel) } humanAsToolCli(): ({ message }: { message: string }) => Promise<string> { return async ({ message }: { message: string }) => { logger.info(`Agent ${this.runId} requests assistance: ${message} `) const feedback = prompt('Please enter a response: \n\n') return feedback || '' } } humanAsToolBackend( contactChannel?: ContactChannel, ): ({ message }: { message: string }) => Promise<string> { const channel = contactChannel || this.contactChannel return async ({ message }: { message: string }) => { return this.fetchHumanResponse({ spec: { msg: message, channel: channel, }, }) } } async fetchHumanResponse({ spec }: { spec: HumanContactSpec }): Promise<string> { spec.channel = nullIsh(spec.channel) ? this.contactChannel : spec.channel let humanContact = await this.createHumanContact({ spec }) if (this.verbose) { logger.info(`HumanLayer: Requested human response from HumanLayer cloud`) } while (true) { await this.sleep(3000) humanContact = await this.getHumanContact(humanContact.call_id) if (!humanContact.status?.response) { continue } if (this.verbose) { logger.info(`HumanLayer: Received human response: ${humanContact.status.response}`) } return humanContact.status.response } } async createHumanContact({ spec }: { spec: HumanContactSpec }): Promise<HumanContact> { spec.channel = nullIsh(spec.channel) ? this.contactChannel : spec.channel if (!this.backend) { throw new HumanLayerException('createHumanContact requires a backend') } const callId = this.genid('human_call') const ret = await this.backend.contacts().add({ run_id: this.runId, call_id: callId, spec: spec, }) return ret } async escalateEmailHumanContact(call_id: string, escalation: Escalation): Promise<HumanContact> { if (!this.backend) { throw new HumanLayerException('escalateEmailFunctionCall requires a backend') } return this.backend.contacts().escalateEmail(call_id, escalation) } getHumanContact(call_id: string): Promise<HumanContact> { if (!this.backend) { throw new HumanLayerException('getHumanContact requires a backend') } return this.backend.contacts().get(call_id) } async fetchHumanApproval({ spec }: { spec: FunctionCallSpec }): Promise<FunctionCallStatus> { spec.channel = nullIsh(spec.channel) ? this.contactChannel : spec.channel let functionCall = await this.createFunctionCall({ spec: spec, }) if (this.verbose) { logger.info(`HumanLayer: Requested human approval from HumanLayer cloud`) } while (true) { await this.sleep(3000) functionCall = await this.getFunctionCall(functionCall.call_id) if ( functionCall.status?.approved === null || typeof functionCall.status?.approved === 'undefined' ) { continue } if (this.verbose) { logger.info( `HumanLayer: Received response ${ functionCall.status?.approved ? ' (approved)' : ' (denied)' } ${functionCall.status?.comment ? `with comment: ${functionCall.status?.comment}` : ''}`, ) } return functionCall.status! } } async createFunctionCall({ spec }: { spec: FunctionCallSpec }): Promise<FunctionCall> { spec.channel = nullIsh(spec.channel) ? this.contactChannel : spec.channel if (!this.backend) { throw new HumanLayerException('createFunctionCall requires a backend') } const callId = this.genid('call') return this.backend.functions().add({ run_id: this.runId, call_id: callId, spec: spec, }) } async escalateEmailFunctionCall(call_id: string, escalation: Escalation): Promise<FunctionCall> { if (!this.backend) { throw new HumanLayerException('escalateEmailFunctionCall requires a backend') } return this.backend.functions().escalateEmail(call_id, escalation) } async getFunctionCall(call_id: string): Promise<FunctionCall> { if (!this.backend) { throw new HumanLayerException('getFunctionCall requires a backend') } return this.backend.functions().get(call_id) } }