Add example: personalized interview simulator (closes #166) (#189)

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:
mmjwxbc authored and GitHub committed 2026-04-28 15:28:39 +08:00
1 parent 604324bedd
commit d8ee30cbec
7 files changed
+534 -1

No files matched your search

+1 -1
View File
@@ -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)
})
})