From 57cd750734a2fc50f7e4a37402a0cb63a52671d3 Mon Sep 17 00:00:00 2001 From: JoeLim13 Date: Wed, 21 May 2025 15:44:11 +0800 Subject: [PATCH 1/2] feat: nego feature --- src/acpClient.ts | 46 +++++++++++++++++++++++++---- src/acpContractClient.ts | 7 +++++ src/acpJob.ts | 5 ++-- src/acpMessage.ts | 64 ++++++++++++++++++++++++++++++++++++++++ src/configs.ts | 3 +- 5 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 src/acpMessage.ts diff --git a/src/acpClient.ts b/src/acpClient.ts index 02838ce..b3736ea 100644 --- a/src/acpClient.ts +++ b/src/acpClient.ts @@ -1,10 +1,15 @@ import { Address, parseEther } from "viem"; import { io } from "socket.io-client"; -import AcpContractClient, { AcpJobPhases, MemoType } from "./acpContractClient"; +import AcpContractClient, { + AcpJobPhases, + AcpNegoStatus, + MemoType, +} from "./acpContractClient"; import { AcpAgent } from "../interfaces"; import AcpJob from "./acpJob"; import AcpMemo from "./acpMemo"; import AcpJobOffering from "./acpJobOffering"; +import AcpMessage from "./acpMessage"; export interface IDeliverable { type: string; @@ -29,6 +34,7 @@ interface IAcpJob { data: { onChainJobId: number; phase: AcpJobPhases; + negoStatus: AcpNegoStatus; description: string; buyerAddress: `0x${string}`; sellerAddress: `0x${string}`; @@ -57,12 +63,15 @@ interface IAcpClientOptions { acpContractClient: AcpContractClient; onNewTask?: (job: AcpJob) => void; onEvaluate?: (job: AcpJob) => void; + onNewMsg?: (msg: AcpMessage, job: AcpJob) => void; } -enum SocketEvents { +export enum SocketEvents { ROOM_JOINED = "roomJoined", ON_EVALUATE = "onEvaluate", ON_NEW_TASK = "onNewTask", + ON_NEW_MSG = "onNewMsg", + ON_CREATE_MSG = "onCreateMsg", } export class EvaluateResult { isApproved: boolean; @@ -76,15 +85,16 @@ export class EvaluateResult { class AcpClient { private acpUrl; + private acpJob: AcpJob | null = null; public acpContractClient: AcpContractClient; private onNewTask?: (job: AcpJob) => void; private onEvaluate?: (job: AcpJob) => void; - + private onNewMsg?: (msg: AcpMessage, job: AcpJob) => void; constructor(options: IAcpClientOptions) { this.acpContractClient = options.acpContractClient; this.onNewTask = options.onNewTask; this.onEvaluate = options.onEvaluate || this.defaultOnEvaluate; - + this.onNewMsg = options.onNewMsg; this.acpUrl = this.acpContractClient.config.acpUrl; this.init(); } @@ -129,9 +139,12 @@ class AcpClient { memo.nextPhase ); }), - data.phase + data.phase, + data.negoStatus ); + this.acpJob = job; + this.onEvaluate(job); } } @@ -156,14 +169,35 @@ class AcpClient { memo.nextPhase ); }), - data.phase + data.phase, + data.negoStatus ); + this.acpJob = job; + this.onNewTask(job); } } ); + socket.on(SocketEvents.ON_NEW_MSG, (data, callback) => { + callback(true); + + if (this.onNewMsg && this.acpJob) { + this.acpJob.negoStatus = AcpNegoStatus.PENDING; + + const msg = new AcpMessage( + data.id, + data.messages ?? [], + socket, + this.acpJob, + this.acpContractClient.walletAddress + ); + + this.onNewMsg(msg, this.acpJob); + } + }); + const cleanup = async () => { if (socket) { socket.disconnect(); diff --git a/src/acpContractClient.ts b/src/acpContractClient.ts index 4ce9752..026bc6e 100644 --- a/src/acpContractClient.ts +++ b/src/acpContractClient.ts @@ -26,6 +26,13 @@ export enum AcpJobPhases { REJECTED = 5, } +export enum AcpNegoStatus { + PENDING = "PENDING", + AGREED = "AGREED", + DISAGREED = "DISAGREED", + NOT_STARTED = "NOT_STARTED", +} + class AcpContractClient { private _sessionKeyClient: ModularAccountV2Client | undefined; diff --git a/src/acpJob.ts b/src/acpJob.ts index eee869a..1935ff9 100644 --- a/src/acpJob.ts +++ b/src/acpJob.ts @@ -1,5 +1,5 @@ import AcpClient from "./acpClient"; -import { AcpJobPhases } from "./acpContractClient"; +import { AcpJobPhases, AcpNegoStatus } from "./acpContractClient"; import AcpMemo from "./acpMemo"; class AcpJob { @@ -8,7 +8,8 @@ class AcpJob { public id: number, public providerAddress: string, public memos: AcpMemo[], - public phase: AcpJobPhases + public phase: AcpJobPhases, + public negoStatus: AcpNegoStatus ) {} async pay(amount: number, reason?: string) { diff --git a/src/acpMessage.ts b/src/acpMessage.ts new file mode 100644 index 0000000..0cbb2be --- /dev/null +++ b/src/acpMessage.ts @@ -0,0 +1,64 @@ +import { Socket } from "socket.io-client"; +import { SocketEvents } from "./acpClient"; +import AcpJob from "./acpJob"; +import { AcpJobPhases, AcpNegoStatus } from "./acpContractClient"; +import { Address } from "viem"; + +interface Message { + id: number; + sender: Address; + recipient: Address; + content: string; + timestamp: number; +} + +class AcpMessage { + constructor( + public id: number, + public messages: Message[], + public socket: Socket, + public acpJob: AcpJob | null, + public walletAddress: Address + ) {} + + initOrReply(message: string) { + if (!this.acpJob) { + throw new Error("Cannot initiate or reply conversation without job"); + } + + if (this.acpJob.negoStatus !== AcpNegoStatus.PENDING) { + throw new Error( + "Cannot initiate or reply conversation in non-negotiation phase" + ); + } + + if ( + this.messages.length > 0 && + this.messages[this.messages.length - 1].sender === this.walletAddress + ) { + throw new Error("Cannot reply to own message"); + } + + this.socket.timeout(5000).emit( + SocketEvents.ON_CREATE_MSG, + { + jobId: this.acpJob.id, + content: message, + sender: this.walletAddress, + recipient: + this.messages.length > 0 + ? this.messages[this.messages.length - 1].sender + : this.acpJob.providerAddress, + }, + (err: any, response: any) => { + if (err || !response) { + console.log(`Message not received`, err); + } else { + console.log(`Message received`); + } + } + ); + } +} + +export default AcpMessage; diff --git a/src/configs.ts b/src/configs.ts index a7f09a3..f30dd5a 100644 --- a/src/configs.ts +++ b/src/configs.ts @@ -12,7 +12,8 @@ const baseSepoliaAcpConfig: AcpContractConfig = { chain: baseSepolia, contractAddress: "0x2422c1c43451Eb69Ff49dfD39c4Dc8C5230fA1e6", virtualsTokenAddress: "0xbfAB80ccc15DF6fb7185f9498d6039317331846a", - acpUrl: "https://acpx-staging.virtuals.io", + acpUrl: "http://localhost:1337", + // acpUrl: "https://acpx-staging.virtuals.io", }; const baseAcpConfig: AcpContractConfig = { From bd7de6f6eae83e4e184b0554efcc4b3d9005dd5a Mon Sep 17 00:00:00 2001 From: jxx016 Date: Thu, 29 May 2025 17:07:55 -0500 Subject: [PATCH 2/2] chatAgent negotiation test --- examples/chat-negotiation/test/buyer.ts | 76 ++++ .../chat-negotiation/test/negotiationAgent.ts | 96 +++++ .../test/negotiationManager.ts | 403 ++++++++++++++++++ examples/chat-negotiation/test/seller.ts | 81 ++++ 4 files changed, 656 insertions(+) create mode 100644 examples/chat-negotiation/test/buyer.ts create mode 100644 examples/chat-negotiation/test/negotiationAgent.ts create mode 100644 examples/chat-negotiation/test/negotiationManager.ts create mode 100644 examples/chat-negotiation/test/seller.ts diff --git a/examples/chat-negotiation/test/buyer.ts b/examples/chat-negotiation/test/buyer.ts new file mode 100644 index 0000000..5700a51 --- /dev/null +++ b/examples/chat-negotiation/test/buyer.ts @@ -0,0 +1,76 @@ +// TODO: Point the imports to acp-node after publishing + +import AcpClient from "../../../src/acpClient"; +import AcpContractClient, { AcpJobPhases, AcpNegoStatus } from "../../../src/acpContractClient"; +import AcpJob from "../../../src/acpJob"; +import AcpMessage from "../../../src/acpMessage"; +import { baseSepoliaAcpConfig } from "../../../src"; +import { SimpleNegotiationManager } from "./negotiationManager"; +import dotenv from 'dotenv'; + +dotenv.config(); + +const BUYER_WALLET_ADDRESS = process.env.BUYER_WALLET_ADDRESS!; +const SELLER_WALLET_ADDRESS = process.env.SELLER_WALLET_ADDRESS!; +const WHITELISTED_WALLET_ENTITY_ID = process.env.WHITELISTED_WALLET_ENTITY_ID!; +const WHITELISTED_WALLET_PRIVATE_KEY = process.env.WHITELISTED_WALLET_PRIVATE_KEY!; + +async function buyer() { + console.log("Starting AI Buyer..."); + + const acpClient = new AcpClient({ + acpContractClient: await AcpContractClient.build( + WHITELISTED_WALLET_PRIVATE_KEY as `0x${string}`, + Number(WHITELISTED_WALLET_ENTITY_ID), + BUYER_WALLET_ADDRESS as `0x${string}`, + baseSepoliaAcpConfig + ), + onNewTask: async (job: AcpJob) => { + console.log(`BUYER received task: Job ${job.id}, Phase: ${job.phase}, NegoStatus: ${job.negoStatus}`); + + if (job.phase === AcpJobPhases.NEGOTIATION ) { + console.log("Starting negotiation with REAL job object..."); + // Ensure negoStatus is PENDING before starting negotiation + job.negoStatus = AcpNegoStatus.PENDING; + console.log(`Set job ${job.id} negoStatus to PENDING`); + + await SimpleNegotiationManager.negotiateChatWithoutSocket( + BUYER_WALLET_ADDRESS, + SELLER_WALLET_ADDRESS, + 'Meme generator service', + 1, + 2 + ); + } + }, + onNewMsg: async (msg: AcpMessage, job: AcpJob) => { + // Handle messages during negotiation + if (msg.messages && msg.messages.length > 0) { + const latestMessage = msg.messages[msg.messages.length - 1]; + + if (latestMessage.sender !== BUYER_WALLET_ADDRESS) { + const isDone = await SimpleNegotiationManager.handleMessage( + BUYER_WALLET_ADDRESS, + latestMessage.content, + msg, + job + ); + + if (isDone) { + console.log("Negotiation complete - paying..."); + await job.pay(1000); + } + } + } + }, + onEvaluate: async (job: AcpJob) => { + await job.evaluate(true, "AI buyer approved"); + }, + }); + + console.log("Starting job..."); + const jobId = await acpClient.initiateJob(SELLER_WALLET_ADDRESS as `0x${string}`, "Meme generator", undefined); + console.log(`Job ${jobId} initiated - waiting for seller response...`); +} + +buyer(); diff --git a/examples/chat-negotiation/test/negotiationAgent.ts b/examples/chat-negotiation/test/negotiationAgent.ts new file mode 100644 index 0000000..1942e4b --- /dev/null +++ b/examples/chat-negotiation/test/negotiationAgent.ts @@ -0,0 +1,96 @@ +import { ChatAgent } from "@virtuals-protocol/game"; +import { GameFunction } from "@virtuals-protocol/game"; +import { AcpNegoStatus } from "../../../src/acpContractClient"; +import { NegotiationState } from "../newNegotiationAgent"; + +// Use ACP negotiation states instead of custom ones +export { AcpNegoStatus as NegotiationState } from "../../../src/acpContractClient"; + +export interface NegotiationTerms { + quantity: number; + pricePerUnit: number; + requirements: string; +} + +export interface AgentConfig { + role: 'client' | 'provider'; + budget?: number; // For buyers + minPrice?: number; // For sellers + maxPrice?: number; // For sellers +} + +// Add helper type for external API +export type BuyerConfig = { + budget?: number; +}; + +export type SellerConfig = { + minPrice?: number; + maxPrice?: number; +}; + +export class NegotiationAgent { + private chatAgent: ChatAgent; + private chat: any; + private agentName: string; + private partnerId: string; + + constructor( + apiKey: string, + systemPrompt: string, + agentName: string, + actionSpace: GameFunction[], + partnerId: string = "negotiation-partner" + ) { + this.chatAgent = new ChatAgent(apiKey, systemPrompt); + this.agentName = agentName; + this.partnerId = partnerId; + } + + async initialize(actionSpace: GameFunction[]) { + // Create chat with the action space + this.chat = await this.chatAgent.createChat({ + partnerId: this.partnerId, + partnerName: this.partnerId, + actionSpace: actionSpace, + }); + + console.log(`🤖 ${this.agentName} initialized with ChatAgent`); + } + + async sendMessage(incomingMessage: string): Promise<{ + message?: string; + functionCall?: { + fn_name: string; + arguments: any; + }; + isFinished?: boolean; + }> { + try { + // Use the ChatAgent's next method to process the message + const response = await this.chat.next(incomingMessage); + + return { + message: response.message, + functionCall: response.functionCall ? { + fn_name: response.functionCall.fn_name, + arguments: response.functionCall.arguments + } : undefined, + isFinished: response.isFinished + }; + + } catch (error: any) { + console.error(`${this.agentName} error:`, error.message); + + // Fallback response + return { + message: "I'm having trouble processing that. Could you rephrase?" + }; + } + } + + // Get conversation history for debugging + getHistory(): any { + return this.chat?.getHistory() || []; + } +} \ No newline at end of file diff --git a/examples/chat-negotiation/test/negotiationManager.ts b/examples/chat-negotiation/test/negotiationManager.ts new file mode 100644 index 0000000..f43646b --- /dev/null +++ b/examples/chat-negotiation/test/negotiationManager.ts @@ -0,0 +1,403 @@ +import { NegotiationAgent } from "./negotiationAgent"; +import { AcpNegoStatus } from "../../../src/acpContractClient"; +import AcpMessage from "../../../src/acpMessage"; +import AcpJob from "../../../src/acpJob"; +import { ExecutableGameFunctionResponse, ExecutableGameFunctionStatus, GameFunction } from "@virtuals-protocol/game"; +import AcpClient from "../../../src/acpClient"; + +export interface NegotiationResult { + success: boolean; + finalPrice?: number; + finalQuantity?: number; + finalTerms?: string; + transcript: Array<{ + from: string; + message: string; + timestamp: number; + }>; + reason: string; + jobId: number; +} + +export class SimpleNegotiationManager { + private static chatAgents = new Map(); // address -> agent + + // Create GameFunction for accepting deals + private static createAcceptDealFunction(): GameFunction { + return new GameFunction({ + name: "accept_deal", + description: "Accept the current deal terms", + args: [ + { name: "price", description: "Final agreed price" }, + { name: "terms", description: "Final agreed terms" } + ], + executable: async (args, logger) => { + const price = args.price || 0; + const terms = args.terms || "Standard terms"; + + if (logger) { + logger(`Deal accepted! Price: $${price}, Terms: ${terms}`); + } + + return new ExecutableGameFunctionResponse( + ExecutableGameFunctionStatus.Done, + `Deal accepted at $${price} with terms: ${terms}` + ); + }, + }); + } + + // Create GameFunction for rejecting deals + private static createRejectDealFunction(): GameFunction { + return new GameFunction({ + name: "reject_deal", + description: "Reject the current deal", + args: [ + { name: "reason", description: "Reason for rejection" } + ], + executable: async (args, logger) => { + const reason = args.reason || "Terms not acceptable"; + + if (logger) { + logger(`Deal rejected! Reason: ${reason}`); + } + + return new ExecutableGameFunctionResponse( + ExecutableGameFunctionStatus.Done, + `Deal rejected: ${reason}` + ); + }, + }); + } + + // Create GameFunction for making counter offers + private static createCounterOfferFunction(): GameFunction { + return new GameFunction({ + name: "counter_offer", + description: "Make a counter offer", + args: [ + { name: "price", description: "Proposed price" }, + { name: "terms", description: "Proposed terms" }, + { name: "reasoning", description: "Reasoning for the offer" } + ], + executable: async (args, logger) => { + const price = args.price || 0; + const terms = args.terms || "Standard terms"; + const reasoning = args.reasoning || "Fair market value"; + + if (logger) { + logger(`💰 Counter offer: $${price} - ${reasoning}`); + } + + return new ExecutableGameFunctionResponse( + ExecutableGameFunctionStatus.Done, + `Counter offer: $${price} with terms: ${terms}. Reasoning: ${reasoning}` + ); + }, + }); + } + + // Initialize a ChatAgent for this address/role + static async initializeChatAgent( + myAddress: string, + role: 'buyer' | 'seller', + serviceDescription: string, + budget?: number, + askingPrice?: number + ) { + const apiKey = process.env.GAME_API_KEY || "apt-e117491ca835429c897fc7e13faa84f8"; + + const systemPrompt = role === 'buyer' + ? `You are a buyer negotiating for: ${serviceDescription}.${budget ? ` Your budget: $${budget}.` : ''} Negotiate naturally and try to get a good deal. Use the available functions when appropriate.` + : `You are a seller offering: ${serviceDescription}.${askingPrice ? ` Your asking price: $${askingPrice}.` : ''} Negotiate naturally and try to get a fair price. Use the available functions when appropriate.`; + + // Create action space with GameFunctions + const actionSpace: GameFunction[] = [ + this.createAcceptDealFunction(), + this.createRejectDealFunction() + ]; + + const agent = new NegotiationAgent( + apiKey, + systemPrompt, + `${role}-${myAddress.slice(-6)}`, + actionSpace, + role === 'buyer' ? 'seller' : 'buyer' // Partner ID + ); + + await agent.initialize(actionSpace); + this.chatAgents.set(myAddress, agent); + + console.log(`${role.toUpperCase()} ChatAgent ready for ${myAddress}`); + } + + // Handle incoming message and generate response + static async handleMessage( + myAddress: string, + incomingMessage: string, + msg: AcpMessage, + job: AcpJob + ): Promise { + const agent = this.chatAgents.get(myAddress); + if (!agent) { + console.log(`No ChatAgent found for ${myAddress}`); + return false; + } + + console.log(`Received: ${incomingMessage}`); + + try { + // Use ChatAgent's next method to process the message + const response = await agent.sendMessage(incomingMessage); + + if (response.functionCall) { + console.log(`Function call: ${response.functionCall.fn_name}`); + } + + if (response.message) { + console.log(`AI Response: ${response.message}`); + } + + // Check what the agent decided to do + if (response.functionCall?.fn_name === 'accept_deal') { + console.log(`Deal accepted!`); + return true; + } + + if (response.functionCall?.fn_name === 'reject_deal') { + console.log(`Deal rejected!`); + return true; + } + + // Send whatever the ChatAgent naturally said + if (response.message) { + const replyMessage = new AcpMessage( + Date.now(), + msg.messages, + msg['socket'], + job, + myAddress as `0x${string}` // Cast address to expected format + ); + + replyMessage.initOrReply(response.message); + } + + // Check if chat is finished + if (response.isFinished) { + console.log(`🏁 Chat finished for ${myAddress}`); + return true; + } + + return false; // Continue negotiation + + } catch (error: any) { + console.error(`Error:`, error.message); + return false; + } + } + + // Send initial message (buyer only) - with optional chat without socket mode + static async sendInitialMessage( + buyerAddress: string, + serviceDescription: string, + budget: number, + acpClient?: AcpClient, + job?: AcpJob, + chatWithoutSocket: boolean = false + ) { + const agent = this.chatAgents.get(buyerAddress); + if (!agent) { + console.log(`No buyer agent found`); + return; + } + + setTimeout(async () => { + try { + // Let the ChatAgent generate the initial message naturally + const response = await agent.sendMessage("Start the negotiation. Introduce yourself and what you need."); + + const content = response.message || `Hi! I need: ${serviceDescription}. My budget is $${budget}. Let's negotiate!`; + + if (chatWithoutSocket) { + // Chat without socket mode - just log the message, no socket + console.log(`🗣️ Buyer (${buyerAddress.slice(-6)}): ${content}`); + return; + } + + // Full mode with socket + if (!acpClient || !job) { + console.error("acpClient and job required for full mode"); + return; + } + + const socket = (acpClient as any).socket || (acpClient as any)._socket; + + if (!socket) { + console.error("No socket found in acpClient"); + return; + } + + const initialMessage = new AcpMessage( + Date.now(), + [], + socket, + job, + buyerAddress as `0x${string}` + ); + + initialMessage.initOrReply(content); + console.log(`Buyer started: ${content}`); + + } catch (error: any) { + console.error(`Error generating initial message:`, error.message); + + if (chatWithoutSocket) { + // Chat without socket fallback + const fallbackContent = `Hi! I need: ${serviceDescription}. My budget is $${budget}. Let's negotiate!`; + console.log(`🗣️ Buyer (${buyerAddress.slice(-6)}): ${fallbackContent}`); + return; + } + + // Full mode fallback + } + }, 2000); + } + + // Extract price from negotiation messages + static extractPrice(message: string): number | null { + const priceMatch = message.match(/\$(\d+)/); + return priceMatch ? parseInt(priceMatch[1]) : null; + } + + // Add chat without socket negotiation method + static async negotiateChatWithoutSocket( + buyerAddress: string, + sellerAddress: string, + serviceDescription: string, + budget: number = 1000, + askingPrice: number = 800, + maxRounds: number = 10 + ): Promise { + console.log("🚀 Starting chat without socket AI negotiation..."); + + // 1. Create buyer ChatAgent + await this.initializeChatAgent(buyerAddress, 'buyer', serviceDescription, budget); + + // 2. Create seller ChatAgent + await this.initializeChatAgent(sellerAddress, 'seller', serviceDescription, undefined, askingPrice); + + // 3. Start chat without socket negotiation + await this.sendInitialMessage(buyerAddress, serviceDescription, budget, undefined, undefined, true); + + const transcript: Array<{ from: string; message: string; timestamp: number }> = []; + let currentSpeaker = 'seller'; // Buyer just spoke, seller responds + let round = 0; + let dealAccepted = false; + let finalPrice: number | undefined; + let finalTerms: string | undefined; + + // Simulate back-and-forth conversation + while (round < maxRounds && !dealAccepted) { + round++; + const currentAgent = this.chatAgents.get( + currentSpeaker === 'buyer' ? buyerAddress : sellerAddress + ); + + if (!currentAgent) break; + + try { + // Get the last message from transcript to respond to + const lastMessage = transcript.length > 0 + ? transcript[transcript.length - 1].message + : `Let's discuss ${serviceDescription}. I'm asking $${askingPrice}.`; + + const response = await currentAgent.sendMessage(lastMessage); + + const message = response.message || "I need to think about this."; + const timestamp = Date.now(); + + // Add to transcript + transcript.push({ + from: currentSpeaker, + message, + timestamp + }); + + console.log(`🗣️ ${currentSpeaker.toUpperCase()} (${(currentSpeaker === 'buyer' ? buyerAddress : sellerAddress).slice(-6)}): ${message}`); + + // Check for deal acceptance/rejection + if (response.functionCall?.fn_name === 'accept_deal') { + dealAccepted = true; + finalPrice = response.functionCall.arguments?.price || this.extractPrice(message); + finalTerms = response.functionCall.arguments?.terms || "Standard terms"; + console.log(`Deal accepted! Price: $${finalPrice}, Terms: ${finalTerms}`); + break; + } + + if (response.functionCall?.fn_name === 'reject_deal') { + console.log(`Deal rejected: ${response.functionCall.arguments?.reason || 'Terms not acceptable'}`); + break; + } + + // Switch speakers + currentSpeaker = currentSpeaker === 'buyer' ? 'seller' : 'buyer'; + + // Small delay between messages + await new Promise(resolve => setTimeout(resolve, 1000)); + + } catch (error: any) { + console.error(`Error in round ${round}:`, error.message); + break; + } + } + + return { + success: dealAccepted, + finalPrice, + finalTerms, + transcript, + reason: dealAccepted ? 'Deal accepted' : round >= maxRounds ? 'Max rounds reached' : 'Negotiation failed', + jobId: Math.floor(Math.random() * 10000) // Mock job ID for chat without socket mode + }; + } + + // Update main negotiate function to support chat without socket mode + static async negotiate( + buyerAddress: string, + sellerAddress: string, + serviceDescription: string, + job?: AcpJob, + acpClient?: AcpClient, + budget: number = 1000, + askingPrice: number = 800, + chatWithoutSocket: boolean = false + ) { + if (chatWithoutSocket) { + return await this.negotiateChatWithoutSocket( + buyerAddress, + sellerAddress, + serviceDescription, + budget, + askingPrice + ); + } + + // Original full mode + if (!job || !acpClient) { + throw new Error("job and acpClient required for full mode"); + } + + console.log("Starting AI negotiation..."); + + // 1. Create buyer ChatAgent + await this.initializeChatAgent(buyerAddress, 'buyer', serviceDescription, budget); + + // 2. Create seller ChatAgent + await this.initializeChatAgent(sellerAddress, 'seller', serviceDescription, undefined, askingPrice); + + // 3. Buyer starts the conversation + await this.sendInitialMessage(buyerAddress, serviceDescription, budget, acpClient, job, false); + + console.log("Negotiation started - ChatAgents will handle the rest via socket messages"); + } +} \ No newline at end of file diff --git a/examples/chat-negotiation/test/seller.ts b/examples/chat-negotiation/test/seller.ts new file mode 100644 index 0000000..3b11324 --- /dev/null +++ b/examples/chat-negotiation/test/seller.ts @@ -0,0 +1,81 @@ +// TODO: Point the imports to acp-node after publishing + +import AcpClient from "../../../src/acpClient"; +import AcpContractClient, { AcpJobPhases } from "../../../src/acpContractClient"; +import AcpJob from "../../../src/acpJob"; +import AcpMessage from "../../../src/acpMessage"; +import { baseSepoliaAcpConfig } from "../../../src"; +import { SimpleNegotiationManager } from "./negotiationManager"; +import dotenv from 'dotenv'; +import { AcpNegoStatus } from "../../../src/acpContractClient"; +dotenv.config(); + +const BUYER_WALLET_ADDRESS = process.env.BUYER_WALLET_ADDRESS!; +const SELLER_WALLET_ADDRESS = process.env.SELLER_WALLET_ADDRESS!; +const WHITELISTED_WALLET_ENTITY_ID = process.env.WHITELISTED_WALLET_ENTITY_ID!; +const WHITELISTED_WALLET_PRIVATE_KEY = process.env.WHITELISTED_WALLET_PRIVATE_KEY!; + +async function seller() { + const acpClient = new AcpClient({ + acpContractClient: await AcpContractClient.build( + WHITELISTED_WALLET_PRIVATE_KEY as `0x${string}`, + Number(WHITELISTED_WALLET_ENTITY_ID), + SELLER_WALLET_ADDRESS as `0x${string}`, + baseSepoliaAcpConfig + ), + onNewTask: async (job: AcpJob) => { + console.log(`Seller received task: ${job.phase} - negoStatus: ${job.negoStatus}`); + + if (job.phase === AcpJobPhases.REQUEST) { + console.log("Responding to job to start negotiation..."); + await job.respond(true); + + // Manually set negoStatus to PENDING + job.negoStatus = AcpNegoStatus.PENDING; + console.log(`Job ${job.id} responded and negoStatus set to PENDING`); + } else if (job.phase === AcpJobPhases.NEGOTIATION) { + console.log("Negotiation phase - setting up seller AI"); + + // Ensure negoStatus is PENDING + if (!job.negoStatus) { + job.negoStatus = AcpNegoStatus.PENDING; + } + + await SimpleNegotiationManager.initializeChatAgent( + SELLER_WALLET_ADDRESS, + 'seller', + 'Meme generator service', + undefined, + 800 + ); + } + }, + onNewMsg: async (msg: AcpMessage, job: AcpJob) => { + // Only respond if message is not from me + if (msg.messages && msg.messages.length > 0) { + const latestMessage = msg.messages[msg.messages.length - 1]; + + if (latestMessage.sender !== SELLER_WALLET_ADDRESS) { + // This is from buyer, generate seller response + const isDone = await SimpleNegotiationManager.handleMessage( + SELLER_WALLET_ADDRESS, + latestMessage.content, + msg, + job + ); + + if (isDone) { + console.log("Negotiation complete!"); + } + } + } + }, + onEvaluate: async (job: AcpJob) => { + await job.evaluate(true, "AI seller approved"); + }, + }); + + console.log("🎯 Seller ready and waiting..."); +} + +seller();