-
Notifications
You must be signed in to change notification settings - Fork 12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sweep: 玩家作为一个非 Agent 角色,本身也可以与 Agent 进行互动,譬如和 Agent 进行对话,Agent 会根据自己的设定对玩家进行回复我想知道这部分代码在哪里实现的 #10
Comments
Here's the PR! #11.⚡ Sweep Basic Tier: I'm using GPT-4. You have 5 GPT-4 tickets left for the month and 3 for the day. (tracking ID:
39797902a5 )For more GPT-4 tickets, visit our payment portal. For a one week free trial, try Sweep Pro (unlimited GPT-4 tickets). Actions (click)
Sandbox Execution ✓Here are the sandbox execution logs prior to making any changes: Sandbox logs for
|
}); | |
function divideIntoGroups(players: Player[]) { | |
const playerById = new Map(players.map((p) => [p.id, p])); | |
const groups: Player[][] = []; | |
const solos: Player[] = []; | |
while (playerById.size > 0) { | |
const player = playerById.values().next().value; | |
playerById.delete(player.id); | |
const nearbyPlayers = getNearbyPlayers(player.motion, [...playerById.values()]); | |
if (nearbyPlayers.length > 0) { | |
// If you only want to do 1:1 conversations, use this: | |
// groups.push([player, nearbyPlayers[0]]); | |
// playerById.delete(nearbyPlayers[0].id); | |
// otherwise, do more than 1:1 conversations by adding them all: | |
groups.push([player, ...nearbyPlayers]); | |
for (const nearbyPlayer of nearbyPlayers) { | |
playerById.delete(nearbyPlayer.id); | |
} | |
} else { | |
solos.push(player); | |
} | |
} | |
return { groups, solos }; | |
} | |
async function handleAgentSolo(ctx: ActionCtx, player: Player, memory: MemoryDB, done: DoneFn) { | |
// console.debug('handleAgentSolo: ', player.name, player.id); | |
// Handle new observations: it can look at the agent's lastWakeTs for a delta. | |
// Calculate scores | |
// Run reflection on memories once in a while | |
await memory.reflectOnMemories(player.id, player.name); | |
// Future: Store observations about seeing players in conversation | |
// might include new observations -> add to memory with openai embeddings | |
// Later: handle object ownership? | |
// Based on plan and observations, determine next action: | |
// if so, add new memory for new plan, and return new action | |
const walk = player.motion.type === 'stopped' || player.motion.targetEndTs < Date.now(); | |
// Ignore everyone we last said something to. | |
const ignore = | |
player.motion.type === 'walking' ? player.motion.ignore : player.lastChat?.message.to ?? []; | |
await done(player.agentId, { type: walk ? 'walk' : 'continue', ignore }); | |
} | |
export async function handleAgentInteraction( | |
ctx: ActionCtx, | |
players: Player[], | |
memory: MemoryDB, | |
done: DoneFn, | |
) { | |
// TODO: pick a better conversation starter | |
const leader = players[0]; | |
for (const player of players) { | |
const imWalkingHere = | |
player.motion.type === 'walking' && player.motion.targetEndTs > Date.now(); | |
// Get players to walk together and face each other | |
if (player.agentId) { | |
if (player === leader) { | |
if (imWalkingHere) { | |
await ctx.runMutation(internal.journal.stop, { | |
playerId: player.id, | |
}); | |
} | |
} else { | |
await ctx.runMutation(internal.journal.walk, { | |
agentId: player.agentId, | |
target: leader.id, | |
ignore: players.map((p) => p.id), | |
}); |
Lines 1 to 28 in d76e3ca
// How close a player can be to have a conversation. | |
export const NEARBY_DISTANCE = 3; | |
// Close enough to stop and observe something. | |
export const CLOSE_DISTANCE = 1; | |
// How long it takes a player to walk one tile. | |
export const TIME_PER_STEP = 2_000; | |
// After this many ms, give up on the agent and start thinking again. | |
export const AGENT_THINKING_TOO_LONG = 600_000; | |
// How long to hang out if there was no path to your destination. | |
export const STUCK_CHILL_TIME = 30_000; | |
// How long to let a conversation go on for with agents | |
export const CONVERSATION_TIME_LIMIT = 20_000; | |
// If you don't set a start position, you'll start at 0,0. | |
export const DEFAULT_START_POSE = { position: { x: 0, y: 0 }, orientation: 0 }; | |
// How often to send up heartbeats | |
export const HEARTBEAT_PERIOD = 30_000; // In ms | |
// How long to wait after heartbeats before considering a world idle. | |
export const WORLD_IDLE_THRESHOLD = 300_000; // In ms | |
// How long to wait before update a memory's last access time. | |
export const MEMORY_ACCESS_THROTTLE = 300_000; // In ms | |
// We round tick times to debounce events. They'll get rounded up to the nearest multiple of this. | |
export const TICK_DEBOUNCE = 10; // In ms | |
// This is a balance of how busy to make the DB at once. | |
export const VACUUM_BATCH_SIZE = 50; | |
const MINUTE = 60_000; | |
const HOUR = 60 * MINUTE; | |
const DAY = 24 * HOUR; | |
export const VACUUM_JOURNAL_AGE = 7 * DAY; |
Lines 1 to 92 in d76e3ca
import { cronJobs } from 'convex/server'; | |
import { internalMutation } from './_generated/server'; | |
import { getLatestPlayerMotion } from './journal'; | |
import { | |
AGENT_THINKING_TOO_LONG, | |
VACUUM_BATCH_SIZE, | |
VACUUM_JOURNAL_AGE, | |
VACUUM_MEMORIES_AGE, | |
} from './config'; | |
import { enqueueAgentWake } from './engine'; | |
import { internal } from './_generated/api'; | |
import { TableNames } from './_generated/dataModel'; | |
export const recoverThinkingAgents = internalMutation({ | |
args: {}, | |
handler: async (ctx, args) => { | |
const world = await ctx.db.query('worlds').order('desc').first(); | |
if (!world) throw new Error('No world found'); | |
// Future: we can check all players, but for now just the most recent world. | |
const ts = Date.now(); | |
const agentDocs = await ctx.db | |
.query('agents') | |
.withIndex('by_worldId_thinking', (q) => q.eq('worldId', world._id).eq('thinking', true)) | |
.filter((q) => q.lt(q.field('lastWakeTs'), Date.now() - AGENT_THINKING_TOO_LONG)) | |
.collect(); | |
if (agentDocs.length !== 0) { | |
// We can just enqueue one, since they're all at the same time. | |
const scheduled = await enqueueAgentWake(ctx, agentDocs[0]._id, world._id, ts); | |
for (const agentDoc of agentDocs) { | |
console.error(`Agent ${agentDoc._id} was thinking too long. Resetting`); | |
await ctx.db.patch(agentDoc._id, { | |
thinking: false, | |
nextWakeTs: ts, | |
scheduled, | |
}); | |
} | |
} | |
}, | |
}); | |
// Allow 1s for a character to start moving after ending a walk. | |
const BUFFER = 1_000; | |
export const recoverStoppedAgents = internalMutation({ | |
args: {}, | |
handler: async (ctx, args) => { | |
const world = await ctx.db.query('worlds').order('desc').first(); | |
if (!world) throw new Error('No world found'); | |
if (world.frozen) { | |
console.debug("Didn't tick: world frozen"); | |
return; | |
} | |
// Future: we can check all players, but for now just the most recent world. | |
const agentDocs = await ctx.db | |
.query('agents') | |
.withIndex('by_worldId_thinking', (q) => q.eq('worldId', world._id).eq('thinking', false)) | |
.collect(); | |
for (const agentDoc of agentDocs) { | |
const motion = await getLatestPlayerMotion(ctx.db, agentDoc.playerId); | |
if (motion.type === 'walking' && motion.targetEndTs < Date.now() - BUFFER) { | |
console.error("We found a stationary agent that's not thinking. Tick time"); | |
await enqueueAgentWake(ctx, agentDoc._id, world._id, Date.now()); | |
return; | |
} | |
} | |
}, | |
}); | |
export const vacuumOldEntries = internalMutation({ | |
handler: async ( | |
ctx, | |
{ | |
table, | |
age, | |
...args | |
}: { | |
table: TableNames; | |
untilTs?: number; | |
age: number; | |
cursor: null | string; | |
soFar: number; | |
}, | |
) => { | |
const untilTs = args.untilTs ?? Date.now() - age; | |
const results = await ctx.db | |
.query(table) | |
.withIndex('by_creation_time', (q) => q.lt('_creationTime', untilTs)) | |
.paginate({ cursor: args.cursor, numItems: VACUUM_BATCH_SIZE }); | |
for (const doc of results.page) { | |
await ctx.db.delete(doc._id); | |
} | |
if (results.isDone) { | |
console.debug(`Vacuumed ${results.page.length} old ${table} entries.`); |
cat-town/convex/conversation.ts
Lines 1 to 72 in d76e3ca
import { Id } from './_generated/dataModel'; | |
import { ActionCtx } from './_generated/server'; | |
import { fetchEmbeddingWithCache } from './lib/cached_llm'; | |
import { MemoryDB, filterMemoriesType } from './lib/memory'; | |
import { LLMMessage, chatCompletion, fetchEmbedding } from './lib/openai'; | |
import { Message } from './schema'; | |
type Player = { id: Id<'players'>; name: string; identity: string }; | |
type Relation = Player & { relationship: string }; | |
export async function startConversation( | |
ctx: ActionCtx, | |
audience: Player[], | |
memory: MemoryDB, | |
player: Player, | |
) { | |
const newFriendsNames = audience.map((p) => p.name); | |
const { embedding } = await fetchEmbeddingWithCache( | |
ctx, | |
`What do you think about ${newFriendsNames.join(',')}?`, | |
{ write: true }, | |
); | |
const memories = await memory.accessMemories(player.id, embedding); | |
const convoMemories = filterMemoriesType(['conversation'], memories); | |
const animal = player.name === 'Tilly' ? 'dog' : 'cat'; | |
const prompt: LLMMessage[] = [ | |
{ | |
role: 'user', | |
content: `You are a ${animal} whose name is ${player.name}. Talk as if you are a ${animal}. Reference information provided to you in the conversation`, | |
}, | |
{ | |
role: 'user', | |
content: | |
`You just saw ${newFriendsNames}. You should greet them and start a conversation with them. Below are some of your memories about ${newFriendsNames}:` + | |
//relationships.map((r) => r.relationship).join('\n') + | |
convoMemories.map((r) => r.memory.description).join('\n') + | |
`\n${player.name}:`, | |
}, | |
]; | |
const stop = newFriendsNames.map((name) => name + ':'); | |
const { content } = await chatCompletion({ messages: prompt, max_tokens: 300, stop }); | |
return { content, memoryIds: memories.map((m) => m.memory._id) }; | |
} | |
function messageContent(m: Message): string { | |
switch (m.type) { | |
case 'started': | |
return `${m.fromName} started the conversation.`; | |
case 'left': | |
return `${m.fromName} left the conversation.`; | |
case 'responded': | |
return `${m.fromName} to ${m.toNames.join(',')}: ${m.content}\n`; | |
} | |
} | |
export function chatHistoryFromMessages(messages: Message[]): LLMMessage[] { | |
return ( | |
messages | |
// For now, just use the message content. | |
// However, we could give it context on who started / left the convo | |
.filter((m) => m.type === 'responded') | |
.map((m) => ({ | |
role: 'user', | |
content: messageContent(m), | |
})) | |
); | |
} | |
export async function decideWhoSpeaksNext( |
Lines 1 to 150 in d76e3ca
import { v } from 'convex/values'; | |
import { internal } from './_generated/api'; | |
import { Doc, Id } from './_generated/dataModel'; | |
import { DatabaseReader, DatabaseWriter, MutationCtx, internalMutation } from './_generated/server'; | |
import { TICK_DEBOUNCE, WORLD_IDLE_THRESHOLD } from './config'; | |
import { asyncMap, pruneNull } from './lib/utils'; | |
export const tick = internalMutation({ | |
args: { worldId: v.id('worlds'), noSchedule: v.optional(v.boolean()) }, | |
handler: async (ctx, { worldId, noSchedule }) => { | |
const ts = Date.now(); | |
// Fetch the first recent heartbeat. | |
if (!(await getRecentHeartbeat(ctx.db, worldId))) { | |
console.debug("Didn't tick: no heartbeat recently"); | |
return; | |
} | |
const world = await ctx.db.get(worldId); | |
if (!world) throw new Error("Didn't tick: No world found"); | |
if (world.frozen && !noSchedule) throw new Error("Didn't tick: world frozen"); | |
// Fetch agents to wake up: not already thinking | |
const agentDocs = await ctx.db | |
.query('agents') | |
.withIndex('by_worldId_thinking', (q) => q.eq('worldId', worldId).eq('thinking', false)) | |
.collect(); | |
if (!agentDocs.length) { | |
console.debug("Didn't tick: all agents thinking"); | |
return; | |
} | |
const agentsEagerToWake = agentDocs.filter((a) => a.nextWakeTs && a.nextWakeTs <= ts); | |
const agentIdsToWake = new Set([ | |
...agentsEagerToWake.flatMap((a) => [a._id, ...(a.alsoWake ?? [])]), | |
]); | |
const nextToWake = agentDocs.find((a) => !agentIdsToWake.has(a._id) && a.nextWakeTs > ts); | |
if (nextToWake && !nextToWake.scheduled) { | |
await ctx.db.patch(nextToWake._id, { scheduled: true }); | |
await ctx.scheduler.runAt(nextToWake.nextWakeTs, internal.engine.tick, { | |
worldId, | |
}); | |
} | |
if (!agentsEagerToWake.length) { | |
console.debug("Didn't tick: spurious, no agents eager to wake up"); | |
return; | |
} | |
const agentsToWake = pruneNull(await asyncMap(agentIdsToWake, ctx.db.get)).filter( | |
(a) => !a.thinking, | |
); | |
for (const agentDoc of agentsToWake) { | |
await ctx.db.patch(agentDoc._id, { thinking: true, lastWakeTs: ts }); | |
} | |
const playerIds = agentsToWake.map((a) => a.playerId); | |
await ctx.scheduler.runAfter(0, internal.agent.runAgentBatch, { playerIds, noSchedule }); | |
}, | |
}); | |
async function getRecentHeartbeat(db: DatabaseReader, worldId: Id<'worlds'>) { | |
return ( | |
db | |
.query('heartbeats') | |
// Tip: by fetching heartbeats this way, the transaction doesn't conflict | |
// with new heartbeats being added, assuming it wasn't the first heartbeat | |
.withIndex('by_creation_time', (q) => | |
q.gt('_creationTime', Date.now() - WORLD_IDLE_THRESHOLD), | |
) | |
.first() | |
); | |
} | |
export const agentDone = internalMutation({ | |
args: { | |
agentId: v.id('agents'), | |
otherAgentIds: v.optional(v.array(v.id('agents'))), | |
wakeTs: v.number(), | |
noSchedule: v.optional(v.boolean()), | |
}, | |
handler: async (ctx, args) => { | |
const agentDoc = await ctx.db.get(args.agentId); | |
if (!agentDoc) throw new Error(`Agent ${args.agentId} not found`); | |
if (!agentDoc.thinking) { | |
throw new Error('Agent was not thinking: did you call agentDone twice for the same agent?'); | |
} | |
const nextWakeTs = Math.ceil(args.wakeTs / TICK_DEBOUNCE) * TICK_DEBOUNCE; | |
await ctx.db.replace(args.agentId, { | |
playerId: agentDoc.playerId, | |
worldId: agentDoc.worldId, | |
thinking: false, | |
lastWakeTs: agentDoc.nextWakeTs, | |
nextWakeTs, | |
alsoWake: args.otherAgentIds, | |
scheduled: await enqueueAgentWake( | |
ctx, | |
args.agentId, | |
agentDoc.worldId, | |
nextWakeTs, | |
args.noSchedule, | |
), | |
}); | |
}, | |
}); | |
export async function enqueueAgentWake( | |
ctx: MutationCtx, | |
agentId: Id<'agents'>, | |
worldId: Id<'worlds'>, | |
atTs: number, | |
noSchedule?: boolean, | |
) { | |
// Future: Debounce wakups by looking 100ms into the future. | |
const nextScheduled = await ctx.db | |
.query('agents') | |
.withIndex('by_worldId_thinking', (q) => | |
q.eq('worldId', worldId).eq('thinking', false).lte('nextWakeTs', atTs), | |
) | |
.first(); | |
if (nextScheduled) { | |
if (!nextScheduled.scheduled) { | |
throw new Error("Next scheduled agent isn't scheduled: " + JSON.stringify(nextScheduled)); | |
} | |
// We are effectively scheduled since it'll wake up at the same time. | |
if (nextScheduled.nextWakeTs === atTs) { | |
return true; | |
} | |
// Another agent will be scheduled before us | |
if (nextScheduled._id !== agentId) { | |
return false; | |
} | |
} | |
if (!noSchedule) await ctx.scheduler.runAt(atTs, internal.engine.tick, { worldId }); | |
return true; | |
} | |
export const freezeAll = internalMutation({ | |
args: {}, | |
handler: async (ctx, args) => { | |
const worlds = await ctx.db.query('worlds').collect(); | |
for (const world of worlds) { | |
await ctx.db.patch(world._id, { frozen: true }); | |
} | |
}, | |
}); | |
export const unfreeze = internalMutation({ | |
args: { worldId: v.optional(v.id('worlds')) }, | |
handler: async (ctx, args) => { | |
const world = await ctx.db.query('worlds').order('desc').first(); | |
if (!world) throw new Error("Didn't unfreeze: No world found"); | |
await ctx.db.patch(world._id, { frozen: false }); | |
await ctx.scheduler.runAfter(0, internal.engine.tick, { worldId: world._id }); | |
}, |
Step 2: ⌨️ Coding
- Modify
convex/agent.ts
! No changes made
Modify convex/agent.ts with contents:
• Explain the `handleAgentInteraction` function to the user. This function is responsible for managing the interaction between agents and players. It determines the leader of the conversation and makes the players walk together and face each other.
- Check
convex/agent.ts
✗
Run `convex/agent.ts` through the sandbox.
- Modify
convex/conversation.ts
✓ f724484
Modify convex/conversation.ts with contents:
• Explain the `startConversation` function to the user. This function is responsible for initiating a conversation between the agent and the players. It fetches the agent's memories about the players and uses them to generate a conversation prompt.
• Explain the `chatCompletion` function to the user. This function generates the agent's response based on the conversation prompt.
- Check
convex/conversation.ts
✗
Sandbox logs for
trunk fmt convex/conversation.ts || exit 0
1/2 ✓Checked 1 file ✔ No issues
trunk check --fix --print-failures convex/conversation.ts
2/2 ❌ (`1`)ISSUES convex/conversation.ts:21:11 9:6 medium 'Relation' is defined but never used. eslint/@typescript-eslint/no-unused-vars 21:11 medium 'embedding' is assigned a value but never used. eslint/@typescript-eslint/no-unused-vars Checked 1 file 1 existing issue ✖ 1 new issue
- Modify
convex/conversation.ts
✓ 0529692
Modify convex/conversation.ts with contents: Remove the unused type 'Relation' from the file. This type is defined but not used anywhere in the code, so it can be safely removed.Next, remove the unused variable 'embedding'. This variable is assigned a value but is not used anywhere in the code. Therefore, the line of code where 'embedding' is assigned a value can be removed.
- Check
convex/conversation.ts
✓ 970ea0e
Sandbox logs for https://github.com/ykhli/cat-town/commit/970ea0e3cc8d925e62aad63437ca08ea7fa8a7a2
trunk fmt convex/conversation.ts || exit 0
1/2 ✓✔ Formatted convex/conversation.ts Re-checking autofixed files... Checked 1 file ✔ No issues
trunk check --fix --print-failures convex/conversation.ts
2/2 ✓Checked 1 file ✔ No issues
- Check
convex/conversation.ts
✓
Sandbox logs for
trunk fmt convex/conversation.ts || exit 0
1/2 ✓Checked 1 file ✔ No issues
trunk check --fix --print-failures convex/conversation.ts
2/2 ✓Checked 1 file ✔ No issues
Step 3: 🔁 Code Review
I have finished reviewing the code for completeness. I did not find errors for sweep/player-agent-interaction-explanation
.
🎉 Latest improvements to Sweep:
- Sweep now uses the
rope
library to refactor Python! Check out Large Language Models are Bad at Refactoring Code. To have Sweep refactor your code, trysweep: Refactor <your_file>.py
! - Sweep finds and fixes bugs in your code by writing unit tests! Check out Having GPT-4 Iterate on Unit Tests like a Human.
💡 To recreate the pull request edit the issue title or description. To tweak the pull request, leave a comment on the pull request.
Join Our Discord
我想要在斯坦福小镇里实现这个功能 问下需要添加哪些文件呢 或者说在这个猫镇上哪里的代码是表示这部分的 |
Details
No response
Checklist
convex/agent.ts
! No changes madeconvex/conversation.ts
✓ f724484convex/conversation.ts
✗convex/conversation.ts
✓ 0529692convex/conversation.ts
✓ 970ea0econvex/conversation.ts
✓The text was updated successfully, but these errors were encountered: