mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-22 02:53:26 -05:00
Adds an interactive interview simulator under examples/cookbook/ with interviewer + observer + reporter agents, manual SharedMemory orchestration, readline-driven candidate input, and a Zod-validated debrief.
This commit is contained in:
1 parent
604324bedd
commit
d8ee30cbec
7 files changed
+534
-1
No files matched your search
+1
-1
@@ -48,7 +48,6 @@ Reusable shapes for common multi-agent problems.
|
||||
| [`patterns/research-aggregation`](patterns/research-aggregation.ts) | Multi-source research collated by a synthesis agent. |
|
||||
| [`patterns/cost-tiered-pipeline`](patterns/cost-tiered-pipeline.ts) | Run the same four-stage pipeline twice to compare flagship vs tiered model cost. |
|
||||
| [`patterns/agent-handoff`](patterns/agent-handoff.ts) | Synchronous sub-agent delegation via `delegate_to_agent`. |
|
||||
|
||||
## cookbook — use-case recipes
|
||||
|
||||
End-to-end examples framed around a concrete problem (meeting summarization, translation QA, competitive monitoring, etc.) rather than a single orchestration primitive. Lighter bar than `production/`: no tests or pinned model versions required. Good entry point if you want to see how the patterns compose on a real task.
|
||||
@@ -59,6 +58,7 @@ End-to-end examples framed around a concrete problem (meeting summarization, tra
|
||||
| [`cookbook/contract-review-dag`](cookbook/contract-review-dag.ts) | 4-task DAG (extract → compliance-check + summary → notify) with step-level retry. Run normally or with `FORCE_FAIL=task2` to exercise retry. |
|
||||
| [`cookbook/incident-postmortem-dag`](cookbook/incident-postmortem-dag.ts) | 5-task DAG with three parallel root tasks (log patterns + deploy correlation + blast radius) feeding root-cause hypothesis and final postmortem synthesis. |
|
||||
| [`cookbook/competitive-monitoring`](cookbook/competitive-monitoring.ts) | Parallel source monitoring (Twitter/Reddit/News), contradiction detection, and aggregated intelligence reporting. |
|
||||
| [`cookbook/personalized-interview-simulator`](cookbook/personalized-interview-simulator.ts) | Interactive interviewer loop with observer flags, shared memory, and structured debrief. |
|
||||
|
||||
## integrations — external systems
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Personalized Interview Simulator (Interviewer + Observer)
|
||||
*
|
||||
* Demonstrates:
|
||||
* - A stateful `interviewer` agent using `Agent.prompt()` across turns
|
||||
* - A stateless `observer` agent that reads full transcript context between turns
|
||||
* - Manual `SharedMemory` seeding and prompt injection outside `runTeam()` / `runTasks()`
|
||||
* - Human input handled at app level with `readline`
|
||||
* - Structured debrief via Zod at the end of the interview loop
|
||||
*
|
||||
* Run:
|
||||
* npx tsx examples/cookbook/personalized-interview-simulator.ts
|
||||
*
|
||||
* Prerequisites:
|
||||
* ANTHROPIC_API_KEY env var must be set.
|
||||
*
|
||||
* Optional:
|
||||
* Set INTERVIEW_CANDIDATE_DIR to point at your own materials directory.
|
||||
* Expected files:
|
||||
* - resume.md
|
||||
* - project-notes.md
|
||||
* - code.ts
|
||||
* - job-description.md
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
import { stdin as input, stdout as output } from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { z } from 'zod'
|
||||
import { Agent, SharedMemory, ToolExecutor, ToolRegistry } from '../../src/index.js'
|
||||
import type { AgentConfig } from '../../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate materials
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DEFAULT_CANDIDATE_DIR = path.join(__dirname, '../fixtures/interview-candidate')
|
||||
const CANDIDATE_DIR = process.env.INTERVIEW_CANDIDATE_DIR ?? DEFAULT_CANDIDATE_DIR
|
||||
const MAX_TURNS = 10
|
||||
|
||||
function loadText(fileName: string): string {
|
||||
return readFileSync(path.join(CANDIDATE_DIR, fileName), 'utf-8').trim()
|
||||
}
|
||||
|
||||
const resumeText = loadText('resume.md')
|
||||
const projectNotes = loadText('project-notes.md')
|
||||
const codeSnippet = loadText('code.ts')
|
||||
const jobDescription = loadText('job-description.md')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured debrief
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DebriefSchema = z.object({
|
||||
questions_asked: z.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
why_it_mattered: z.string(),
|
||||
}),
|
||||
),
|
||||
weak_spots: z.array(z.string()),
|
||||
strong_spots: z.array(z.string()),
|
||||
overall_assessment: z.object({
|
||||
recommendation: z.enum(['strong-hire', 'hire', 'lean-hire', 'lean-no-hire', 'no-hire']),
|
||||
summary: z.string(),
|
||||
}),
|
||||
})
|
||||
type Debrief = z.infer<typeof DebriefSchema>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildAgent(config: AgentConfig): Agent {
|
||||
const registry = new ToolRegistry()
|
||||
const executor = new ToolExecutor(registry)
|
||||
return new Agent(config, registry, executor)
|
||||
}
|
||||
|
||||
async function readlineFromCandidate(prompt: string, rl: ReturnType<typeof createInterface>): Promise<string> {
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('INTERVIEWER')
|
||||
console.log('='.repeat(60))
|
||||
console.log(prompt)
|
||||
console.log()
|
||||
|
||||
const answer = await rl.question('Candidate > ')
|
||||
return answer.trim()
|
||||
}
|
||||
|
||||
function isExitAnswer(answer: string): boolean {
|
||||
const normalized = answer.trim().toLowerCase()
|
||||
return normalized === 'exit' || normalized === 'quit'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent configs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
console.log('[skip] This example needs ANTHROPIC_API_KEY.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const interviewerConfig: AgentConfig = {
|
||||
name: 'interviewer',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
systemPrompt: `You are a senior technical interviewer.
|
||||
|
||||
You are running a personalized interview grounded in:
|
||||
- the candidate's resume
|
||||
- project notes
|
||||
- a code sample
|
||||
- the target job description
|
||||
- observer flags written after each turn
|
||||
|
||||
Rules:
|
||||
- Ask exactly one question per turn.
|
||||
- Prefer probing follow-ups over switching topics too early.
|
||||
- Use the role expectations and candidate materials together.
|
||||
- If the observer flags a contradiction, vague answer, or missing dimension, use that.
|
||||
- Keep each question concise but sharp.
|
||||
- Do not answer your own question.
|
||||
- Do not output analysis, just the next interview question.`,
|
||||
maxTurns: 2,
|
||||
temperature: 0.3,
|
||||
}
|
||||
|
||||
const observerConfig: AgentConfig = {
|
||||
name: 'observer',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
systemPrompt: `You are an interview observer.
|
||||
|
||||
You will receive shared memory containing:
|
||||
- candidate resume
|
||||
- project notes
|
||||
- code sample
|
||||
- target job spec
|
||||
- the full interview transcript so far
|
||||
|
||||
Write compact flags for the next interviewer turn.
|
||||
Focus on:
|
||||
- contradictions with candidate claims
|
||||
- vague or incomplete answers
|
||||
- important dimensions not yet tested
|
||||
- specific follow-up hooks hidden in the answer
|
||||
|
||||
Output 3-6 short bullets. Be concrete. No prose introduction.`,
|
||||
maxTurns: 1,
|
||||
temperature: 0.2,
|
||||
}
|
||||
|
||||
const reporterConfig: AgentConfig = {
|
||||
name: 'reporter',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
systemPrompt: `You are writing a compact interview debrief.
|
||||
|
||||
Use the complete interview record to produce:
|
||||
- questions_asked: the most important questions and why each mattered
|
||||
- weak_spots: concise bullets for weak areas
|
||||
- strong_spots: concise bullets for strengths
|
||||
- overall_assessment: recommendation plus a short summary
|
||||
|
||||
Return JSON only, matching the schema exactly.`,
|
||||
maxTurns: 2,
|
||||
temperature: 0.1,
|
||||
outputSchema: DebriefSchema,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mem = new SharedMemory()
|
||||
|
||||
await mem.write('candidate', 'resume', resumeText)
|
||||
await mem.write('candidate', 'project-notes', projectNotes)
|
||||
await mem.write('candidate', 'code', codeSnippet)
|
||||
await mem.write('role', 'target-job-spec', jobDescription)
|
||||
|
||||
const interviewer = buildAgent(interviewerConfig)
|
||||
const observer = buildAgent(observerConfig)
|
||||
const reporter = buildAgent(reporterConfig)
|
||||
|
||||
const rl = createInterface({ input, output })
|
||||
|
||||
console.log('Personalized Interview Simulator')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`Candidate materials: ${CANDIDATE_DIR}`)
|
||||
console.log('Type "exit" or "quit" to stop early.')
|
||||
console.log()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let answer = ''
|
||||
let turnsCompleted = 0
|
||||
|
||||
try {
|
||||
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
||||
const ctx = await mem.getSummary()
|
||||
const result = await interviewer.prompt(
|
||||
turn === 0
|
||||
? [
|
||||
`Context:\n${ctx}`,
|
||||
'Ask the most probing opening question you can justify from the materials and role.',
|
||||
].join('\n\n')
|
||||
: [
|
||||
`Context:\n${ctx}`,
|
||||
`Candidate answer:\n${answer}`,
|
||||
'Ask the next question.',
|
||||
].join('\n\n'),
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Interviewer failed:', result.output)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
answer = await readlineFromCandidate(result.output, rl)
|
||||
if (isExitAnswer(answer)) {
|
||||
console.log('\nInterview stopped by candidate.')
|
||||
break
|
||||
}
|
||||
|
||||
turnsCompleted++
|
||||
await mem.write('interviewer', `turn-${turn}`, `Q: ${result.output}\nA: ${answer}`)
|
||||
|
||||
const observerResult = await observer.run(
|
||||
[
|
||||
'Review transcript and candidate materials. Write flags for the next turn.',
|
||||
await mem.getSummary(),
|
||||
].join('\n\n'),
|
||||
)
|
||||
|
||||
if (!observerResult.success) {
|
||||
console.error('Observer failed:', observerResult.output)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await mem.write('observer', 'flags', observerResult.output)
|
||||
}
|
||||
} finally {
|
||||
rl.close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured debrief
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('DEBRIEF')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
const debriefResult = await reporter.run(`Summarize the full interview:\n\n${await mem.getSummary()}`)
|
||||
|
||||
if (!debriefResult.success || !debriefResult.structured) {
|
||||
console.error('Debrief generation failed:', debriefResult.output)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const debrief = debriefResult.structured as Debrief
|
||||
|
||||
console.log(JSON.stringify(debrief, null, 2))
|
||||
console.log()
|
||||
console.log(`Turns completed: ${turnsCompleted}`)
|
||||
console.log('Done.')
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { pool } from './db.js'
|
||||
|
||||
export async function approveExpense(req: Request, res: Response): Promise<void> {
|
||||
const expenseId = req.params.id
|
||||
const approverId = req.body.approverId
|
||||
|
||||
if (!expenseId || !approverId) {
|
||||
res.status(400).json({ error: 'missing fields' })
|
||||
return
|
||||
}
|
||||
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
const expense = await client.query(
|
||||
'SELECT id, status, amount_cents FROM expenses WHERE id = $1',
|
||||
[expenseId],
|
||||
)
|
||||
|
||||
if (expense.rowCount === 0) {
|
||||
await client.query('ROLLBACK')
|
||||
res.status(404).json({ error: 'expense not found' })
|
||||
return
|
||||
}
|
||||
|
||||
await client.query(
|
||||
'UPDATE expenses SET status = $1, approved_by = $2, approved_at = NOW() WHERE id = $3',
|
||||
['approved', approverId, expenseId],
|
||||
)
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO audit_log(entity_id, action, actor_id) VALUES ($1, $2, $3)',
|
||||
[expenseId, 'expense_approved', approverId],
|
||||
)
|
||||
|
||||
await client.query('COMMIT')
|
||||
res.status(200).json({ ok: true })
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK')
|
||||
res.status(500).json({ error: 'internal error' })
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Target Job Spec
|
||||
|
||||
Role: Backend Engineer (Payments and Workflow Systems)
|
||||
|
||||
Expectations:
|
||||
- Strong fundamentals in API design, database transactions, and failure handling
|
||||
- Able to reason about idempotency, retries, concurrency, and data consistency
|
||||
- Comfortable explaining tradeoffs between implementation simplicity and operational robustness
|
||||
- Can debug production issues using logs, metrics, and traces
|
||||
|
||||
Interview focus:
|
||||
- project ownership and decision making
|
||||
- correctness under retries and duplicated external events
|
||||
- transaction boundaries and race conditions
|
||||
- scaling path from in-process jobs to durable queues
|
||||
@@ -0,0 +1,15 @@
|
||||
# Project Notes
|
||||
|
||||
Current showcase project:
|
||||
- Team expense management platform for small businesses
|
||||
- Backend written in TypeScript with Express and PostgreSQL
|
||||
- Background jobs process receipt OCR results and reimbursement approvals
|
||||
|
||||
Implementation details worth probing:
|
||||
- Uses Redis for short-lived deduplication keys on webhook ingestion
|
||||
- Exposes `/expenses/:id/approve` for manager approval actions
|
||||
- Runs nightly reconciliation jobs against third-party accounting exports
|
||||
|
||||
Known tradeoffs:
|
||||
- No queue system yet; background work is triggered in-process
|
||||
- Approval flow is eventually consistent during third-party sync windows
|
||||
@@ -0,0 +1,13 @@
|
||||
# Candidate Resume
|
||||
|
||||
Name: Maya Lin
|
||||
|
||||
Summary:
|
||||
- 2 years of backend-focused software engineering experience
|
||||
- Primary stack: TypeScript, Node.js, PostgreSQL, Redis
|
||||
- Comfortable with Docker, CI pipelines, and REST API design
|
||||
|
||||
Selected claims:
|
||||
- Led the redesign of an internal notification service that reduced alert latency by 40%
|
||||
- Built idempotent payment retry logic for failed checkout webhooks
|
||||
- Improved production incident response by adding structured logging and tracing
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { Agent } from '../src/agent/agent.js'
|
||||
import { AgentRunner } from '../src/agent/runner.js'
|
||||
import { SharedMemory } from '../src/memory/shared.js'
|
||||
import { ToolExecutor } from '../src/tool/executor.js'
|
||||
import { ToolRegistry } from '../src/tool/framework.js'
|
||||
import type { AgentConfig, LLMAdapter, LLMResponse } from '../src/types.js'
|
||||
|
||||
const DebriefSchema = z.object({
|
||||
questions_asked: z.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
why_it_mattered: z.string(),
|
||||
}),
|
||||
),
|
||||
weak_spots: z.array(z.string()),
|
||||
strong_spots: z.array(z.string()),
|
||||
overall_assessment: z.object({
|
||||
recommendation: z.enum(['strong-hire', 'hire', 'lean-hire', 'lean-no-hire', 'no-hire']),
|
||||
summary: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
function mockAdapter(responses: string[]): LLMAdapter {
|
||||
let callIndex = 0
|
||||
return {
|
||||
name: 'mock',
|
||||
async chat() {
|
||||
const text = responses[callIndex++] ?? ''
|
||||
return {
|
||||
id: `mock-${callIndex}`,
|
||||
content: [{ type: 'text' as const, text }],
|
||||
model: 'mock-model',
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
} satisfies LLMResponse
|
||||
},
|
||||
async *stream() {
|
||||
/* unused */
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildMockAgent(config: AgentConfig, responses: string[]): Agent {
|
||||
const adapter = mockAdapter(responses)
|
||||
const registry = new ToolRegistry()
|
||||
const executor = new ToolExecutor(registry)
|
||||
const agent = new Agent(config, registry, executor)
|
||||
const runner = new AgentRunner(adapter, registry, executor, {
|
||||
model: config.model,
|
||||
systemPrompt: config.systemPrompt,
|
||||
maxTurns: config.maxTurns,
|
||||
maxTokens: config.maxTokens,
|
||||
temperature: config.temperature,
|
||||
agentName: config.name,
|
||||
})
|
||||
;(agent as any).runner = runner
|
||||
return agent
|
||||
}
|
||||
|
||||
describe('personalized interview simulator architecture', () => {
|
||||
it('uses shared memory between interviewer turns and ends with structured debrief', async () => {
|
||||
const mem = new SharedMemory()
|
||||
|
||||
await mem.write('candidate', 'resume', 'Built idempotent payment retry logic.')
|
||||
await mem.write('candidate', 'project-notes', 'Uses Redis keys to deduplicate webhook events.')
|
||||
await mem.write('candidate', 'code', 'POST /webhooks/stripe retries failed events.')
|
||||
await mem.write('role', 'target-job-spec', 'Test idempotency, transactions, and failure handling.')
|
||||
|
||||
const interviewer = buildMockAgent(
|
||||
{
|
||||
name: 'interviewer',
|
||||
model: 'mock-model',
|
||||
systemPrompt: 'Ask one probing question at a time.',
|
||||
maxTurns: 2,
|
||||
},
|
||||
[
|
||||
'You mentioned idempotent retry logic. How did you prevent duplicate payment state transitions?',
|
||||
'Walk me through the transaction boundary you chose when a webhook retry arrives mid-update.',
|
||||
],
|
||||
)
|
||||
|
||||
const observer = buildMockAgent(
|
||||
{
|
||||
name: 'observer',
|
||||
model: 'mock-model',
|
||||
systemPrompt: 'Write compact observer flags.',
|
||||
maxTurns: 1,
|
||||
},
|
||||
[
|
||||
'- Candidate named idempotency keys but did not explain expiry choice.\n- Concurrency handling not yet tested.',
|
||||
'- Transaction boundary explanation was good.\n- Still probe race conditions around audit logging.',
|
||||
],
|
||||
)
|
||||
|
||||
const debriefPayload = {
|
||||
questions_asked: [
|
||||
{
|
||||
question: 'How did you prevent duplicate payment state transitions?',
|
||||
why_it_mattered: 'The role requires strong reasoning about idempotency under retries.',
|
||||
},
|
||||
{
|
||||
question: 'What transaction boundary did you choose during webhook retries?',
|
||||
why_it_mattered: 'The role expects correctness under concurrent updates.',
|
||||
},
|
||||
],
|
||||
weak_spots: [
|
||||
'Did not justify idempotency key expiry tradeoff.',
|
||||
],
|
||||
strong_spots: [
|
||||
'Explained transaction boundaries clearly.',
|
||||
],
|
||||
overall_assessment: {
|
||||
recommendation: 'lean-hire',
|
||||
summary: 'Good backend fundamentals with one notable gap around retry-window tradeoffs.',
|
||||
},
|
||||
}
|
||||
|
||||
const reporter = buildMockAgent(
|
||||
{
|
||||
name: 'reporter',
|
||||
model: 'mock-model',
|
||||
systemPrompt: 'Return a structured debrief.',
|
||||
maxTurns: 2,
|
||||
outputSchema: DebriefSchema,
|
||||
},
|
||||
[JSON.stringify(debriefPayload)],
|
||||
)
|
||||
|
||||
let answer = ''
|
||||
for (let turn = 0; turn < 2; turn++) {
|
||||
const ctx = await mem.getSummary()
|
||||
const prompt = turn === 0
|
||||
? `Context:\n${ctx}\n\nAsk the most probing opening question you can justify from the materials and role.`
|
||||
: `Context:\n${ctx}\n\nCandidate answer:\n${answer}\n\nAsk the next question.`
|
||||
|
||||
const questionResult = await interviewer.prompt(prompt)
|
||||
expect(questionResult.success).toBe(true)
|
||||
|
||||
answer = turn === 0
|
||||
? 'We used a Redis idempotency key keyed by provider event id before mutating payment state.'
|
||||
: 'We wrapped the expense update and audit write in one database transaction.'
|
||||
|
||||
await mem.write('interviewer', `turn-${turn}`, `Q: ${questionResult.output}\nA: ${answer}`)
|
||||
|
||||
const observerResult = await observer.run(
|
||||
`Review transcript and candidate materials; write flags for the next turn:\n${await mem.getSummary()}`,
|
||||
)
|
||||
|
||||
expect(observerResult.success).toBe(true)
|
||||
await mem.write('observer', 'flags', observerResult.output)
|
||||
}
|
||||
|
||||
const summary = await mem.getSummary()
|
||||
expect(summary).toContain('candidate')
|
||||
expect(summary).toContain('role')
|
||||
expect(summary).toContain('interviewer')
|
||||
expect(summary).toContain('observer')
|
||||
expect(summary).toContain('Still probe race conditions around audit logging')
|
||||
|
||||
const debrief = await reporter.run(`Summarize the full interview:\n${summary}`)
|
||||
expect(debrief.success).toBe(true)
|
||||
expect(debrief.structured).toBeDefined()
|
||||
expect(DebriefSchema.parse(debrief.structured)).toEqual(debriefPayload)
|
||||
|
||||
const history = interviewer.getHistory()
|
||||
expect(history.length).toBe(4)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user